打造 RecyclerView 全能重新整理 Helper
前言
我們已經越來越離不開RecyclerView,業務的複雜導致介面的複雜,而RecyclerView無疑是最佳之選。RecyclerView如何打造出複雜的佈局,如何打造高效能,多功能的RecyclerView,如何打造高複用,靈活的Adapter等,這些問題,這篇文章都不涉及(手動滑稽)。這篇文章講的是似乎被忽略的RecyclerView的重新整理,各種姿勢的重新整理,媽媽再也不用擔心產品的各種奇葩重新整理了。
庫的由來
專案開發週期短讓我們沒時間自己去實現或者封裝庫,最好有現成的庫(切記重複造輪子),但我找了一圈也沒找到,這就尷尬了。只能自己動手豐衣足食,好的東西不能自己享用,因此開源,大家一起享受。
本庫沿著幾條需求進行展開,現在一一分析。

09fa45bac545363c74d442246e880a40.webp.jpg
RecyclerView的viewType增多,邏輯變複雜,幾個月後,你確定還能理清思路嗎?
多type的實現,常規做法就是每個type對應一個集合,然後在adapter中處理這些集合,一個兩個還好,更多呢?而且在開發週期比較短的情況,總是避免不了後期的重構,最尷尬的是重構的時候,自己寫的是啥忘了(多註釋啊)。在這裡,你根本不需要考慮重新整理前資料處理邏輯。
假設我們服務端是多個介面返回資料,你確定能正確重新整理相應type嗎?
我們總是避免不了服務端會分介面返回,多個介面合併處理吧,影響效率,介面響應時間不一致,如果需求是隻重新整理某個type呢?因此我們的全域性重新整理夢想也破滅了。我們不得不完美地把控每個position對應的ViewType,一旦出錯,可能導致條目錯亂,甚至崩潰,這太影響使用者體驗了。在這裡,刷錯type,你打我。
想一個RecyclerView高效快捷管理整個介面嗎?
其實這條跟我們的重新整理庫並無多大關係,更考驗的是對RecyclerView的理解,但是管理整個介面總是避免不了有各種各樣的type。
你還在使用notifyDataSetChanged無腦重新整理嗎?
什麼?無腦?但是簡單好用啊,而且很少出錯。notifyDataSetChanged方法重新整理是沒有動畫的,給人感覺很生硬,使用帶動畫的重新整理又容易出錯,還有一個更致命的缺點是不需要重新整理的也強制重新整理了。在這裡,吸納了各自的優點。
你想單個viewType在loadingView,dataView,errorView,emptyView自如切換嗎?
厲害了,我的哥,服務端多介面返回資料的時候,可能某個接口出錯了,我們總不能整個頁面出錯吧?不顯示又太low了,搞個輕提示吧,使用者還不一定看見。在這裡,你想怎麼切就怎麼切。
你還在為重新整理導致資料錯亂而煩惱嗎?
這種錯誤往往出現在使用Adapter的範圍型重新整理,比如notifyItemRangeInserted,使用者一重新整理,發現有2個一樣的type,死的心都有了,這其實還算好,如果你的app擁有切換條件的功能,比如區域等,不同區域不同資料,可是,使用者切換的時候,2個區域的資料都出現了。我的天,被開除的節奏。在這裡,你可以保住你飯碗。

image.png
現在
此情此景,我想吟詩一首,啊,我想要一個能解決上述問題且能配合我們專案的Adapter的庫。少年,往這裡看,不知道這些服務,您還滿意嗎?
- 簡單快捷,可配合大多數Adapter
- 一行程式碼重新整理相應viewType
- 支援粘性頭
- 支援非同步重新整理,可擴充套件(如配合RxAndroid)
- 支援高頻率重新整理(流暢,非同步執行)
- 支援載入facebook的shimmer效果loading頁面
- 支援載入相應type錯誤頁面
- 支援載入相應type空頁面
- 支援標準(一個type對應一個集合)和混合(一般的多型別集合)自如切換(自動排序集合)
- 支援集合set,add,remove,clear等操作重新整理
- 支援註解生成類,減少工作量
- 支援重新整理生命週期回撥

image.png
未來
看完上面的同學可能要問了,到底怎麼使用啊?實現原理又是什麼啊?放心,我會一一給你解答。
使用方法
使用方法在這裡不便給出,實在太多了,但我肯定不是那種不負責任的人,對於每次大更新,更新點以及使用方法我都會寫一篇文章,你可以到我的部落格看,也可以到我的github上看。對於每個類,每個方法基本都有註釋,這點你可以放心。上面說的完全夠你對庫的使用手到擒來。
實現原理
本庫的重新整理基本都是圍繞著DiffUtil展開,那麼我們從這裡切入。
DiffUtil
DiffUtil內部採用的Eugene W. Myers’s difference 演算法,通過傳入新老資料集,計算二者間的差異,再呼叫相應的方法進行重新整理:
adapter.notifyItemRangeInserted(position, count);
adapter.notifyItemRangeRemoved(position, count);
adapter.notifyItemMoved(fromPosition, toPosition);
adapter.notifyItemRangeChanged(position, count, payload);
;
這樣一來,我們可以跟notifyDataSetChanged說88啦。那麼新老資料集怎麼來?
資料集
以下type新老資料集,簡稱資料集,整個資料集,簡稱資料來源。
老資料來源好說,不就是adapter裡面的資料來源嘛。新資料來源呢?如果是整一個數據集,直接拿來用就是了,可我現在只想更新資料來源裡面的某個type的資料集,最好有個以type為key的map集合管理著每個type的資料集,當我們要更新資料集的時候,可以直接掏出老資料集。I have a old type list,I have a new type list,new type list!
到這裡,我想了兩個辦法。其一,更新map集合中需要更新的type的value為新資料集,然後再遍歷組合成新資料來源。其二,copy一份老資料來源,先移除老資料集,再新增新資料集。這裡先不分析孰優孰劣,我選擇了後者。
無論是第一種還是第二種都面臨一個問題,資料集的位置。假設現在我們有3種type,不妨為A,B,C,產品要求順序為A,B,C(如果B資料集為空,那麼就為A,C,以此類推),但你現在可能出現C,B,A,老鐵,這不是打籃球,恐怕你又得被開除啊。這種優先順序的概念,我們見得太多了,比如程序的優先順序,有序廣播的優先順序等等。所以這裡我們引入了level的概念,且把map的key改為level,舉個比較明顯問題的栗子,遍歷map的時候,你還是不知道誰前誰後。那你會說,我根據level找到type,再根據type找到資料集不就好了?很不幸,我們這裡,level跟type是一對多的關係,比如上面說的A,它可能用來顯示正常的資料,萬一產品說如果資料出錯,我們需要有錯誤頁面(錯誤頁面級別是type),那豈不是GG?
map的key改為level後,兩種方法的實現思路很明顯了。這裡貼出第二種的程式碼。
mNewData.addAll(mData);//copy老資料來源 mNewData.removeAll(oldItemData); mNewData.remove(oldItemHeader);//移除老資料集 mNewData.addAll(oldHeader == null ? positionStart : positionStart + 1, newData); mNewData.add(positionStart, newHeader);//新增新資料集
細心的同學可能發現,positionStart怎麼來的?它是靠map遍歷得到的,但它是不完全遍歷(這裡涉及到最優時間複雜度)。
到這裡,新老資料來源都有了,剩下的就是交給diffutil去更新UI。
DiffUtil.DiffResult result = DiffUtil.calculateDiff(getDiffCallBack(mData, mNewData), isDetectMoves()); diffResult.dispatchUpdatesTo(getListUpdateCallback(mAdapter)); mData.addAll(mNewData);//切記,切記,一定要保持內外資料來源一致
這裡我們看到getDiffCallBack(mData, mNewData),isDetectMoves(),getListUpdateCallback(mAdapter)。先來說說isDetectMoves()方法,返回值作為DiffUtil靜態方法calculateDiff的第二個引數,這個引數官方是這麼說的。
If move detection is enabled, it takes an additional O(N^2) time where N is the total number of added and removed items. If your lists are already sorted by the same constraint (e.g. a created timestamp for a list of posts), you can disable move detection to improve performance.
大概意思是說,如果該引數為true,那麼在計算的時候,會額外增加O(N^2) 的時間複雜度,N為移動的數量(增加和刪除),如果列表已經按約束設計了(不需要調整),建議填false。可能這麼說比較抽象,官方也給出了測試資料(除錯資料為一組隨機UUID字串,執行在Android版本型號為M的Nexus 5X),我們來看看吧。
100項中10項修改:平均值:0.39毫秒,中位數:0.35毫秒
100項中100項修改:平均值:3.82毫秒,中位數:3.75毫秒
100個專案中100個修改(不移動):平均值:2.09毫秒,中位數:2.06毫秒
1000項中50項修改:平均值:4.67毫秒,中位數:4.59毫秒。
1000個專案中50個修改(不移動):平均值:3.59毫秒,中位數:3.50毫秒
1000項中200項修改:平均值:27.07毫秒,中位數:26.92毫秒
1000個專案中200個修改(不移動):平均值:13.54毫秒,中位數:13.36毫秒
接下來我們來看看getListUpdateCallback方法,這個比較好理解,它作為dispatchUpdatesTo方法的引數。返回值ListUpdateCallback是對計算資料的回撥。我們來看看庫的預設實現。

image.png
比官方的預設實現多了preDataCount變數,該變數是為了保證內部資料position與adapter的position一致。同學們可以通過重寫getPreDataCount方法改變值。
最後一個getDiffCallBack方法,這個較為複雜,但是熟悉了也還好,我這裡簡單介紹一下,感興趣的可以到官方文件看,官方是最權威的,小弟的英文也不太好,所以。。。那個。。。言歸正傳,該方法的返回值為抽象類DiffUtil.Callback,共有5個方法。

image.png
前面兩個我就不說了,見名知意,中間2個,其實也很明顯,第三個看名字是說判斷2個條目是否相同的,恭喜你答對了,這個地方我們一般判斷兩個條目的“主鍵”,如果返回true才會調areContentsTheSame方法,看名字就是讓我們判斷條目中的內容是否一樣,可以判斷其中一項,也可以判斷多項,甚至全部。最後一個方法getChangePayload,是配合Adapter中
public void onBindViewHolder(VH holder, int position, List<Object> payloads)
Android原始碼中該方法是調兩個引數的方法,那麼第三個引數怎麼來的呢?我們回上去看看getListUpdateCallback方法,裡面有這麼一個方法

image.png
最後在adapter回撥方法onBindViewHolder中取出Bundle,根據Bundle來區域性更新,不用全部走一遍。
好了,這裡栗子用的Bundle,大家可以看到資料型別其實是Object,之所以用Bundle是因為我們要跟隨谷歌爸爸的腳步,Bundle在Android資料通訊這一塊作用還是很大的。
非同步
上面result與diffResult不一致是我採用了兩個方法,原因是DiffUtil造成的。

image.png
大概意思說,資料量太大的時候,計算最好放到子執行緒,計算結束再到主執行緒更新UI。沒毛病,因此拆成2個方法,如果是非同步,則先呼叫前者,再切到主執行緒呼叫後者。但這其實是執行緒不安全的,與Android引入Handler更新UI有點類似(不理解的同學,可以去看看Handler的相關文章)。這裡我們選擇了序列的方法並引入了以單鏈表結構的佇列來管理每次重新整理的資料來源。
HandleBase<T> pollData = mRefreshQueue.poll();if (pollData != null) { startRefresh(pollData);} else { onEnd();}
我們這裡沒有Looper的概念,因為我知道它什麼時候開始,什麼時候結束。
以上就是本庫的核心原理啦,其它還有像什麼資源管理(個人認為設計的不夠好),資料的建立,模式的切換,生命週期的回撥等。感興趣的同學可以看看原始碼。
展望未來
有一句話是這麼說的,當局者迷旁觀者清。如果得到你們的支援,我相信他會越來越完善,能夠支援更多使用者的專案,他並不是我的,是大家的,有米娜的陪伴,他一定能夠更好。剛把帶!

image.png
結束語
你的意見,你的建議,你的star,你的分享,一直是我前進的動力。現在關於LayoutManager,RecyclerView,Adapter的流派很多,我們更關注於資料的優雅重新整理。