老话题,ListView 源码解析

Android · coderen · 于 发布 · 最后由 d_clock回复 · 212 次阅读
96

源码解析的习惯也要开始养成呀,ListView 的一些工作流程当时看懂了,可过了一段时间再看彻底忘了,这次又花了时间顺了一遍,所以必须要记下来了,以便跟 RecyclerView 作对比,所以先看 ListView

继承结构

ListView 和 GridView 都继承自 AbListView ,AbListView 继承自 AdapterView,再往上就是 ViewGroup 和 View 了

在使用 ListView 时,我们需要 Adpater 来适配 ListView 的工作,这样用到了适配器模式,ListView 只负责显示数据和处理交互, 至于显示什么数据,这个工作就交给了 Adapter,这样在 ListView 需要数据时就跟 Adapter 来要,减轻了 ListView 的工作量,并为 ListView 可以展示不同的数据提供了实现。

我们学习过 ListView 的时候,都知道 ListView 不但以列表形式展示,并且 item 还能实现复用,这样即使显示上千条数据,内存中 ListView 的 item 的 View 对象也就那么几个,至于 ListView 的 item 复用是如何实现的,今天的解析就是为了搞清楚这个。

RecycleBin

首先我们看一个 AbListView 的内部类,RecycleBin ,这个类在 LisView 工作中起到了关键性作用。主要解析 RecycleBin 的 5 个方法

RecycleBin 中的代码很多,我只选了重要的 5 个方法,这五个方法都加了注释,可以先稍微了解一下,接下来分析 ListView 的时候再回头看会更加清晰。

class AbListView {

class RecycleBin {

/**
* 在 ListView 的 item 只有一种布局时,存储废弃 View 的集合
/
private ArrayList<View> mCurrentScrap;
/
*
* 在 ListViwe 有多种布局时,这个数组中的每一项都是一种布局的集合
*/
private ArrayList<View>[] mScrapViews;

/**
* 当前 ListView 中显示的 View
*/
private View[] mActiveViews = new View[0];

/**
* 将 ListView 中的 View 都放入 mActivieViews 数组中
* childCount 要存入的 View 的数量
* firstActivePosition 第一个可见的 View 的 position
*/
void fillActiveViews(int childCount, int firstActivePosition) {

if (mActiveViews.length < childCount) {
mActiveViews = new View[childCount];
}

mFirstActivePosition = firstActivePosition;

final View[] activeViews = mActiveViews;
for (int i = 0; i < childCount; i++) {// 遍历 AbListView 中的子 View ,把每一个都放入 mActiveViews 中
View child = getChildAt(i);
AbsListView.LayoutParams lp = (AbsListView.LayoutParams) child.getLayoutParams();

if (lp != null && lp.viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {

activeViews[i] = child;
// Remember the position so that setupChild() doesn't reset state.
lp.scrappedFromPosition = firstActivePosition + i;
}
}
}

/**
* 与 fillActiveViews 相对,getActiveView 方法作用为从 mActiveViews 中取出相应位置的 View,并移除 mActiveViews 中 position 位置的 View
* 说明 mActiveViews 中每个位置的 View 只能使用一次,除非重新赋值
*/
View getActiveView(int position) {
int index = position - mFirstActivePosition;
final View[] activeViews = mActiveViews;
if (index >=0 && index < activeViews.length) {
final View match = activeViews[index];
activeViews[index] = null;
return match;
}
return null;
}

/**
* 根据 View 在 AbListView 中的 Type ,存储废弃的 View,例如滚动出屏幕的 View
*/
void addScrapView(View scrap, int position) {
...
final AbsListView.LayoutParams lp = (AbsListView.LayoutParams) scrap.getLayoutParams();
final int viewType = lp.viewType; // 获取 Type 的值
...
if (mViewTypeCount == 1) {
mCurrentScrap.add(scrap); // 如果只有一种则添加到 mCurrentScrap 中
} else {
mScrapViews[viewType].add(scrap); // 如果有多种,则放入对应的集合中
}
...
}

/**
* @return A view from the ScrapViews collection. 找到废弃 View 的数组中 itemId 跟 position 处 itemId 相同的 View 从数组中移除,并将 View 返回
*/
View getScrapView(int position) {
final int whichScrap = mAdapter.getItemViewType(position); // 需要获取的 Viwe 的 Type
if (whichScrap < 0) {
return null;
}
if (mViewTypeCount == 1) {
return retrieveFromScrap(mCurrentScrap, position); // 只有一种时,返回 mCurrentScrap 中对应位置 View
} else if (whichScrap < mScrapViews.length) {
return retrieveFromScrap(mScrapViews[whichScrap], position); // 多种情况是,返回对应种类集合中的 View
}
return null;
}

/**
* 设置 ListView 中 item 的种类,并初始化存储废弃 View 的集合
*/
public void setViewTypeCount(int viewTypeCount) {
if (viewTypeCount < 1) {
throw new IllegalArgumentException("Can't have a viewTypeCount < 1");
}
//noinspection unchecked
ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount];
for (int i = 0; i < viewTypeCount; i++) {
scrapViews[i] = new ArrayList<View>();
}
mViewTypeCount = viewTypeCount;
mCurrentScrap = scrapViews[0];
mScrapViews = scrapViews;
}
}
}

ListView 的工作流程

ListView 在显示时,也是通过 onMeasure,onLayout,onDraw 的流程,onMeasure 过程很简单,因为 ListView 一般都是固定宽高或者宽高最大;onDraw 更简单,绘制的过程都是每个子 View 自己绘制自己,ListViwe 不用管绘制阶段;只有 onLayout 过程是有实际工作意义的,所以我们直接来看 onLayout 方法,onLayout 方法的实现是在 AbListView 中的,onLayout 方法中主要就是调用 layoutChildren 来布局子 View,这个方法就切换到 ListView 中了

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);

...

layoutChildren(); // 布局 子 view

...
}

@Override
protected void layoutChildren() {
...
final int childCount = getChildCount();
if (dataChanged) { // 判断 数据集 是否变化
for (int i = 0; i < childCount; i++) {
recycleBin.addScrapView(getChildAt(i), firstPosition+i);
}
} else { // ListView 刚进来时,进去当然是没变化的,所以执行这里,recycleBin 就是刚开始提到的 RecycleBin 的实例,fillActiveViews 将子 View 添加到 RcycleBin 的响应集合里,由于第一次进来是没有子 View 的,所以这行代码无效果
recycleBin.fillActiveViews(childCount, firstPosition);
}

...
switch (mLayoutMode) { // 布局模式,一般都是默认
default:
if (childCount == 0) { // 第一次 onLayout 时 子 View 个数为 0
if (!mStackFromBottom) { // 从上往下加载
final int position = lookForSelectablePosition(0, true);
setSelectedPositionInt(position);
sel = fillFromTop(childrenTop);
} else { // 从下往上加载
final int position = lookForSelectablePosition(mItemCount - 1, false);
setSelectedPositionInt(position);
sel = fillUp(mItemCount - 1, childrenBottom);
}
} else {
...
}
break;

}
}

我们来看 layoutChildre 方法,数据没有变化时会调用 recycleBin.fillActiveViews 的方法,这是子 View 的个数为 0 ,所以这行代码无效,接着到了根据布局模式填充数据的阶段,一般都是默认模式。接下来判断从上往下填充活着从下往上填充 ListView,这个是由 ListView 使用者设置的,默认从上往下。

// 从上往下填充
private View fillFromTop(int nextTop) {
// 检查第一个位置的有效性
mFirstPosition = Math.min(mFirstPosition, mSelectedPosition);
mFirstPosition = Math.min(mFirstPosition, mItemCount - 1);
if (mFirstPosition < 0) {
mFirstPosition = 0;
}
return fillDown(mFirstPosition, nextTop); // 填充数据
}

private View fillDown(int pos, int nextTop) {
View selectedView = null;

...

// 循环填充数据
while (nextTop < end && pos < mItemCount) { // 下一个 Viwe 的 top 位置还不到 listView 的底部,并且当前 position 小于数据的数量时继续填充
// is this the selected item?
boolean selected = pos == mSelectedPosition;

// 由 位置,需要添加的 Viwe 的顶部在 ListView 中的位置,padding 值的构造子 View 并添加到 ListView 中
View child = makeAndAddView(pos, nextTop, true, mListPadding.left, selected);

nextTop = child.getBottom() + mDividerHeight; // 计算下一个子 View 的顶部位置
if (selected) {
selectedView = child;
}
pos++;
}

setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1);
return selectedView;
}

// 从下往上填充的情况
private View fillUp(int pos, int nextBottom) {
View selectedView = null;

int end = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
end = mListPadding.top;
}

// 循环填充
while (nextBottom > end && pos >= 0) { // 从下往上填充,判断的是下一个 View 的底部是否已经出了屏幕位置,如果出了屏幕则不用添加
// is this the selected item?
boolean selected = pos == mSelectedPosition;
View child = makeAndAddView(pos, nextBottom, false, mListPadding.left, selected); // 构造 View 并填充到 ListView
nextBottom = child.getTop() - mDividerHeight; // 下一个 Viwe 的底部位置
if (selected) {
selectedView = child;
}
pos--;
}

mFirstPosition = pos + 1;
setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1);
return selectedView;
}

由代码我们看到不管是从下往上填充还是从上往下填充,都会调用一个 makeAndAddView 方法来构造 View 并将其填充到 ListView 中

// 构造 View 并填充 ListView
private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
boolean selected) {

if (!mDataChanged) { // 数据集没变化时
final View activeView = mRecycler.getActiveView(position); // 从 RecycleBin 中获取当前位置的 View ,由于第一次加载界面,该 View 为空
if (activeView != null) {
setupChild(activeView, position, y, flow, childrenLeft, selected, true);
return activeView;
}
}

final View child = obtainView(position, mIsScrap); // 在 RecycleBin 中没有需要的 View 时,调用 obtainView 方法来构造 View
// This needs to be positioned and measured.
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]); // 将 View 填充到 ListView

return child;
}

第一次加载时在 RecycleBin 中不能获取到对象 view, 则通过 obtainView 方法来构造 Viwe,该方法在 AbListView 中.

View obtainView(int position, boolean[] outMetadata) {

...

final View scrapView = mRecycler.getScrapView(position); // 从 RecycleBin 中的废弃 View 中取出需要的 View ,由于是第一次加载,返回为空

final View child = mAdapter.getView(position, scrapView, this); // 由 Adapter 的 getView 方法来获取 View

if (scrapView != null) {
if (child != scrapView) {
mRecycler.addScrapView(scrapView, position);
} else if (child.isTemporarilyDetached()) {
...
}
}

setItemViewLayoutParams(child, position);

return child; // 返回 Viwe
}

obtainView 方法中会首先从 RecycleBin 中的废弃 View 的集合中来获取 View,第一次加载时是获取不到的,所以同 mAdapter.getView 方法来获取 View,getView 方法就是我们定义 Adapter 时重写的那个方法。在 scrapView 为空时,我们会通过 LayoutInflater 来加载一个 View 赋值给 child,最后将child 返回。makeAndAddView 方法中调用 obtainView 获取到 View 之后,在调用 setupChild 方法,其中调用 attachViewToParent 将 Viwe 填充到 ListView 中。加载时的第一次 onLayout 过程结束

由于 Android 界面加载机制,onLayout 方法会调用两次,第一次 onLayout 时我们上面已经分析过了,如果再加载一次,难道数据会添加两次? 我们接着回头看第二次 onLayout 的过程。其中 onLayout 还是会调用 layoutChildren 方法,不过执行的逻辑确跟第一次不同了

@Override
protected void layoutChildren() {
...
final int childCount = getChildCount();
if (dataChanged) { // 判断 数据集 是否变化
for (int i = 0; i < childCount; i++) {
recycleBin.addScrapView(getChildAt(i), firstPosition+i);
}
} else { // 这已经是第二季加载,ListView 中已经是有数据了,这时候将 ListView 中的所有 View 都添加到 recycleBind 中的存储 ListView 子 View 的集合中
recycleBin.fillActiveViews(childCount, firstPosition);
}

...

detachAllViewsFromParent(); // 移除 ListView 中所有的 Viwe

...
switch (mLayoutMode) { // 布局模式,一般都是默认
default:
if (childCount == 0) { // 第一次 onLayout 时 子 View 个数为 0 时的加载
...
} else { // 第二次加载时 childCount 已经不是 0
if (mSelectedPosition >= 0 && mSelectedPosition < mItemCount) {
sel = fillSpecific(mSelectedPosition,
oldSel == null ? childrenTop : oldSel.getTop());
} else if (mFirstPosition < mItemCount) {
sel = fillSpecific(mFirstPosition,
oldFirst == null ? childrenTop : oldFirst.getTop());
} else {
sel = fillSpecific(0, childrenTop);
}
}
break;

}
}

第二次 onLayout 时,layoutChildren 方法的执行已经跟第一次不一样,这是会将 ListView 中所有的 View 添加到 RecycleBin 中,然后移除 ListView 中所有的 View,再调用 fillSpecific 方法来填充 ListView 。

private View fillSpecific(int position, int top) {
boolean tempIsSelected = position == mSelectedPosition;
View temp = makeAndAddView(position, top, true, mListPadding.left, tempIsSelected);
// Possibly changed again in fillUp if we add rows above this one.
mFirstPosition = position;

View above;
View below;

final int dividerHeight = mDividerHeight;
if (!mStackFromBottom) { // 从上往下加载
above = fillUp(position - 1, temp.getTop() - dividerHeight);
// This will correct for the top of the first view not touching the top of the list
adjustViewsUpOrDown();
below = fillDown(position + 1, temp.getBottom() + dividerHeight);
int childCount = getChildCount();
if (childCount > 0) {
correctTooHigh(childCount);
}
} else {// 从下往加载
below = fillDown(position + 1, temp.getBottom() + dividerHeight);
// This will correct for the bottom of the last view not touching the bottom of the list
adjustViewsUpOrDown();
above = fillUp(position - 1, temp.getTop() - dividerHeight);
int childCount = getChildCount();
if (childCount > 0) {
correctTooLow(childCount);
}
}

...
}

fillSpecific 方法里面还是会根据 ListView 的填充模式从下往上或者从上往下来填充,不过由于之前已经加载过,被选中的 position 的值可能不为 0 ,这时候会从被选中的位置向两头加载。同样是调用 fillUp 和 fillDown 方法,这两个方法的执行跟第一次 onLayout 没什么区别,都是根据当前视图中最后一个 View 的位置和数据的数量来判断是否需要继续添加 View,不过其内部构造 View 时调用的 makeAndAddView 确有了一点不同。由于 从 RecycleBin 中取到了数据,就不需要调用 obtainView 方法来获取 View ,直接将 RecycleBin 中取到的 view 填充到 ListView 中,通过 setupChild 方法中的 attachViewToParent 方法完成填充。

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
boolean selected) {
if (!mDataChanged) {

final View activeView = mRecycler.getActiveView(position); // 由于第二次 onLayout 开始就把 ListView 中原来的 view 添加到 RecycleBin 中了,所有 activeView 有值

if (activeView != null) {
// Found it. We're reusing an existing child, so it just needs
// to be positioned like a scrap view.
setupChild(activeView, position, y, flow, childrenLeft, selected, true); // 将从 RecycleBin 中取到的值填充到 ListView 中
return activeView; // 返回 RecycleBin 中得到的 View
}
}

// Make a new view for this position, or convert an unused view if
// possible.
final View child = obtainView(position, mIsScrap);

// This needs to be positioned and measured.
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);

return child;
}

这样第一次 onLayout 和第二次 onLayout 方法就调用完成了,ListView 显示到界面上时也就可以展示数据了。只有在第一次加载时才从 Adapter 中获取View,第二次加载时首先为 RecycleBin 添加数据,ListView 再需要数据时直接从 RecycleBin 中获取数据即可。

ListView 滑动过程中的视图填充

在 ListView 滑动过程中 ListView 中的视图是会随时变化的,所以我们必须要分析滑动过程中 ListView 的工作过程,滑动中处理主要在 onTouchEvent 方法中。由于滑动过程中的触摸事件是 EVENT_MOVE 所以我们只看移动过程中的代码,接着追踪到了 onTouchMove 方法中

@Override
public boolean onTouchEvent(MotionEvent ev) {

initVelocityTrackerIfNotExists();
final MotionEvent vtev = MotionEvent.obtain(ev);
final int actionMasked = ev.getActionMasked();
...
switch (actionMasked) {
...
case MotionEvent.ACTION_MOVE: {
onTouchMove(ev, vtev);
break;
}
...
}

if (mVelocityTracker != null) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
return true;
}

ListView 的滑动过程中,在 onTouchMove 中对应的情况为 TOUCH_MODE_SCROLL ,接着追踪到了 scrollFNeeded 方法中,该方法中通过调用 trackMotionScroll 方法来完成滑动过程中的填充任务,trackMotionScroll 方法的两个参数为 deltaY 为从 ACTION_DOWN 到当前位置移动的距离,incrementalDelaY 当前距离上一个事件时发送的距离变化

boolean trackMotionScroll(int deltaY, int incrementalDeltaY) {
...

// 如果 incrementalDeltaY 大于 0 ,说明手指下滑,屏幕中 ListView 向上滚动,向下拉状态,要显示的 View 从顶部出现

// 如果 incrementalDeltaY 小于 0 ,说明手指上滑,屏幕中 ListView 向下滚动,向上拉状态,要显示的 View 从底部出现

final boolean down = incrementalDeltaY < 0;

int start = 0;
int count = 0;

if (down) { // 上拉状态,View 从底部填充,同时 View 从顶部滑出 ListView 的区域
int top = -incrementalDeltaY; // 手指滑动的长度
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
top += listPadding.top; // 手指滑动的长度加上 ListViwe 的顶部的 padding 值
}
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
if (child.getBottom() >= top) { // View 的底部加上手指滑动的距离如果比 top 值大,说明该 View 还有部分或整体都在 ListView 中,不能回收
break;
} else { // 如果 View 的底部的值加滑动的距离比 top 值小,说明该 View 经过滑动之后会滑出 ListView 的区域,需要回收
count++; // 需要回收的数量加 1
int position = firstPosition + i;
if (position >= headerViewsCount && position < footerViewsStart) {
...
mRecycler.addScrapView(child, position); // 将需要回收的 view 放入 RecycleBin 中的回收集合中
}
}
}
} else { // 下拉状态,数据从顶部填充,同时 View 从底部移出
int bottom = getHeight() - incrementalDeltaY;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
bottom -= listPadding.bottom;
}
for (int i = childCount - 1; i >= 0; i--) {
final View child = getChildAt(i);
if (child.getTop() <= bottom) {
break;
} else {
start = i;
count++;
int position = firstPosition + i;
if (position >= headerViewsCount && position < footerViewsStart) {
// The view will be rebound to new data, clear any
// system-managed transient state.
child.clearAccessibilityFocus();
mRecycler.addScrapView(child, position);
}
}
}
}

if (count > 0) {
detachViewsFromParent(start, count); // 需要移除的 view 移出 ListView
mRecycler.removeSkippedScrap();
}

offsetChildrenTopAndBottom(incrementalDeltaY); // 将还存在的 view 根据滑动距离做出偏移

// 判断原来最顶部的 View 的在 ListView 中的高度小于滑动的高度,或者最底部的 View 在 ListView 中的高度小于滑动的高度,这时候必然有 View 滑出屏幕
if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {
fillGap(down); // 有 View 滑出屏幕是需要新的 View 填充屏幕
}

}

trackMotionScroll 方法中,就根据滑动的距离,筛选出经过滑动之后移除屏幕的 View 并添加到 RecycleBin 中存储废弃 View 的集合中,接着会调用 detachViewFromParent 将需要移除的 view 移出 ListView,还会将仍在布局中的 View 根据滑动距离做出移动,最后如果有 View 移出屏幕,则通过 fillGap 方法充新的 View

void fillGap(boolean down) {
final int count = getChildCount();
if (down) { // 向上拉,从底部填充 view
int paddingTop = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
paddingTop = getListPaddingTop();
}
final int startOffset = count > 0 ? getChildAt(count - 1).getBottom() + mDividerHeight :
paddingTop;
fillDown(mFirstPosition + count, startOffset);
correctTooHigh(getChildCount());
} else { // 向下拉,从顶部填充 view
int paddingBottom = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
paddingBottom = getListPaddingBottom();
}
final int startOffset = count > 0 ? getChildAt(0).getTop() - mDividerHeight :
getHeight() - paddingBottom;
fillUp(mFirstPosition - 1, startOffset);
correctTooLow(getChildCount());
}
}

又到了,fillDown 和 fillUp 方法,还记得吗,上面 onLayout 过程中填充 ListView 的方法,其中会调用 makeAndAddView 方法来获取 View

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
boolean selected) {
if (!mDataChanged) {
// Try to use an existing view for this position.
final View activeView = mRecycler.getActiveView(position); // 由于在 ListView 的第二次 onLayout 过程中将 RecycleBin.getActiveView 中存储的 View 已经全部获取,所以这里会取得 null
if (activeView != null) {
// Found it. We're reusing an existing child, so it just needs
// to be positioned like a scrap view.
setupChild(activeView, position, y, flow, childrenLeft, selected, true);
return activeView;
}
}

// Make a new view for this position, or convert an unused view if
// possible.
final View child = obtainView(position, mIsScrap); // 从而接着从 obtainView 方法中获取 View

// This needs to be positioned and measured.
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);

return child;
}

这次 obtainView 中的执行跟第一次 onLayout 时就不一样了,从 RecycleBin 中的废弃 View 中取出需要的 View ,由于滑出屏幕的 View 已经添加到RecycleBin 中,所以这里不为 null,在 Adapter.getView 时就可以重用被回收的 View ,实现了 ListView 虽然有千百条数据,但是真正的 item View 确只有很少的几个。

View obtainView(int position, boolean[] outMetadata) {

...

final View scrapView = mRecycler.getScrapView(position); // 从 RecycleBin 中的废弃 View 中取出需要的 View ,由于滑出屏幕的 View 已经添加到了 RecycleBin 中,所以这里不为 null

final View child = mAdapter.getView(position, scrapView, this); // 由 Adapter 的 getView 方法来获取 View,将 scrapView 传入该方法,也就是 AdatperView 的 getView 的 convertView 是有值的,这时我们不会重新构造 View,而是复用 convertView ,这样就实现了 ListView 滑动是的 View 重用

if (scrapView != null) {
if (child != scrapView) {
mRecycler.addScrapView(scrapView, position);
} else if (child.isTemporarilyDetached()) {
...
}
}

setItemViewLayoutParams(child, position);

return child; // 返回 Viwe
}

到这里,整个 ListView 源码的分析就要结束了,主要的内容是两次 onLayout 过程中的处理,和 ListView 在滑动过程中 View 的回收和复用的过程。

在 ListView 滑动之后,还会调用 invalidate 方法来申请重新绘制,这个过程又会调用依次 onLayout 这时的 onLayout 执行过程就如果显示到屏幕上时第二次调用 onLayout 的过程,会将 View 添加到 RecycleBin ,移除所有 View ,再从 RecycleBin 中获取 View 添加到 ListView 中。整个过程执行结束。

共收到 1 条回复
30
d_clock · #1 ·

排版太乱了啊。。。。。。看得费劲,望调整。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册