谈谈RecyclerView的LayoutManager-LinearLayoutManager源码分析
今天我们来好好谈谈LayoutManager的问题。
前言
LayoutManager是RecyclerView用来管理子view布局的一个组件(另一个组件应该是Recycler,负责回收视图),它主要负责三个事情:
-
布局子视图
-
在滚动过程中根据子视图在布局中所处的位置,决定何时添加子视图和回收视图。
-
滚动子视图
其中,只有滚动子视图,才会需要对子视图回收或者添加,而添加子视图则必然伴随着对所添加对象的布局处理。在滚动过程中,添加一次子视图只会影响到被添加对象,原有子视图的相对位置不会变化。
LayoutManager是RecyclerView的一个抽象内部类,一般我们使用它都是使用它的子类,常用的有LinearLayoutManager,GridLayoutManager,StaggeredGridLayoutManager,它们都是sdk自带的,实现了几种常用的布局。这里就不介绍它们的用法了。
你也可以自定义一个LayoutManager,但是在你自定义之前,你必须分析现有的LayoutManager。这篇文章就是从分析LinearLayoutManager入手,来深入的理解LayoutManager这个东西。相信在看了LinearLayoutManager的源码之后,你对布局管理器会有深入的认识。
准备工作
我首先把SDK中LinearLayoutManager的源码copy了一份出来,重新命名为TestLayoutManager,然后解决了里面的错误(因为这个时候已经不在原来的包里了,一些类会找不到),这样我就能随意的在里面打log,修改代码看效果。我喜欢在读代码的同时去改代码:假如这里去掉,或者增加一些代码会发生什么情况。如果你需要,可以直接在这里下载我独立出来的这个TestLayoutManager http://jcodecraeer.oss-cn-shanghai.aliyuncs.com/cod/TestLayoutManager.java 。
一些基本的知识
不管是LinearLayoutManager,还是其它自定义的LayoutManager,这些方法基本都是逃不掉的:
onLayoutChildren()
onLayoutChildren()是 LayoutManager 的主入口。 它会在初始化布局时调用, 当适配器的数据改变时(或者整个适配器被换掉时)会再次调用。它的作用就是在初始化的时候放置item,直到填满布局为止。
canScrollHorizontally() & canScrollVertically()
这些方法很简单,在你想要滚动方向对应的方法里返回 true , 不想要滚动方向对应的方法里返回 false。
scrollHorizontallyBy() & scrollVerticallyBy()
在这里实现滚动的逻辑。RecyclerView 已经处理了触摸事件的那些事情,当你上下左右滑动的时候scrollHorizontallyBy() & scrollVerticallyBy()会传入此时的位移偏移量dy(或者dx), 根据这个dy你需要完成下面这三个任务:
-
将所有的子视图移动适当的位置 (对的,你得自己做这个)。
-
决定移动视图后 添加/移除 视图。
-
返回滚动的实际距离。框架会根据它判断你是否触碰到边界。
开始
LinearLayoutManager一共有2000多行代码,并不多,而且LinearLayoutManager需要处理纵向,横向,动画等问题,但是我们只关心它是如何做到管理布局的,其实关键的代码并不多,不会超过1000行。
似乎我们该从onLayoutChildren方法开始对吧,因为它是入口嘛。本来期望里面是类似于添加view,为view设置位置的代码,应该很简单。但是看了onLayoutChildren方法的代码之后,一下子就受到10000点伤害,居然有近200行代码,而且完全不知所云。
在碰壁之后,我觉得从RecyclerView的滚动过程开始分析。LinearLayoutManager支持横向和纵向滚动因此,它的canScrollHorizontally() 和 canScrollVertically()方法是这样实现的:
@Override
public boolean canScrollHorizontally() {
return mOrientation == HORIZONTAL;
}
/**
* @return true if {@link #getOrientation()} is {@link #VERTICAL}
*/
@Override
public boolean canScrollVertically() {
return mOrientation == VERTICAL;
}
再来看看scrollHorizontallyBy() 和 scrollVerticallyBy()
/**
* {@inheritDoc}
*/
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
RecyclerView.State state) {
if (mOrientation == VERTICAL) {
return 0;
}
return scrollBy(dx, recycler, state);
}
/**
* {@inheritDoc}
*/
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
RecyclerView.State state) {
if (mOrientation == HORIZONTAL) {
return 0;
}
return scrollBy(dy, recycler, state);
}
可以看到,这两个方法都把滚动的处理交给了scrollBy方法,这个方法很短
int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() == 0 || dy == 0) {
return 0;
}
mLayoutState.mRecycle = true;
ensureLayoutState();
final int layoutDirection = dy > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
final int absDy = Math.abs(dy);
updateLayoutState(layoutDirection, absDy, true, state);
final int consumed = mLayoutState.mScrollingOffset
+ fill(recycler, mLayoutState, state, false);
if (consumed < 0) {
if (DEBUG) {
Log.d(TAG, "Don't have any more elements to scroll");
}
return 0;
}
final int scrolled = absDy > consumed ? layoutDirection * consumed : dy;
mOrientationHelper.offsetChildren(-scrolled);
if (DEBUG) {
Log.d(TAG, "scroll req: " + dy + " scrolled: " + scrolled);
}
mLayoutState.mLastScrollDelta = scrolled;
return scrolled;
}
当dy=0或者没有子元素的时候,什么也不做直接返回0。这个很好理解吧。
然后根据dy判断滚动方向。如果是垂直布局的LinearLayoutManager的话,LayoutState.LAYOUT_END表示向下翻滚(手指向上划),反之LayoutState.LAYOUT_START表示向上翻滚。
然后取dy的绝对值,并保存在absDy变量中。LinearLayoutManager在处理滚动的时候,都是用正整数来计算的,而不是用带有正负号的向量来计算。对于数学不好的人来说使用带正负号的数字太抽象了。
在滚动的时候保存状态
接下来调用了updatelayoutState方法,这个方法主要是完成一些状态的更新,在后面添加和回收视图的时候会把这些状态作为判断的条件。updatelayoutState方法代码不多,如下:
private void updateLayoutState(int layoutDirection, int requiredSpace,
boolean canUseExistingSpace, RecyclerView.State state) {
mLayoutState.mInfinite = mOrientationHelper.getMode() == View.MeasureSpec.UNSPECIFIED;
mLayoutState.mExtra = getExtraLayoutSpace(state);
mLayoutState.mLayoutDirection = layoutDirection;
int scrollingOffset;
if (layoutDirection == LayoutState.LAYOUT_END) {
mLayoutState.mExtra += mOrientationHelper.getEndPadding();
// get the first child in the direction we are going
final View child = getChildClosestToEnd();
// the direction in which we are traversing children
mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD
: LayoutState.ITEM_DIRECTION_TAIL;
mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
mLayoutState.mOffset = mOrientationHelper.getDecoratedEnd(child);
// calculate how much we can scroll without adding new children (independent of layout)
scrollingOffset = mOrientationHelper.getDecoratedEnd(child)
- mOrientationHelper.getEndAfterPadding();
} else {
final View child = getChildClosestToStart();
mLayoutState.mExtra += mOrientationHelper.getStartAfterPadding();
mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_TAIL
: LayoutState.ITEM_DIRECTION_HEAD;
mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
mLayoutState.mOffset = mOrientationHelper.getDecoratedStart(child);
scrollingOffset = -mOrientationHelper.getDecoratedStart(child)
+ mOrientationHelper.getStartAfterPadding();
}
mLayoutState.mAvailable = requiredSpace;
if (canUseExistingSpace) {
mLayoutState.mAvailable -= scrollingOffset;
}
mLayoutState.mScrollingOffset = scrollingOffset;
}
这些状态保存在mLayoutState变量中,下面是mLayoutState的各项数据代表的意思:
-
mLayoutState.mLayoutDirection 滑动方向
-
mLayoutState.mCurrentPosition 当前应该从adapter中获取item的position,用于在添加视图的时候,根据这个position从recycler中获取相应的View。
-
mLayoutState.mOffset 用于在添加布局的时候,根据它来确定被添加子View的布局位置。
-
mLayoutState.mAvailable 此次滚动发生后,
-
mLayoutState.mScrollingOffset 在添加一个新view之前,还可以滑动多少空间。
其中mLayoutState.mScrollingOffset有点诡异。在下面的fill方法中又对它重新赋值:layoutState.mScrollingOffset += layoutState.mAvailable;
导致实际上它等于dy。
保存完状态之后,就进入fill方法。
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable) {
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
if (layoutState.mScrollingOffset != LayoutState.SCOLLING_OFFSET_NaN) {
// TODO ugly bug fix. should not happen
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
//Log.d(TAG, "layoutState.mScrollingOffset =" + layoutState.mScrollingOffset);
}
recycleByLayoutState(recycler, layoutState);
}
int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
LayoutChunkResult layoutChunkResult = new LayoutChunkResult();
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (layoutChunkResult.mFinished) {
break;
}
layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
/**
* Consume the available space if:
* * layoutChunk did not request to be ignored
* * OR we are laying out scrap children
* * OR we are not doing pre-layout
*/
if (!layoutChunkResult.mIgnoreConsumed || mLayoutState.mScrapList != null
|| !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
// we keep a separate remaining space because mAvailable is important for recycling
remainingSpace -= layoutChunkResult.mConsumed;
}
if (layoutState.mScrollingOffset != LayoutState.SCOLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);
}
if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}
if (DEBUG) {
validateChildOrder();
}
return start - layoutState.mAvailable;
}
fill做了两件事情:先回收移除不再显示的子View,然后添加即将进入可见区域的子View。
回收过程
在fill方法中首先重新设置了layoutState.mScrollingOffset的值,然后根据上面updatelayoutState方法所得到的状态对item进行回收,回收是通过recycleByLayoutState()方法实现的。recycleByLayoutState()方法的大致流程很简单,当一个item滚出了布局边界立即回收。其实这里不仅仅是做了回收,还把滚出页面的视图从RecyclerView移除。
下面让我们来一步步分析recycleByLayoutState()的过程。
private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
if (!layoutState.mRecycle || layoutState.mInfinite) {
return;
}
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
recycleViewsFromEnd(recycler, layoutState.mScrollingOffset);
} else {
recycleViewsFromStart(recycler, layoutState.mScrollingOffset);
}
}
第一个if语句暂时不管它。
在第二个if语句中,根据当前的滚动方向调用了不同的方法。如果是上拉(即LayoutState.LAYOUT_START)则调用recycleViewsFromEnd方法,从名字可以看出回收是从末尾开始的,这个很好理解,上拉的时候是查看前面的内容,底部的item 不断滚出界面,当然是回收末尾的view了;而如果是下拉(LayoutState.LAYOUT_END)则调用recycleViewsFromStart方法。
顺藤摸瓜,进入recycleViewsFromStart(),一会儿再来看recycleViewsFromEnd(),其实原理都一样:
private void recycleViewsFromStart(RecyclerView.Recycler recycler, int dt) {
if (dt < 0) {
if (DEBUG) {
Log.d(TAG, "Called recycle from start with a negative value. This might happen"
+ " during layout changes but may be sign of a bug");
}
return;
}
// ignore padding, ViewGroup may not clip children.
final int limit = dt;
final int childCount = getChildCount();
if (mShouldReverseLayout) {
for (int i = childCount - 1; i >= 0; i--) {
View child = getChildAt(i);
if (mOrientationHelper.getDecoratedEnd(child) > limit) {// stop here
recycleChildren(recycler, childCount - 1, i);
return;
}
}
} else {
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (mOrientationHelper.getDecoratedEnd(child) > limit) {// stop here
recycleChildren(recycler, 0, i);
return;
}
}
}
}
这里的第二个参数dt即某时刻滚动距离的绝对值。如果你仔细看了前面的代码就知道它来自于layoutState.mScrollingOffset。虽然layoutState.mScrollingOffset本身代表的不是这个意思,但是代码里确实让它在此时等效于dy了。这也是我说layoutState.mScrollingOffset比较诡异的原因。
然后把这个dt赋值给了limit,在这个方法里也许是多此一举,不过它主要是为了和recycleViewsFromEnd方法相统一。
接着判断是否为mShouldReverseLayout,一般情况下都不是了,正常情况下是进入第二个条件。
在第二个条件里是个for循环。这个for循环比较难懂。
粗略的看就是遍历当前布局(RecyclerView)的子View,符合一定条件的子View就回收。
执行回收的具体方法是recycleChildren()。
但是符合什么条件才回收呢,还有就是 recycleChildren(recycler, 0, i)这个方法为什么有三个参数呢?
回收一个view只需i这个参数就行了吧?
mOrientationHelper.getDecoratedEnd(child)获得的是一个子view的底部边界的位置,我是根据log和方法名推测出来的,没有去深究它的实现。而> limit意思就是如果一旦一个子view的底部位置大于即将发生的位移(dt
),说明这个view在位移发生后,它仍然是可见的,那么就开始回收它之前的View,循环也到此结束(return)。for循环的作用就是跳过不可见的View。recycleChildren(recycler, 0, i)不是回收一个view,而是一堆view。但是实际上recycleChildren一次也只能回收了一个view。如果你从i=0开始分析(或者在recycleChildren中打log分析)就知道,不会存在累积很多个view才一起回收的情况,一有机会就回收了。
我通过在recycleChildren中打log得出,在下拉时候,被回收的始终是child 0(第一个子View)。因为一旦回收了一个view,它随即也被从布局中移除,第二个立即变成了第一个。
说完了recycleViewsFromStart()方法,还得简要的说下recycleViewsFromEnd(RecyclerView.Recycler recycler, int dt)方法啰,它和recycleViewsFromStart()相反是处理上拉的时候的回收,即LayoutState.LAYOUT_START。
上拉的时候,我们是要看上面的item,底部的item逐渐消失,顶部item不断出现,这时该回收的是底部的item。这个方法的代码跟recycleViewsFromStart是完全一样的步骤,只是limit的计算变了,这里的limit = mOrientationHelper.getEnd() - dt;遍历的顺序也变了不是从0开始,而是从最后一个child开始,寻找第一个可见的child,一旦发现某个child的上边界在位移发生后还能小于limit,那么它就是可见的,它就是倒数第一个可见item,它之后的都是不可见的,调用recycleChildren(recycler, childCount - 1, i)把它们删除回收。
所以你知道为什么recycleChildren方法需要三个参数了吧,因为它是删除一个区间,当然需要起始和末尾的索引啦,在加上参数recycler(用于回收)就是三个参数了。
跟recycleViewsFromStart()方法一样,虽然这里回收一个区间,但是不会存在累积很多个view才一起回收的情况,被回收的始终是 childCount - 1。一旦有一个item就立马被回收了,然后被回收的item被它前面(或者后面一个)item替代。
为了验证我的想法(不会存在累积很多个view才一起回收的情况),我在里面打了两个log,看看是否一次其实只回收了一个。
没有判断if (DEBUG) 的那两个log才是我打的哈:
下拉,一直都在回收第一个:
上拉,一直都在回收第七个(即最后一个,具体最后一个是多少跟手机屏幕,布局大小有关):
回收过程就这样结束了,接下来是视图的添加,注意回收和添加并没有什么因果关系,它们发生在两头,以下拉查看后面的内容为例,回收发生在顶部,而添加则发生在尾部。
添加视图的过程
让我们再回到fill方法:
添加View的条件
当一个子view完全显示出来,意味着下一个子view就要进来了,这个时候你就需要向布局中添加view。接下来的while循环就是添加View的过程。在添加之前,需要判断什么时候可以添加View。
它有三个条件(有&&的也有||的),但是起决定因素的是remainingSpace > 0,因为layoutState.hasMore(state)和layoutState.mInfinite两个条件可以快速判断出来在多数情况下是一定的(看它们的源码就知道了)。所以我们这里就要去弄明白remainingSpace > 0到底是什么意思。
remainingSpace = layoutState.mAvailable + layoutState.mExtra;
其中 layoutState.mAvailable来自于上面提到的updatelayoutState方法, layoutState.mExtra可以暂时不用管,前面提到了 layoutState.mAvailable表示dy与最后一个View完全可见的所剩空间的差。但是并没有说明它怎么来的。我们还是再一次看看updateLayoutState方法吧:
我们找到和layoutState.mAvailable相关的代码:
mLayoutState.mAvailable = requiredSpace;
if (canUseExistingSpace) {
mLayoutState.mAvailable -= scrollingOffset;
}
其中requiredSpace就是某一刻滚动的距离即scrollby中的dy。
canUseExistingSpace在滚动的时候始终为真的,所以
mLayoutState.mAvailable -= scrollingOffset。那么现在的问题就是要搞清楚scrollingOffset是什么东西了。
(1)当layoutDirection == LayoutState.LAYOUT_END
scrollingOffset = mOrientationHelper.getDecoratedEnd(child)
-mOrientationHelper.getEndAfterPadding();
其中mOrientationHelper.getDecoratedEnd(child)表示获得child的下边界,而child=getChildClosestToEnd(),即最后一个可见元素。
那么mOrientationHelper.getDecoratedEnd(child)的意思就是获得最后一个可见子view的下边界。
mOrientationHelper.getEndAfterPadding()表示获得布局去除padding过后的底部边界。
所以scrollingOffset就等于:最后一个可见视图的底部边界 - 布局去除padding过后的底部边界。
它代表什么意思呢?
它代表最后一个可见View在当前滚动方向上还能滚动多远就完全可见了。
而mLayoutState.mAvailable -= scrollingOffset就是用dy和它比较,当dy大于它,说明滚动发生后最后一个可见元素已经完全可见且离开了,该添加新的布局了。
因为remainingSpace = layoutState.mAvailable + layoutState.mExtra;所以remainingSpace和mLayoutState.mAvailable的意思是一样的。因此remainingSpace > 0可以作为判断是否添加View的条件。
(2)当layoutDirection == LayoutState.LAYOUT_START的时候
这个时候我们则是计算布局顶部边界与第一个可见View的顶部边界,进而计算第一个可见子view什么时候完全可见。
上面就是scrollingOffset的意思了:总结起来就是,在添加一个新的View之前,还能滚动多少距离,它包括了两个方向的情况。
而mLayoutState.mAvailable -= scrollingOffset则是用dy和scrollingOffset比较,如果dy大于scrollingOffset的话,那么说明滚动发生后临近边界的view已经完全消耗完了,需要添加新的view了。
现在回到remainingSpace,remainingSpace = layoutState.mAvailable + layoutState.mExtra;
layoutState.mExtra可以忽略它。那么remainingSpace就相当layoutState.mAvailable,remainingSpace>0就表示移动发生后,需要添加一个新的View了。
问题来了,为什么要用while循环而不是if语句呢?
这是因为fill方法不只在滚动的时候被调用,初次布局的时候也被调用了,用while循环是为了初始化的时候不断的填满布局。
添加View
添加一个View是调用layoutChunk方法来完成的,让我们来看看这个layoutChunk方法:
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutState layoutState, LayoutChunkResult result) {
View view = layoutState.next(recycler);
if (view == null) {
if (DEBUG && layoutState.mScrapList == null) {
throw new RuntimeException("received null view when unexpected");
}
// if we are laying out views in scrap, this may return null which means there is
// no more items to layout.
result.mFinished = true;
return;
}
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
if (layoutState.mScrapList == null) {
if (mShouldReverseLayout == (layoutState.mLayoutDirection
== LayoutState.LAYOUT_START)) {
addView(view);
} else {
addView(view, 0);
}
} else {
if (mShouldReverseLayout == (layoutState.mLayoutDirection
== LayoutState.LAYOUT_START)) {
addDisappearingView(view);
} else {
addDisappearingView(view, 0);
}
}
measureChildWithMargins(view, 0, 0);
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
int left, top, right, bottom;
if (mOrientation == VERTICAL) {
if (isLayoutRTL()) {
right = getWidth() - getPaddingRight();
left = right - mOrientationHelper.getDecoratedMeasurementInOther(view);
} else {
left = getPaddingLeft();
right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
}
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
bottom = layoutState.mOffset;
top = layoutState.mOffset - result.mConsumed;
} else {
top = layoutState.mOffset;
bottom = layoutState.mOffset + result.mConsumed;
}
} else {
top = getPaddingTop();
bottom = top + mOrientationHelper.getDecoratedMeasurementInOther(view);
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
right = layoutState.mOffset;
left = layoutState.mOffset - result.mConsumed;
} else {
left = layoutState.mOffset;
right = layoutState.mOffset + result.mConsumed;
}
}
// We calculate everything with View's bounding box (which includes decor and margins)
// To calculate correct layout position, we subtract margins.
layoutDecorated(view, left + params.leftMargin, top + params.topMargin,
right - params.rightMargin, bottom - params.bottomMargin);
if (DEBUG) {
Log.d(TAG, "laid out child at position " + getPosition(view) + ", with l:"
+ (left + params.leftMargin) + ", t:" + (top + params.topMargin) + ", r:"
+ (right - params.rightMargin) + ", b:" + (bottom - params.bottomMargin));
}
// Consume the available space if the view is not removed OR changed
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
result.mFocusable = view.isFocusable();
}
layoutChunk方法做了三件事:
-
一是根据当前的position获得一个View。
-
二是调用addView方法,addView顾名思义就是添加view了,不过它是LayoutManager的方法,最终还是要调用ViewGroup的addView方法;
-
三是对刚刚添加的View进行布局。把它放置在恰当的位置。因为RecyclerView的item还包含了itemdecoration,LayoutManager提供了layoutDecorated方法来简化布局的过程。
根据当前的position获取View的代码是
View view = layoutState.next(recycler);
它调用了layoutState的next方法来获取当前position的View:
View next(RecyclerView.Recycler recycler) {
if (mScrapList != null) {
return nextViewFromScrapList();
}
final View view = recycler.getViewForPosition(mCurrentPosition);
mCurrentPosition += mItemDirection;
return view;
}
if里面语句的意思是如果mScrapList不为空,则直接从mScrapList中获取。暂时没有搞明白这个到底在什么情况下有用,因为我在这个if中写log从来没有被调用过。
所以一般View还是从recycler的getViewForPosition(mCurrentPosition)中获取的。mCurrentPosition在前面已经解释了,它是在updatelayoutState方法中得到的。
接下来用 mCurrentPosition += mItemDirection;更新mCurrentPosition的值,不过在滚动的时候这个貌似没有用呢。应该是用于初始化布局的时候吧。
待续。。。