深入理解 RecyclerView 的緩存機制

使用 ScrollView 的時候,它的所有子 view 都會一次性被加載出來。而正確使用 RecyclerView 可以做到按需加載,按需綁定,並實現複用。本文主要分析 RecyclerView 緩存複用的原理。

從緩存獲取 ViewHolder 流程概覽

從緩存獲取的大致流程如下圖所示:

 

緩存獲取流程

 

 

說明:

在創建 ViewHolder 之前,RecyclerView 會先從緩存中嘗試獲取是否有符合要求的 ViewHolder,詳見 Recycler#tryGetViewHolderForPositionByDeadline 方法

  • 第一次,嘗試從 mChangedScrap 中獲取。
    • 只有在 mState.isPreLayout() 爲 true 時,也就是預佈局階段,纔會做這次嘗試。
    • 「預佈局」的概念會在介紹。
  • 第二次,getScrapOrHiddenOrCachedHolderForPosition() 獲得 ViewHolder。
    • 嘗試從 1. mAttachedScrap 2.mHiddenViews 3.mCachedViews 中查找 ViewHolder
      • 其中 mAttachedScrap 和 mCachedViews 都是 Recycler 的成員變量
      • 如果成功獲得 ViewHolder 則檢驗其有效性,
        • 檢驗失敗則將其回收到 RecyclerViewPool 中
        • 檢驗成功可以直接使用
  • 第三次,如果給 Adapter 設置了 stableId,調用 getScrapOrCachedViewForId 嘗試獲取 ViewHolder。
    • 跟第二次的區別在於,之前是根據 position 查找,現在是根據 id 查找
  • 第四次,mViewCacheExtension 不爲空的話,則調用 ViewCacheExtension#getViewForPositionAndType 方法嘗試獲取 View
    • 注:ViewCacheExtension 是由開發者設置的,默認情況下爲空,一般我們也不會設置。這層緩存大部分情況下可以忽略。
  • 第五次。嘗試從 RecyclerViewPool 中獲取,相比較於 mCachedViews,從 mRecyclerPool 中成功獲取 ViewHolder 對象後並沒有做合法性和 item 位置校驗,只檢驗 viewType 是否一致。
    • 從 RecyclerViewPool 中取出來的 ViewHolder 需要重新執行 bind 才能使用。
  • 如果上面五次嘗試都失敗了,調用 RecyclerView.Adapter#createViewHolder 創建一個新的 ViewHolder
  • 最後根據 ViewHolder 的狀態,確定是否需要調用 bindViewHolder 進行數據綁定。

問題

預佈局、預測動畫是什麼?

理解「預佈局」需要先了解「預測動畫」。考慮這樣一個場景:

用戶有 A、B、C 三個 item,A,B 剛好顯示在屏幕中,這個時候,用戶把 B 刪除了,那麼最終 C 會顯示在 B 原來的位置

 

 

 

如果 C 從底部平滑地滑動到之前 B 的位置將會更符合直覺。但是要做到這點實際上沒那麼簡單。因爲我們只知道 C 最終的位置,但是不知道 C 的起始位置在哪裏,無法確定 C 應該從哪裏滑動過來。如果根據最終的狀態,就斷定 C 應該要從底部滑動過來的話,很可能是有問題的。因爲在其他 LayoutManager 中,它可能是從側面或者是其他地方滑動過來的。

那根據原狀態與最終狀態之間的差異,能不能得出我們應該執行什麼樣的切換動畫呢?答案依然是 no。因爲在原狀態中,C 根本就不存在。(這個時候,我們並不知道,B 要被刪除了,如果把 C 給加載出來,很可能是一種資源浪費。)

設計 RecyclerView 的工程師是這麼解決的。當 Adapter 發生變化的時候,RecyclerView 會讓 LayoutManager 進行兩次佈局。

  • 第一次是預佈局。將之前原狀態 下的 item 都佈局出來。並且根據 Adapter 的 notify 信息,我們知道哪些 item 即將變化了,所以可以加載出另外的 View。在上述例子中,因爲知道 B 已經被刪除了,所以可以把屏幕之外的 C 也加載出來
  • 第二個,最終的佈局,也就是變化完成之後的佈局。

 

 

 

這樣只要比較前後佈局的變化,就能得出應該執行什麼動畫了。

這種負責執行動畫的 view 在原佈局或新佈局中不存在的動畫,就稱爲預測動畫

預佈局是實現預測動畫的一個步驟。

下面兩個動圖展示了普通動畫與預測動畫效果的區別:

普通動畫 👇

 

 

 

預測動畫 👇

 

 

 

關於預測動畫,感興趣的同學可以進一步閱讀這篇文章

關於 Scrap

Scrap 緩存列表(mChangedScrap、mAttachedScrap)是 RecyclerView 最先查找 ViewHolder 地方,它跟 RecyclerViewPool 或者 ViewCache 有很大的區別。

mChangedScrap 和 mAttachedScrap 只在佈局階段使用。其他時候它們是空的。佈局完成之後,這兩個緩存中的 viewHolder,會移到 mCacheView 或者 RecyclerViewPool 中。

當 LayoutManager 開始佈局的時候(預佈局或者是最終佈局),當前佈局中的所有 view,都會被 dump 到 scrap 中(具體實現可見 LinearLayoutManager#onLayoutChildren() 方法中調用了 detachAndScrapAttachedViews() ),然後 LayoutManager 挨個地取回 view,除非 view 發生了什麼變化,否則它會馬上從 scrap 中回到原來的位置。

 

img

 

 

以上圖爲例,我們刪除掉 b,調用 notifyItemRemove 方法,觸發重新佈局,這時 a,b,c 都會被 dump 到 scrap 中,然後 LayoutManager 會從 scrap 中取回 a 和 c。

偏個題,這個時候,b 去哪了? RecyclerView 看到 b 沒有出現在最終的佈局中,會 unscrap 它,讓它執行一個消失的動畫然後隱藏。動畫執行完之後,b 被放到 RecyclerViewPool 中。

爲什麼 LayoutManager 需要先執行 detach,然後再重新 attach 這些 view,而不是隻移除哪些變化的子 view 呢?Scrap 緩存列表的存在,是爲了隔離 LayoutManager 和 RecyclerView.Recycler 之間的關注點/職責。LayoutManager 不需要知道哪一個子 view 應該保留 或者是 應該被回收到 pool 亦或者其他什麼地方。這是 Recycler 的職責。

除了在佈局時不爲空外,還有另一個與 scrap 有關的規律:所有 scrap 的 view 都會跟 RecyclerView 分離。ViewGroup 中的 attachView 和 detachView 方法跟 addView 和 removeView 方法很像,但是不會觸發請求佈局會重繪的事件。它們只是從 ViewGroup 的子 view 列表中刪除對應的子 view,並將該子 view 的 parent 設置爲 null。detached 狀態必須是臨時,後面緊隨着 attach 或者 remove 事件

如果在計算一個新佈局的時候,已經添加了一堆子 view,可以放心的將它們全部 detach ,Recyclerview 就是這麼做的。

Attached vs Changed scrap

Recycler 類中,我們可以看到兩個單獨的 scrap 容器: mAttachedScrap 和 mChangedScrap。爲什麼需要兩個呢?

ViewHolder 只有在滿足下面情況纔會被添加到 mChangedScrap:當它關聯的 item 發生了變化(notifyItemChanged 或者 notifyItemRangeChanged 被調用),並且 ItemAnimator 調用 ViewHolder#canReuseUpdatedViewHolder 方法時,返回了 false。否則,ViewHolder 會被添加到AttachedScrap 中。

canReuseUpdatedViewHolder 返回 “false” 表示我們要執行用一個 view 替換另一個 view 的動畫,例如淡入淡出動畫。 “true”表示動畫在 view 內部發生。

mAttachedScrap 在 整個佈局過程中都能使用,但是 changed scrap — 只能在預佈局階段使用。

這是有道理的:在佈局後,新的 ViewHolder 應該替換掉“改變了的”視圖,因此 AttachedScrap 在佈局後是沒有用的。 更改動畫執行完成後,change scrap 將按預期方式轉存到 pool 中

默認的 ItemAnimator 可以在 3 種情況下重用更新的 ViewHolder:

  • 調用了 setSupportsChangeAnimations(false)。
  • 調用了 notifyDataSetChanged 而不是 notifyItemChanged 或 notifyItemRangeChanged 。
  • 提供了這樣的更改 payload:adapter.notifyItemChanged(index,anyObject)。

最後一種情況顯示了一種很好的方法,當只想更改一些內部元素時,可以避免創建/綁定新的 ViewHolder。

Hidden Views 是什麼?

前面提到在第二次嘗試獲取 ViewHolder 的時候,有一個子步驟會從 hidden view 中搜索,這裏的 hidden view 指的是什麼?「hidden view」指的是那些正在從 RecyclerView 邊界中脫離的 view。爲了讓這些 view 正確地執行對應的分離動畫,它們仍然作爲 RecyclerView 的子 view 被保留下來。

站在 LayoutManager 的角度,這些 view 已經不存在了,因此不應該被包含在計算裏面。比如 在部分 view 正在執行消失動畫的過程中,調用 LayoutManager#getChildAt 方法,這些 view 不算在下標裏面。來自 LayoutManager 的所有對 getChildAt()、getChildCount()、addView() 等的方法調用 在應用到實際的可回收view 之前,都要通過 ChildHelper 處理,ChildHelper 的職責是重新計算非隱藏的子 view 列表和完整的子 view 列表之間的索引。

請記住,我們正在搜索要提供給 LayoutManager 的視圖,但是 LayoutManager 不應瞭解隱藏 View

舉一個實際的🌰:這種讓人費解的“從隱藏的 view 彈跳”(bouncing from hidden views)機制對於處理下面這種情況而言是很有必要的。 考慮這種場景,我們插入一個 item ,然後在插入動畫完成之前,馬上刪除該 item:

 

img

 

 

我們想要看到的是 b 從 c 移除時的位置開始向上平移。 但是在那個時候,b 是一個隱藏的 view! 如果我們忽略了它(“隱藏”的 b),那會導致在現有 b 下面創建一個新的 b。更糟糕的是,這兩個 view 會重疊,因爲 新的 b 會往上,舊的 b 會往下。 爲了避免這種錯誤,在搜索 ViewHolder 的較早步驟之一中,RecyclerView 會詢問 ChildHelper 是否具有合適的 hidden view。 所謂「合適」,表示這個 view 跟我們需要的位置相關聯,並具有正確的 view type,並且這個 view 的被隱藏的原因不是爲了移除掉它(我們不應該讓被移除的 view 復活)

如果有這樣的 view ,RecyclerView 會將其返回到 LayoutManager 並將其添加到 preLayout 中以標記應從其進行動畫處理的位置(詳見 recordAnimationInfoIfBouncedHiddenView 方法)。

什麼?在 佈局前後 添加內容不應該是 LayoutManager 的職責嗎?怎麼現在 RecyclerView 也在往 preLayout 中添加view? 是的,這種機制看起來有點職責部分,但這是也說明我們有必要了解它。

Stable Id 的作用是什麼?

理解 stable Id 特性的最重要的一個點是,它只會在調用 notifyDataSetChanged 方法之後,影響 RecyclerView 的行爲。

如果調用 notifyDataSetChanged 的時候,Adapter 並沒有設置 hasStableId,RecyclerView 不知道 發生了什麼,哪一些東西變化了,所以,它假設所有的東西都變了,每一個 ViewHolder 都是無效的,因此應該把它們放到 RecyclerViewPool 而不是 scrap 中。

 

img

 

 

如果有 Stable Id,那那將會是像下面這樣:

 

img

 

 

ViewHolder 會進入 scrap 而不是 pool 中。然後會通過特定的 Id(Adapter 中的 getItemId 獲取到的 id)而不是 postion 到 scrap 中查找 ViewHolder。

好處是什麼?

  1. 不會導致 RecyclerViewPool 溢出,因此非必須情況下,不需要創建新的 ViewHolder。之前的 ViewHolder 會重新綁定,因爲 Id 沒有變化不代表內容沒有變化
  2. 最大好處的好處是 支持動畫。上面移動 item4 到 item6 的位置。正常情況下,我們需要調用 notifyItemMoved(4,6) 才能得到一個移動動畫。但是通過 stable id,調用 notifyDataSetChanged 也能支持這一點。因爲 RecyclerView 可以看到特定 id 的 view 在新舊佈局的上的位置,
    • 要注意的是,這裏的動畫只支持簡單的動畫,預測動畫無法支持。 如果我們在新佈局中看到一些 ID,而在舊佈局中沒有,那麼我們如何知道它是新插入的 item 還是從某處移入的 item,在後一種情況下它究竟是從哪裏來的呢? 通常,這些問題的答案會在預佈局中找到,根據適配器的更改,該佈局已超出 RecyclerView 的範圍,但現在這種情況下, 我們不知道這些更改具體是什麼

總體而言,stable id 的使用場景似乎比較有限。 不過,還是有這樣一個使用場景:如果是從 ListView 遷移到 RecyclerView,將所有 notifyDataSetChanged 調用,都轉換爲特定更改的通知可能會很痛苦。 在這種情況下,stable id 可以提供給你提供簡單的 RecyclerView 動畫。

緩存優化實踐

  • 儘量使用 notifyItemXxx 方法進行細粒度的通知更新,而不是 notifyDatasetChanged

    • 如果變更前後是兩個數據集,無法確定具體哪一些數據項變化了,可以考慮使用 DiffUtil
    • 如果數據集較大,建議結合使用 AsyncListDiffer 在子線程做 diff 運算。
  • 如果特定 viewType 的 item 只有一個,可以通過 RecyclerView#getRecycledViewPool()#setMaxRecycledViews(viewType,1); 來調整緩存區的大小,減少內存佔用

  • 如果特定 viewType 的 item 特別多,但是不得不通過 notifyDataSetChange 方法更新數據,可以通過下面這種方式,在變更前調大緩存,變更完成後,調小緩存。這樣佈局變化也可以最大程度地複用已有的 ViewHolder。

    mRecyclerView.getRecycledViewPool().setMaxRecycledViews(0, 屏幕顯示的item總數+7 );
    mAdapter.notifyDataSetChanged();
    new Handler().post(new Runnable() {
        @Override
        public void run() {
            mRecyclerView.getRecycledViewPool()
                    .setMaxRecycledViews(0, 5);
        }
    });
    複製代碼
  • 如果 RecyclerView 中的每個 item 都是一個 RecyclerView, 並且子 RecyclerView 的 item type 相同可以通過 RecyclerView#setRecycledViewPool(); 方法,實現緩存池的複用。

     

 

由於本人水平有限,可能出於誤解或者筆誤難免出錯,如果發現有問題或者對文中內容存在疑問請在下面評論區告訴我,謝謝!

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章