在前一篇文章 RecyclerView 原始碼分析(一) —— 繪製流程解析 介紹了 RecyclerView 的繪製流程,RecyclerView 通過將繪製流程從 View 中抽取出來,放到 LayoutManager 中,使得 RecyclerView 在不同的 LayoutManager 中,擁有不同的樣式,使得 RecyclerView 異常靈活,大大加強了 RecyclerView 使用場景。
當然,RecyclerView 的快取機制也是它特有的一個優點,減少了對記憶體的佔用以及重複的繪製工作,因此,本文意在介紹和學習 RecyclerView 的快取設計思想。
當我們在討論混存的時候,一定會經歷建立-快取-複用的過程。因此對於 RecyclerView 的快取機制也是按照如下的步驟進行。
建立 ViewHolder(VH)
在講到對子 itemView 測量的時候,layoutChunk 方法中會先獲得每一個 itemView,在獲取後,在將其新增到 RecyclerView 中。所以我們先來看看建立的過程:
View next(RecyclerView.Recycler recycler) { if (mScrapList != null) { return nextViewFromScrapList(); } final View view = recycler.getViewForPosition(mCurrentPosition); mCurrentPosition += mItemDirection; return view; }
next
就是呼叫 RecyclerView
的 getViewForPosition
方法來獲取一個 View
的。而 getViewForPosition
方法最終會呼叫到 RecyclerView
的tryGetViewHolderForPositionByDeadline
方法。
tryGetViewHolderForPositionByDeadline
這個方法很長,但是其實邏輯很簡單,整個過程前面部分是先從快取嘗試獲取 VH,如果找不到,就會建立新的 VH,然後繫結資料,最後將再將 VH 繫結到 LayoutParams (LP) 上。
ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) { if (position < 0 || position >= mState.getItemCount()) { throw new IndexOutOfBoundsException("Invalid item position " + position + "(" + position + "). Item count:" + mState.getItemCount() + exceptionLabel()); } boolean fromScrapOrHiddenOrCache = false; ViewHolder holder = null; // 省略從快取查詢 VH 的邏輯,下面是如果還是沒找到,就會建立一個新的if (holder == null) { long start = getNanoTime(); if (deadlineNs != FOREVER_NS && !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) { // abort - we have a deadline we can't meet return null; }
// 建立 VH holder = mAdapter.createViewHolder(RecyclerView.this, type); if (ALLOW_THREAD_GAP_WORK) { // only bother finding nested RV if prefetching RecyclerView innerView = findNestedRecyclerView(holder.itemView); if (innerView != null) { holder.mNestedRecyclerView = new WeakReference<>(innerView); } } long end = getNanoTime(); mRecyclerPool.factorInCreateTime(type, end - start); if (DEBUG) { Log.d(TAG, "tryGetViewHolderForPositionByDeadline created new ViewHolder"); } } } // This is very ugly but the only place we can grab this information // before the View is rebound and returned to the LayoutManager for post layout ops. // We don't need this in pre-layout since the VH is not updated by the LM. if (fromScrapOrHiddenOrCache && !mState.isPreLayout() && holder .hasAnyOfTheFlags(ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST)) { holder.setFlags(0, ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST); if (mState.mRunSimpleAnimations) { int changeFlags = ItemAnimator .buildAdapterChangeFlagsForAnimations(holder); changeFlags |= ItemAnimator.FLAG_APPEARED_IN_PRE_LAYOUT; final ItemHolderInfo info = mItemAnimator.recordPreLayoutInformation(mState, holder, changeFlags, holder.getUnmodifiedPayloads()); recordAnimationInfoIfBouncedHiddenView(holder, info); } } boolean bound = false; if (mState.isPreLayout() && holder.isBound()) { // do not update unless we absolutely have to. holder.mPreLayoutPosition = position; } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) { if (DEBUG && holder.isRemoved()) { throw new IllegalStateException("Removed holder should be bound and it should" + " come here only in pre-layout. Holder: " + holder + exceptionLabel()); } final int offsetPosition = mAdapterHelper.findPositionOffset(position);
// 進行資料繫結 bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs); } final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams(); final LayoutParams rvLayoutParams;
// 下面邏輯就是將 VH 繫結到 LP, LP 又設定到 ItemView 上 if (lp == null) { rvLayoutParams = (LayoutParams) generateDefaultLayoutParams(); holder.itemView.setLayoutParams(rvLayoutParams); } else if (!checkLayoutParams(lp)) { rvLayoutParams = (LayoutParams) generateLayoutParams(lp); holder.itemView.setLayoutParams(rvLayoutParams); } else { rvLayoutParams = (LayoutParams) lp; } rvLayoutParams.mViewHolder = holder; rvLayoutParams.mPendingInvalidate = fromScrapOrHiddenOrCache && bound; return holder; }
即使省略了中間從快取查詢 VH 的邏輯,剩下部分的程式碼還是很長。那我再概括下 tryGetViewHolderForPositionByDeadline 方法所做的事:
-
從快取查詢 VH ;
-
快取沒有,那麼就建立一個 VH;
-
判斷 VH 需不需要更新資料,如果需要就會呼叫 tryBindViewHolderByDeadline 繫結資料;
-
將 VH 繫結到 LP, LP 又設定到 ItemView 上,互相依賴;
到這裡關於建立 VH 的邏輯就講完了。
快取
在介紹新增到快取的邏輯時,還是需要介紹快取相關的類和變數。
快取整體設計
快取機制 Recycler 詳解
Recycler 是 RecyclerView 的一個內部類。我們來看一下它的主要的成員變數。
-
mAttachedScrap 快取螢幕中可見範圍的 ViewHolder
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
-
mChangedScrap 快取滑動時即將與 RecyclerView 分離的ViewHolder,按子View的position或id快取,預設最多存放2個
ArrayList<ViewHolder> mChangedScrap = null;
-
mCachedViews ViewHolder 快取列表,其大小由 mViewCacheMax 決定,預設 DEFAULT_CACHE_SIZE 為 2,可動態設定。
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
-
ViewCacheExtension 開發者可自定義的一層快取,是虛擬類 ViewCacheExtension 的一個例項,開發者可實現方法 getViewForPositionAndType(Recycler recycler, int position, int type) 來實現自己的快取。
private ViewCacheExtension mViewCacheExtension;
-
RecycledViewPool ViewHolder 快取池,在有限的 mCachedViews 中如果存不下 ViewHolder 時,就會把 ViewHolder 存入 RecyclerViewPool 中。
RecycledViewPool mRecyclerPool;
新增到快取
VH 被建立之後,是要被快取,然後重複利用的,那麼他們是什麼時候被新增到快取的呢?此處還是以 LinearLayoutManager 舉例說明。在 RecyclerView 原始碼分析(一) —— 繪製流程解析 一文中曾提到一個方法:
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { // ... detachAndScrapAttachedViews(recycler); // ... }
onLayoutChildren 是對子 view 進行繪製。在對子 view 會先呼叫 detachAndScrapAttachedViews 方法,下面來看看這個方法。
detachAndScrapAttachedViews
下面來看下這個方法:
// recyclerview public void detachAndScrapAttachedViews(@NonNull Recycler recycler) { final int childCount = getChildCount(); for (int i = childCount - 1; i >= 0; i--) { final View v = getChildAt(i);
// 每個 view 都會放到裡面 scrapOrRecycleView(recycler, i, v); } } private void scrapOrRecycleView(Recycler recycler, int index, View view) { final ViewHolder viewHolder = getChildViewHolderInt(view); if (viewHolder.shouldIgnore()) { if (DEBUG) { Log.d(TAG, "ignoring view " + viewHolder); } return; }
// 如果 VH 無效,並且已經被移除了,就會走另一個邏輯 if (viewHolder.isInvalid() && !viewHolder.isRemoved() && !mRecyclerView.mAdapter.hasStableIds()) { removeViewAt(index); recycler.recycleViewHolderInternal(viewHolder); } else {
// 先 detch 掉,然後放入快取中 detachViewAt(index); recycler.scrapView(view); mRecyclerView.mViewInfoStore.onViewDetached(viewHolder); } }
也就是在上面的邏輯裡,被放到快取中。這裡就可以看到
-
如果是 remove,會執行
recycleViewHolderInternal(viewHolder)
方法,而這個方法最終會將 ViewHolder 加入 CacheView 和 Pool 中, -
而當是 Detach,會將 View 加入到 ScrapViews 中
需要指出的一點是:需要區分兩個概念,Detach 和 Remove
-
detach: 在 ViewGroup 中的實現很簡單,只是將 ChildView 從 ParentView 的 ChildView 陣列中移除,ChildView 的 mParent 設定為 null, 可以理解為輕量級的臨時 remove, 因為 View此時和 View 樹還是藕斷絲連, 這個函式被經常用來改變 ChildView 在 ChildView 陣列中的次序。View 被 detach 一般是臨時的,在後面會被重新 attach。
-
remove: 真正的移除,不光被從 ChildView 陣列中除名,其他和 View 樹各項聯絡也會被徹底斬斷(不考慮 Animation/LayoutTransition 這種特殊情況), 比如焦點被清除,從TouchTarget 中被移除等。
recycleViewHolderInternal
下面來看 Recycler 兩個的具體邏輯方法:
/** * internal implementation checks if view is scrapped or attached and throws an exception * if so. * Public version un-scraps before calling recycle. */ void recycleViewHolderInternal(ViewHolder holder) {
// ...省略前面的程式碼,前面都是在做檢驗 final boolean transientStatePreventsRecycling = holder .doesTransientStatePreventRecycling(); @SuppressWarnings("unchecked") final boolean forceRecycle = mAdapter != null && transientStatePreventsRecycling && mAdapter.onFailedToRecycleView(holder); boolean cached = false; boolean recycled = false; if (DEBUG && mCachedViews.contains(holder)) { throw new IllegalArgumentException("cached view received recycle internal? " + holder + exceptionLabel()); } if (forceRecycle || holder.isRecyclable()) { if (mViewCacheMax > 0 && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID | ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_UPDATE | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) { // Retire oldest cached view 如果快取數量超了,就會先移除最先加入的 int cachedViewSize = mCachedViews.size(); if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) { recycleCachedViewAt(0); cachedViewSize--; } int targetCacheIndex = cachedViewSize; if (ALLOW_THREAD_GAP_WORK && cachedViewSize > 0 && !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) { // when adding the view, skip past most recently prefetched views int cacheIndex = cachedViewSize - 1; while (cacheIndex >= 0) { int cachedPos = mCachedViews.get(cacheIndex).mPosition; if (!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)) { break; } cacheIndex--; } targetCacheIndex = cacheIndex + 1; }
// 新增到快取 mCachedViews.add(targetCacheIndex, holder); cached = true; } if (!cached) { addViewHolderToRecycledViewPool(holder, true); recycled = true; } } else { } // even if the holder is not removed, we still call this method so that it is removed // from view holder lists. mViewInfoStore.removeViewHolder(holder); if (!cached && !recycled && transientStatePreventsRecycling) { holder.mOwnerRecyclerView = null; } }
該方法所做的事具體如下:
-
檢驗該 VH 的有效性,確保已經不再被使用;
-
判斷快取的容量,超了就會進行移除,然後找一個合適的位置進行新增。
- 如果不能加入到 CacheViews 中,則加入到 Pool 中。
mCachedViews
mCachedViews 對應的資料結構也是 ArrayList 但是該快取對集合的大小是有限制的,預設是 2。該快取中 ViewHolder 的特性和 mAttachedScrap 中的特性是一樣的,只要 position或者 itemId 對應上了,那麼它就是乾淨的,無需重新繫結資料。開發者可以呼叫 setItemViewCacheSize(size) 方法來改變快取的大小。該層級快取觸發的一個常見的場景是滑動 RV。當然 notifyXXX 也會觸發該快取。該快取和 mAttachedScrap 一樣特別高效。
RecyclerViewPool
RecyclerViewPool 快取可以針對多ItemType,設定快取大小。預設每個 ItemType 的快取個數是 5。而且該快取可以給多個 RecyclerView 共享。由於預設快取個數為 5,假設某個新聞 App,每螢幕可以展示 10 條新聞,那麼必然會導致快取命中失敗,頻繁導致建立 ViewHolder 影響效能。所以需要擴大快取size。
scrapView
接下去看 scrapView 這個方法:
void scrapView(View view) { final ViewHolder holder = getChildViewHolderInt(view); if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID) || !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) { if (holder.isInvalid() && !holder.isRemoved() && !mAdapter.hasStableIds()) { throw new IllegalArgumentException("Called scrap view with an invalid view." + " Invalid views cannot be reused from scrap, they should rebound from" + " recycler pool." + exceptionLabel()); } holder.setScrapContainer(this, false); // 這裡的 false mAttachedScrap.add(holder); } else { if (mChangedScrap == null) { mChangedScrap = new ArrayList<ViewHolder>(); } holder.setScrapContainer(this, true); // 這裡是 true mChangedScrap.add(holder); } }
該方法就比較簡單了,沒有那麼多需要檢驗的邏輯。這裡根據條件,有兩種快取型別可以選擇,具體就不展開了,大家都可以看懂。這裡講解下兩個 scrapView 的快取。
mAttachedScrap
mAttachedScrap 的對應資料結構是ArrayList,在 LayoutManager#onLayoutChildren 方法中,對 views 進行佈局時,會將 RecyclerView 上的 Views 全部暫存到該集合中,以備後續使用,該快取中的 ViewHolder 的特性是,如果和 RV 上的 position 或者 itemId 匹配上了,那麼認為是乾淨的 ViewHolder,是可以直接拿出來使用的,無需呼叫 onBindViewHolder 方法。該 ArrayList 的大小是沒有限制的,螢幕上有多少個 View,就會建立多大的集合。
觸發該層級快取的場景一般是呼叫 notifyItemXXX 方法。呼叫 notifyDataSetChanged 方法,只有當 Adapter hasStableIds 返回 true,會觸發該層級的快取使用。
mChangedScrap
mChangedScrap 和 mAttachedScrap 是同一級的快取,他們是平等的。但是mChangedScrap的呼叫場景是notifyItemChanged和notifyItemRangeChanged,只有發生變化的ViewHolder才會放入到 mChangedScrap 中。mChangedScrap快取中的ViewHolder是需要呼叫onBindViewHolder方法重新繫結資料的。那麼此時就有個問題了,為什麼同一級別的快取需要設計兩個不同的快取?
在 dispatchLayoutStep2 階段 LayoutManager onLayoutChildren方法中最終會呼叫 layoutForPredictiveAnimations 方法,把 mAttachedScrap 中剩餘的 ViewHolder 填充到螢幕上,所以他們的區別就是,mChangedScrap 中的 ViewHolder 在 RV 填充滿的情況下,是不會強行填充到 RV 上的。那麼有辦法可以讓發生改變的 ViewHolder 進入 mAttachedScrap 快取嗎?當然可以。呼叫 notifyItemChanged(int position, Object payload) 方法可以,實現區域性重新整理功能,payload 不為空,那麼發生改變的 ViewHolder 是會被分離到 mAttachedScrap 中的。
使用快取
下面進入到最後一節,使用快取。這個在之前繪製篇幅也有提到,下面直接看對應的方法:
//根據傳入的position獲取ViewHolder ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) { ---------省略---------- boolean fromScrapOrHiddenOrCache = false; ViewHolder holder = null; //預佈局 屬於特殊情況 從mChangedScrap中獲取ViewHolder if (mState.isPreLayout()) { holder = getChangedScrapViewForPosition(position); fromScrapOrHiddenOrCache = holder != null; } if (holder == null) { //1、嘗試從mAttachedScrap中獲取ViewHolder,此時獲取的是螢幕中可見範圍中的ViewHolder //2、mAttachedScrap快取中沒有的話,繼續從mCachedViews嘗試獲取ViewHolder holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun); ----------省略---------- } if (holder == null) { final int offsetPosition = mAdapterHelper.findPositionOffset(position); ---------省略---------- final int type = mAdapter.getItemViewType(offsetPosition); //如果Adapter中宣告瞭Id,嘗試從id中獲取,這裡不屬於快取 if (mAdapter.hasStableIds()) { holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition), type, dryRun); } if (holder == null && mViewCacheExtension != null) { 3、從自定義快取mViewCacheExtension中嘗試獲取ViewHolder,該快取需要開發者實現 final View view = mViewCacheExtension .getViewForPositionAndType(this, position, type); if (view != null) { holder = getChildViewHolder(view); } } if (holder == null) { // fallback to pool //4、從快取池mRecyclerPool中嘗試獲取ViewHolder holder = getRecycledViewPool().getRecycledView(type); if (holder != null) { //如果獲取成功,會重置ViewHolder狀態,所以需要重新執行Adapter#onBindViewHolder繫結資料 holder.resetInternal(); if (FORCE_INVALIDATE_DISPLAY_LIST) { invalidateDisplayListInt(holder); } } } if (holder == null) { ---------省略---------- //5、若以上快取中都沒有找到對應的ViewHolder,最終會呼叫Adapter中的onCreateViewHolder建立一個 holder = mAdapter.createViewHolder(RecyclerView.this, type); } } boolean bound = false; if (mState.isPreLayout() && holder.isBound()) { holder.mPreLayoutPosition = position; } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) { final int offsetPosition = mAdapterHelper.findPositionOffset(position); //6、如果需要繫結資料,會呼叫Adapter#onBindViewHolder來繫結資料 bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs); } ----------省略---------- return holder; }
上述邏輯用流程圖表示:
總結一下上述流程:通過 mAttachedScrap、mCachedViews 及 mViewCacheExtension 獲取的 ViewHolder 不需要重新建立佈局及繫結資料;通過快取池 mRecyclerPool 獲取的 ViewHolder不需要重新建立佈局,但是需要重新繫結資料;如果上述快取中都沒有獲取到目標 ViewHolder,那麼就會回撥 Adapter#onCreateViewHolder 建立佈局,以及回撥 Adapter#onBindViewHolder來繫結資料。
ViewCacheExtension
我們已經知道 ViewCacheExtension 屬於第三級快取,需要開發者自行實現,那麼 ViewCacheExtension 在什麼場景下使用?又是如何實現的呢?
首先我們要明確一點,那就是 Recycler
本身已經設定了好幾級快取了,為什麼還要留個介面讓開發者去自行實現快取呢?
關於這一點,來看看 Recycler
中的其他快取:
-
mAttachedScrap
用來處理可見螢幕的快取; -
mCachedViews
裡儲存的資料雖然是根據position
來快取,但是裡面的資料隨時可能會被替換的; -
mRecyclerPool
裡按viewType
去儲存ArrayList< ViewHolder>
,所以mRecyclerPool
並不能按position
去儲存ViewHolder
,而且從mRecyclerPool
取出的View
每次都要去走Adapter#onBindViewHolder
去重新繫結資料。
假如我現在需要在一個特定的位置(比如 position=0 位置)一直展示某個 View,且裡面的內容是不變的,那麼最好的情況就是在特定位置時,既不需要每次重新建立 View,也不需要每次都去重新繫結資料,上面的幾種快取顯然都是不適用的,這種情況該怎麼辦呢?可以通過自定義快取 ViewCacheExtension
實現上述需求。
RecyclerView & ListView快取機制對比
結論援引自:Android ListView 與 RecyclerView 對比淺析--快取機制
ListView和RecyclerView快取機制基本一致:
-
mActiveViews 和 mAttachedScrap 功能相似,意義在於快速重用螢幕上可見的列表項ItemView,而不需要重新 createView 和 bindView;
-
mScrapView 和 mCachedViews + mReyclerViewPool功能相似,意義在於快取離開螢幕的 ItemView,目的是讓即將進入螢幕的 ItemView 重用.
-
RecyclerView 的優勢在於
-
mCacheViews 的使用,可以做到螢幕外的列表項 ItemView 進入螢幕內時也無須bindView快速重用;
-
mRecyclerPool 可以供多個 RecyclerView 共同使用,在特定場景下,如 viewpaper+ 多個列表頁下有優勢.客觀來說,RecyclerView 在特定場景下對 ListView 的快取機制做了補強和完善。
-
不同使用場景:列表頁展示介面,需要支援動畫,或者頻繁更新,區域性重新整理,建議使用 RecyclerView,更加強大完善,易擴充套件;其它情況(如微信卡包列表頁)兩者都OK,但ListView在使用上會更加方便,快捷。
參考文章
https://www.jianshu.com/p/2b19e9bcda84
https://www.jianshu.com/p/6e6bf58b7f0d
https://www.jianshu.com/p/e1b257484961