使用 ScrollView 的時候,它的全部子 view 都會一次性被加載出來。而正確使用 RecyclerView 能夠作到按需加載,按需綁定,並實現複用。本文主要分析 RecyclerView 緩存複用的原理。android
從緩存獲取 ViewHolder 流程概覽
從緩存獲取的大體流程以下圖所示:git
說明:github
在建立 ViewHolder 以前,RecyclerView 會先從緩存中嘗試獲取是否有符合要求的 ViewHolder,詳見 Recycler#tryGetViewHolderForPositionByDeadline
方法web
-
第一次,嘗試從 mChangedScrap 中獲取。緩存
-
只有在 mState.isPreLayout() 爲 true 時,也就是預佈局階段,纔會作此次嘗試。 -
「預佈局」的概念會在介紹。 -
第二次,getScrapOrHiddenOrCachedHolderForPosition() 得到 ViewHolder。微信
-
其中 mAttachedScrap 和 mCachedViews 都是 Recycler 的成員變量 -
若是成功得到 ViewHolder 則檢驗其有效性, -
若 檢驗失敗則將其回收到 RecyclerViewPool 中 -
檢驗成功能夠直接使用 -
嘗試從 1. mAttachedScrap 2.mHiddenViews 3.mCachedViews 中查找 ViewHolder -
第三次,若是給 Adapter 設置了 stableId,調用 getScrapOrCachedViewForId 嘗試獲取 ViewHolder。編輯器
-
跟第二次的區別在於,以前是根據 position 查找,如今是根據 id 查找 -
第四次,mViewCacheExtension 不爲空的話,則調用 ViewCacheExtension#getViewForPositionAndType 方法嘗試獲取 Viewide
-
注:ViewCacheExtension 是由開發者設置的,默認狀況下爲空,通常咱們也不會設置。這層緩存大部分狀況下能夠忽略。 -
第五次。嘗試從 RecyclerViewPool 中獲取,相比較於 mCachedViews,從 mRecyclerPool 中成功獲取 ViewHolder 對象後並無作合法性和 item 位置校驗,只檢驗 viewType 是否一致。佈局
-
從 RecyclerViewPool 中取出來的 ViewHolder 須要從新執行 bind 才能使用。 -
若是上面五次嘗試都失敗了,調用 RecyclerView.Adapter#createViewHolder 建立一個新的 ViewHolderpost
-
最後根據 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 在原佈局或新佈局中不存在的動畫,就稱爲預測動畫。
預佈局是實現預測動畫的一個步驟。
下面兩個動圖展現了普通動畫與預測動畫效果的區別:
普通動畫 👇
預測動畫 👇
關於預測動畫,感興趣的同窗能夠進一步閱讀這篇文章[1]。
關於 Scrap
Scrap 緩存列表(mChangedScrap、mAttachedScrap)是 RecyclerView 最早查找 ViewHolder 地方,它跟 RecyclerViewPool 或者 ViewCache 有很大的區別。
mChangedScrap 和 mAttachedScrap 只在佈局階段使用。其餘時候它們是空的。佈局完成以後,這兩個緩存中的 viewHolder,會移到 mCacheView 或者 RecyclerViewPool 中。
當 LayoutManager 開始佈局的時候(預佈局或者是最終佈局),當前佈局中的全部 view,都會被 dump 到 scrap 中(具體實現可見 LinearLayoutManager#onLayoutChildren() 方法中調用了 detachAndScrapAttachedViews()
),而後 LayoutManager 挨個地取回 view,除非 view 發生了什麼變化,不然它會立刻從 scrap 中回到原來的位置。
以上圖爲例,咱們刪除掉 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:
咱們想要看到的是 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 中。
若是有 Stable Id,那那將會是像下面這樣:
ViewHolder 會進入 scrap 而不是 pool 中。而後會經過特定的 Id(Adapter 中的 getItemId 獲取到的 id)而不是 postion 到 scrap 中查找 ViewHolder。
好處是什麼?
-
不會致使 RecyclerViewPool 溢出,所以非必須狀況下,不須要建立新的 ViewHolder。以前的 ViewHolder 會從新綁定,由於 Id 沒有變化不表明內容沒有變化 -
最大好處的好處是 支持動畫。上面移動 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 [2] 。 -
若是數據集較大,建議結合使用 AsyncListDiffer [3] 在子線程作 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(); 方法,實現緩存池的複用。
參考資料與學習資源推薦
-
https://android.jlelse.eu/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714 -
http://www.programmersought.com/article/4558924504/ -
https://juejin.im/post/5b79a0b851882542b13d204b -
https://juejin.im/post/5c696ba9e51d457f136d24ff
因爲本人水平有限,可能出於誤解或者筆誤不免出錯,若是發現有問題或者對文中內容存在疑問請在下面評論區告訴我,謝謝!
做者:TimLin
連接:https://juejin.im/post/5eae33a26fb9a043586c7f19
來源:掘金 著做權歸做者全部
參考資料
這篇文章: http://frogermcs.github.io/recyclerview-animations-androiddevsummit-write-up/
[2]DiffUtil: https://developer.android.google.cn/reference/androidx/recyclerview/widget/DiffUtil
[3]AsyncListDiffer: https://developer.android.google.cn/reference/androidx/recyclerview/widget/AsyncListDiffer
本文分享自微信公衆號 - 賈小昆(zywudev)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。