RecyclerView快取機制(回收哪些表項)
這是 RecyclerView
快取機制系列文章的第二篇,系列文章的目錄如下。
上一篇文章講述了“從哪裡獲得回收的表項”,這一篇會結合實際回收場景分析下“回收哪些表項?”。
(ps: 下文中的 粗斜體字 表示引導原始碼閱讀的內心戲)
回收場景
在眾多回收場景中最顯而易見的就是“滾動列表時移出螢幕的表項被回收”。滾動是由MotionEvent.ACTION_MOVE事件觸發的,就以RecyclerView.onTouchEvent()為切入點尋覓“回收表項”的時機:
public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 { @Override public boolean onTouchEvent(MotionEvent e) { ... case MotionEvent.ACTION_MOVE: { ... if (scrollByInternal( canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, vtev)) { getParent().requestDisallowInterceptTouchEvent(true); } ... } } break; ... } }
去掉了大量位移賦值邏輯後,一個處理滾動的函數出現在眼前:
public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 { ... @VisibleForTesting LayoutManager mLayout; ... boolean scrollByInternal(int x, int y, MotionEvent ev) { ... if (mAdapter != null) { ... if (x != 0) { consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState); unconsumedX = x - consumedX; } if (y != 0) { consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState); unconsumedY = y - consumedY; } ... } ... }
RecyclerView
把滾動交給了 LayoutManager
來處理,於是移步到最熟悉的 LinearLayoutManager
:
public class LinearLayoutManager extends RecyclerView.LayoutManager implements ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider { ... @Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { if (mOrientation == HORIZONTAL) { return 0; } return scrollBy(dy, recycler, state); } ... 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); //更新LayoutState(這個函式對於“回收哪些表項”來說很關鍵,待會會提到) 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; } ... }
沿著呼叫鏈往下找,發現了一個上一篇中介紹過的函式 LinearLayoutManager.fill()
,原來列表滾動的同時也會不斷的向其中填充表項( 想想也是,不然怎麼會不斷有新的表項出現呢~ )。上一遍只關注了其中填充的邏輯,但其實裡面還有回收邏輯:
public class LinearLayoutManager extends RecyclerView.LayoutManager implements ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider { ... int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) { ... int remainingSpace = layoutState.mAvailable + layoutState.mExtra; LayoutChunkResult layoutChunkResult = mLayoutChunkResult; //不斷迴圈獲取新的表項用於填充,直到沒有填充空間 while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) { layoutChunkResult.resetInternal(); if (VERBOSE_TRACING) { TraceCompat.beginSection("LLM LayoutChunk"); } //填充新的表項 layoutChunk(recycler, state, layoutState, layoutChunkResult); if (VERBOSE_TRACING) { TraceCompat.endSection(); } if (layoutChunkResult.mFinished) { break; } layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection; 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.SCROLLING_OFFSET_NaN) { //在當前滾動偏移量基礎上追加因新表項插入增加的畫素(這句話對於“回收哪些表項”來說很關鍵,待會會提到) layoutState.mScrollingOffset += layoutChunkResult.mConsumed; if (layoutState.mAvailable < 0) { layoutState.mScrollingOffset += layoutState.mAvailable; } //回收表項 recycleByLayoutState(recycler, layoutState); } if (stopOnFocusable && layoutChunkResult.mFocusable) { break; } ... } ... return start - layoutState.mAvailable; } }
在不斷獲取新表項用於填充的同時也在回收表項( 想想也是,列表滾動的時候有表項插入的同時也有表項被移出 ),移步到回收表項的函式:
public class LinearLayoutManager extends RecyclerView.LayoutManager implements ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider { ... 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); } } ... /** * Recycles views that went out of bounds after scrolling towards the end of the layout. * 當向列表尾部滾動時回收滾出螢幕的表項 * <p> * Checks both layout position and visible position to guarantee that the view is not visible. * * @param recycler Recycler instance of {@link android.support.v7.widget.RecyclerView} * @param dtThis can be used to add additional padding to the visible area. This is used *to detect children that will go out of bounds after scrolling, without *actually moving them.(該引數被用於檢測滾出螢幕的表項) */ 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 || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) { // stop here recycleChildren(recycler, childCount - 1, i); return; } } } else { //遍歷LinearLayoutManager的孩子找出其中應該被回收的 for (int i = 0; i < childCount; i++) { View child = getChildAt(i); if (mOrientationHelper.getDecoratedEnd(child) > limit || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) { // stop here //回收索引為0到i-1的表項 recycleChildren(recycler, 0, i); return; } } } } ... }
原來 RecyclerView
的回收分兩個方向:1. 從列表頭回收 2.從列表尾回收。就以“從列表頭回收”為研究物件分析下 RecyclerView
在滾動時到底是怎麼判斷“哪些表項應該被回收?”。
(“從列表頭回收表項”所對應的場景是:手指上滑,列表向下滾動,新的表項逐個插入到列表尾部,列表頭部的表項逐個被回收。)
回收哪些表項
要回答這個問題,剛才那段程式碼中套在 recycleChildren(recycler, 0, i)
外面的判斷邏輯是關鍵: mOrientationHelper.getDecoratedEnd(child) > limit
。
/** * Helper class for LayoutManagers to abstract measurements depending on the View's orientation. * 該類用於幫助LayoutManger抽象出基於檢視方向的測量 * <p> * It is developed to easily support vertical and horizontal orientations in a LayoutManager but * can also be used to abstract calls around view bounds and child measurements with margins and * decorations. * * @see #createHorizontalHelper(RecyclerView.LayoutManager) * @see #createVerticalHelper(RecyclerView.LayoutManager) */ public abstract class OrientationHelper { ... /** * Returns the end of the view including its decoration and margin. * <p> * For example, for the horizontal helper, if a View's right is at pixel 200, has 2px right * decoration and 3px right margin, returned value will be 205. * * @param view The view element to check * @return The last pixel of the element * @see #getDecoratedStart(android.view.View) */ public abstract int getDecoratedEnd(View view); ... public static OrientationHelper createVerticalHelper(RecyclerView.LayoutManager layoutManager) { return new OrientationHelper(layoutManager) { ... @Override public int getDecoratedEnd(View view) { final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams(); return mLayoutManager.getDecoratedBottom(view) + params.bottomMargin; } ... }
結合註釋和該方法的實現,原來 mOrientationHelper.getDecoratedEnd(child)
表示當前表項的尾部相對於列表頭部的座標, OrientationHelper
這層抽象遮蔽了列表的方向,所以這句話在縱向列表中可以翻譯成“當前表項的底部相對於列表頂部的縱座標”。
判斷條件 mOrientationHelper.getDecoratedEnd(child) > limit
中的 limit
又是什麼鬼? 在縱向列表中,“表項底部縱座標 > 某個值”意味著表項位於某條線的下方 ,回看一眼“回收表項”的邏輯:
//遍歷LinearLayoutManager的孩子找出其中應該被回收的 for (int i = 0; i < childCount; i++) { View child = getChildAt(i); //直到表項底部縱座標大於某個值後,回收該表項以上的所有表項 if (mOrientationHelper.getDecoratedEnd(child) > limit || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) { // stop here //回收索引為0到索引為i-1的表項 recycleChildren(recycler, 0, i); return; } }
隱約覺得 limit
應該等於0,這樣不正好是回收所有從列表頭移出的表項嗎? 不知道這樣YY到底對不對,還是沿著呼叫鏈向上找一下 limit
被賦值的地方吧~,呼叫鏈很長,就不全部羅列了,但其中有兩個關鍵點,其實我在上面的程式碼中埋了伏筆,現在再羅列一下:
public class LinearLayoutManager extends RecyclerView.LayoutManager implements ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider { ... 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); //1. 更新LayoutState(這個函式對於“回收哪些表項”來說很關鍵,待會會提到) updateLayoutState(layoutDirection, absDy, true, state); //滾動時向列表中填充新的表項 final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false); ... } ... int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) { ... //不斷迴圈獲取新的表項用於填充,直到沒有填充空間 while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) { ... if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) { //2. 在當前滾動偏移量基礎上追加因新表項插入增加的畫素(這句話對於“回收哪些表項”來說很關鍵,待會會提到) layoutState.mScrollingOffset += layoutChunkResult.mConsumed; if (layoutState.mAvailable < 0) { layoutState.mScrollingOffset += layoutState.mAvailable; } //回收表項 recycleByLayoutState(recycler, layoutState); } ... } ... return start - layoutState.mAvailable; } ... private void updateLayoutState(int layoutDirection, int requiredSpace, boolean canUseExistingSpace, RecyclerView.State state) { ... int scrollingOffset; if (layoutDirection == LayoutState.LAYOUT_END) { mLayoutState.mExtra += mOrientationHelper.getEndPadding(); //獲得當前方向上裡列表尾部最近的孩子(最後一個孩子) 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 { ... } ... //對mLayoutState.mScrollingOffset賦值 mLayoutState.mScrollingOffset = scrollingOffset; } }
一圖勝千語:

螢幕快照 2019-02-16 下午7.23.51.png
關於 limit
等於0的YY破滅了,其實 limit
是一根橫謂語列表中間的橫線,它的值表示這一次滾動的總距離。(圖中是一種理想情況,即當滾動結束後新插入表項的底部正好和列表底部重疊)其實 回收表項的時機是在滾動真正發生之前,此時我們預先計算出滾動的偏移量,根據偏移量篩選出滾動發生後應該被刪除的表項。即 limit
這根線也可以表述為:當滾動發生後,列表當前 limit
這個位置會成為列表的頭部
分析完“回收哪些表項”後,一不小心發現篇幅有點長了,那關於“怎麼回收”將放到下一篇在講。