這10張圖拿去,別再說學不會RecyclerView的快取複用機制了!

語言: CN / TW / HK

本文正在參加「金石計劃 . 瓜分6萬現金大獎」

ViewPager2是在RecyclerView的基礎上構建而成的,意味著其可以複用RecyclerView物件的絕大部分特性,比如快取複用機制等。

作為ViewPager2系列的第一篇,本篇的主要目的是快速普及必要的前置知識,而內容的核心,正是前面所提到的RecyclerView的快取複用機制。


RecyclerView,顧名思義,它會回收其列表項檢視以供重用

具體而言,當一個列表項被移出屏幕後,RecyclerView並不會銷燬其檢視,而是會快取起來,以提供給新進入螢幕的列表項重用,這種重用可以:

  • 避免重複建立不必要的檢視

  • 避免重複執行昂貴的findViewById

從而達到的改善效能、提升應用響應能力、降低功耗的效果。而要了解其中的工作原理,我們還得回到RecyclerView是如何構建動態列表的這一步。

RecyclerView是如何構建動態列表的?

與RecyclerView構建動態列表相關聯的幾個重要類中,Adapter與ViewHolder負責配合使用,共同定義RecyclerView列表項資料的展示方式,其中:

  • ViewHolder是一個包含列表項檢視(itemView)的封裝容器,同時也是RecyclerView快取複用的主要物件

  • Adapter則提供了資料<->檢視 的“繫結”關係,其包含以下幾個關鍵方法:

    • onCreateViewHolder:負責建立並初始化ViewHolder及其關聯的檢視,但不會填充檢視內容。
    • onBindViewHolder:負責提取適當的資料,填充ViewHolder的檢視內容。

然而,這2個方法並非每一個進入螢幕的列表項都會回撥,相反,由於檢視建立及findViewById執行等動作都主要集中在這2個方法,每次都要回調的話反而效率不佳。因此,我們應該通過對ViewHolder物件積極地快取複用,來儘量減少對這2個方法的回撥頻次。

  1. 最優情況是——取得的快取物件正好是原先的ViewHolder物件,這種情況下既不需要重新建立該物件,也不需要重新繫結資料,即拿即用。

  2. 次優情況是——取得的快取物件雖然不是原先的ViewHolder物件,但由於二者的列表項型別(itemType)相同,其關聯的檢視可以複用,因此只需要重新繫結資料即可。

  3. 最後實在沒辦法了,才需要執行這2個方法的回撥,即建立新的ViewHolder物件並繫結資料。

實際上,這也是RecyclerView從快取中查詢最佳匹配ViewHolder物件時所遵循的優先順序順序。而真正負責執行這項查詢工作的,則是RecyclerView類中一個被稱為回收者的內部類——Recycler

Recycler是如何查詢ViewHolder物件的?

```

/**
 * ...
 * When {@link Recycler#getViewForPosition(int)} is called, Recycler checks attached scrap and
 * first level cache to find a matching View. If it cannot find a suitable View, Recycler will
 * call the {@link #getViewForPositionAndType(Recycler, int, int)} before checking
 * {@link RecycledViewPool}.
 * 
 * 當呼叫getViewForPosition(int)方法時,Recycler會檢查attached scrap和一級快取(指的是mCachedViews)以找到匹配的View。 
 * 如果找不到合適的View,Recycler會先呼叫ViewCacheExtension的getViewForPositionAndType(RecyclerView.Recycler, int, int)方法,再檢查RecycledViewPool物件。
 * ...
 */
public abstract static class ViewCacheExtension {
    ...
}

public final class Recycler { ... /* * Attempts to get the ViewHolder for the given position, either from the Recycler scrap, * cache, the RecycledViewPool, or creating it directly. * * 嘗試通過從Recycler scrap快取、RecycledViewPool查詢或直接建立的形式來獲取指定位置的ViewHolder。 * ... / @Nullable ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) { if (mState.isPreLayout()) { // 0 嘗試從mChangedScrap中獲取ViewHolder物件 holder = getChangedScrapViewForPosition(position); ... } if (holder == null) { // 1.1 嘗試根據position從mAttachedScrap或mCachedViews中獲取ViewHolder物件 holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun); ... } if (holder == null) { ... final int type = mAdapter.getItemViewType(offsetPosition); if (mAdapter.hasStableIds()) { // 1.2 嘗試根據id從mAttachedScrap或mCachedViews中獲取ViewHolder物件 holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition), type, dryRun); ... } if (holder == null && mViewCacheExtension != null) { // 2 嘗試從mViewCacheExtension中獲取ViewHolder物件 final View view = mViewCacheExtension .getViewForPositionAndType(this, position, type); if (view != null) { holder = getChildViewHolder(view); ... } } if (holder == null) { // fallback to pool // 3 嘗試從mRecycledViewPool中獲取ViewHolder物件 holder = getRecycledViewPool().getRecycledView(type); ... } if (holder == null) { // 4.1 回撥createViewHolder方法建立ViewHolder物件及其關聯的檢視 holder = mAdapter.createViewHolder(RecyclerView.this, type); ... } }

        if (mState.isPreLayout() && holder.isBound()) {
            ...
        } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
            ...
            // 4.1 回撥bindViewHolder方法提取資料填充ViewHolder的檢視內容
            bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
        }

        ...

        return holder;
    }
    ...
}

```

結合RecyclerView類中的原始碼及註釋可知,Recycler會依次從mChangedScrap/mAttachedScrap、mCachedViews、mViewCacheExtension、mRecyclerPool中嘗試獲取指定位置或ID的ViewHolder物件以供重用,如果全都獲取不到則直接重新建立。這其中涉及的幾層快取結構分別是:

mChangedScrap/mAttachedScrap

mChangedScrap/mAttachedScrap主要用於臨時存放仍在當前螢幕可見、但被標記為「移除」或「重用」的列表項,其均以ArrayList的形式持有著每個列表項的ViewHolder物件,大小無明確限制,但一般來講,其最大數就是螢幕內總的可見列表項數。

final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>(); ArrayList<ViewHolder> mChangedScrap = null;

但問題來了,既然是當前螢幕可見的列表項,為什麼還需要快取呢?又是什麼時候列表項會被標記為「移除」或「重用」的呢?

這2個快取結構實際上更多是為了避免出現像區域性重新整理這一類的操作,導致所有的列表項都需要重繪的情形。

區別在於,mChangedScrap主要的使用場景是:

  1. 開啟了列表項動畫(itemAnimator),並且列表項動畫的canReuseUpdatedViewHolder(ViewHolder viewHolder)方法返回false的前提下;
  2. 呼叫了notifyItemChanged、notifyItemRangeChanged這一類方法,通知列表項資料發生變化;

``` boolean canReuseUpdatedViewHolder(ViewHolder viewHolder) { return mItemAnimator == null || mItemAnimator.canReuseUpdatedViewHolder(viewHolder, viewHolder.getUnmodifiedPayloads()); }

public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder,
        @NonNull List<Object> payloads) {
    return canReuseUpdatedViewHolder(viewHolder);
}

public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder) {
    return true;
}

```

canReuseUpdatedViewHolder方法的返回值表示的不同含義如下: - true,表示可以重用原先的ViewHolder物件 - false,表示應該建立該ViewHolder的副本,以便itemAnimator利用兩者來實現動畫效果(例如交叉淡入淡出效果)。

簡單講就是,mChangedScrap主要是為列表項資料發生變化時的動畫效果服務的

mAttachedScrap應對的則是剩下的絕大部分場景,比如:

  • 像notifyItemMoved、notifyItemRemoved這種列表項發生移動,但列表項資料本身沒有發生變化的場景。
  • 關閉了列表項動畫,或者列表項動畫的canReuseUpdatedViewHolder方法返回true,即允許重用原先的ViewHolder物件的場景。

下面以一個簡單的notifyItemRemoved(int position)操作為例來演示:

notifyItemRemoved(int position)方法用於通知觀察者,先前位於position的列表項已被移除, 其往後的列表項position都將往前移動1位。

為了簡化問題、方便演示,我們的範例將會居於以下限制: - 列表項總個數沒有鋪滿整個螢幕——意味著不會觸發mCachedViews、mRecyclerPool等結構的快取操作 - 去除列表項動畫——意味著呼叫notifyItemRemoved後RecyclerView只會重新佈局子檢視一次 recyclerView.itemAnimator = null

理想情況下,呼叫notifyItemRemoved(int position)方法後,應只有位於position的列表項會被移除,其他的列表項,無論是位於position之前或之後,都最多隻會調整position值,而不應發生檢視的重新建立或資料的重新繫結,即不應該回調onCreateViewHolder與onBindViewHolder這2個方法。

為此,我們就需要將當前螢幕內的可見列表項暫時從當前螢幕剝離,臨時快取到mAttachedScrap這個結構中去。

等到RecyclerView重新開始佈局顯示其子檢視後,再遍歷mAttachedScrap找到對應position的ViewHolder物件進行復用。

mCachedViews

mCachedViews主要用於存放已被移出螢幕、但有可能很快重新進入螢幕的列表項。其同樣是以ArrayList的形式持有著每個列表項的ViewHolder物件,預設大小限制為2。

final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>(); int mViewCacheMax = DEFAULT_CACHE_SIZE; static final int DEFAULT_CACHE_SIZE = 2;

比如像朋友圈這種按更新時間的先後順序展示的Feed流,我們經常會在快速滑動中確定是否有自己感興趣的內容,當意識到剛才滑走的內容可能比較有趣時,我們往往就會將上一條內容重新滑回來檢視。

這種場景下我們追求的自然是上一條內容展示的實時性與完整性,而不應讓使用者產生“才滑走那麼一會兒又要重新載入”的抱怨,也即同樣不應發生檢視的重新建立或資料的重新繫結。

我們用幾張流程示意圖來演示這種情況:

同樣為了簡化問題、方便描述,我們的範例將會居於以下限制: - 關閉預拉取——意味著之後向上滑動時,都不會再預拉取「待進入螢幕區域」的一個列表項放入mCachedView了 recyclerView.layoutManager?.isItemPrefetchEnabled = false - 只存在一種型別的列表項,即所有列表項的itemType相同,預設都為0。

我們將圖中的列表項分成了3塊區域,分別是被滑出螢幕之外的區域、螢幕內的可見區域、隨著滑動手勢待進入螢幕的區域。

  1. 當position=0的列表項隨著向上滑動的手勢被移出屏幕後,由於mCachedViews初始容量為0,因此可直接放入;

  1. 當position=1的列表項同樣被移出屏幕後,由於未達到mCachedViews的預設容量大小限制,因此也可繼續放入;

  1. 此時改為向下滑動,position=1的列表項重新進入螢幕,Recycler就會依次從mAttachedScrap、mCachedViews查詢可重用於此位置的ViewHolder物件;

  2. mAttachedScrap不是應對這種情況的,自然找不到。而mCachedViews會遍歷自身持有的ViewHolder物件,對比ViewHolder物件的position值與待複用位置的position值是否一致,是的話就會將ViewHolder物件從mCachedViews中移除並返回;

  3. 此處拿到的ViewHolder物件即可直接複用,即符合前面所述的最優情況

  1. 另外,隨著position=1的列表項重新進入螢幕,position=7的列表項也會被移出螢幕,該位置的列表項同樣會進入mCachedViews,即RecyclerView是雙向快取的。

mViewCacheExtension

mViewCacheExtension主要用於提供額外的、可由開發人員自由控制的快取層級,屬於非常規使用的情況,因此這裡暫不展開講。

mRecyclerPool

mRecyclerPool主要用於按不同的itemType分別存放超出mCachedViews限制的、被移出螢幕的列表項,其會先以SparseArray區分不同的itemType,然後每種itemType對應的值又以ArrayList的形式持有著每個列表項的ViewHolder物件,每種itemType的ArrayList大小限制預設為5。

public static class RecycledViewPool { private static final int DEFAULT_MAX_SCRAP = 5; static class ScrapData { final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>(); int mMaxScrap = DEFAULT_MAX_SCRAP; long mCreateRunningAverageNs = 0; long mBindRunningAverageNs = 0; } SparseArray<ScrapData> mScrap = new SparseArray<>(); ... }

由於mCachedViews預設的大小限制僅為2,因此,當滑出螢幕的列表項超過2個後,就會按照先進先出的順序,依次將ViewHolder物件從mCachedViews移出,並按itemType放入RecycledViewPool中的不同ArrayList

這種快取結構主要考慮的是隨著被滑出螢幕列表項的增多,以及被滑出距離的越來越遠,重新進入螢幕內的可能性也隨之降低。於是Recycler就在時間與空間上做了一個權衡,允許相同itemType的ViewHolder被提取複用,只需要重新繫結資料即可。

這樣一來,既可以避免無限增長的ViewHolder物件快取擠佔了原本就緊張的記憶體空間,又可以減少回撥相比較之下執行代價更加昂貴的onCreateViewHolder方法。

同樣我們用幾張流程示意圖來演示這種情況,這些示意圖將在前面的mCachedViews示意圖基礎上繼續操作:

  1. 假設目前存在於mCachedViews中的仍是position=0及position=1這兩個列表項。

  2. 當我們繼續向上滑動時,position=2的列表項會嘗試進入mCachedViews,由於超出了mCachedViews的容量限制,position=0的列表項會從mCachedViews中被移出,並放入RecycledViewPool中itemType為0的ArrayList,即圖中的情況①;

  3. 同時,底部的一個新的列表項也將隨著滑動手勢進入到螢幕內,但由於此時mAttachedScrap、mCachedViews、mRecyclerPool均沒有合適的ViewHolder物件可以提供給其複用,因此該列表項只能執行onCreateViewHolder與onBindViewHolder這2個方法的回撥,即圖中的情況②;

  1. 等到position=2的列表項被完全移出了屏幕後,也就順利進入了mCachedViews中。

  1. 我們繼續保持向上滑動的手勢,此時,由於下一個待進入螢幕的列表項與position=0的列表項的itemType相同,因此我們可以在走到從mRecyclerPool查詢合適的ViewHolder物件這一步時,根據itemType找到對應的ArrayList,再取出其中的1個ViewHolder物件進行復用,即圖中的情況①。

  2. 由於itemType型別一致,其關聯的檢視可以複用,因此只需要重新繫結資料即可,即符合前面所述的次優情況

  1. ②③ 情況與前面的一致,此處不再贅餘。

最後總結一下,

| | RecyclerView快取複用機制 | | --- | --- | | 物件 | ViewHolder(包含列表項檢視(itemView)的封裝容器) | | 目的 | 減少對onCreateViewHolder、onBindViewHolder這2個方法的回撥 | | 好處 | 1.避免重複建立不必要的檢視 2.避免重複執行昂貴的findViewById | | 效果 | 改善效能、提升應用響應能力、降低功耗 | 核心類 | Recycler、RecyclerViewPool | 快取結構 | mChangedScrap/mAttachedScrap、mCachedViews、mViewCacheExtension、mRecyclerPool

| 快取結構 | 容器型別 | 容量限制 | 快取用途 | 優先順序順序(數值越小,優先順序越高) | ---- | ---- | ---- | ---- | ---- | | mChangedScrap/mAttachedScrap | ArrayList | 無,一般為螢幕內總的可見列表項數 | 臨時存放仍在當前螢幕可見、但被標記為「移除」或「重用」的列表項 | 0 | mCachedViews | ArrayList | 預設為2 | 存放已被移出螢幕、但有可能很快重新進入螢幕的列表項 | 1 | mViewCacheExtension | 開發者自己定義 | 無 | 提供額外的可由開發人員自由控制的快取層級 | 2 | mRecyclerPool | SparseArray> | 每種itemType預設為5 | 按不同的itemType分別存放超出mCachedViews限制的、被移出螢幕的列表項 | 3

以上的就是RecyclerView快取複用機制的核心內容了。