Android RecyclerView 用 DiffUtil 的那一兩件事

Jast Lai
Jastzeonic
Published in
15 min readDec 25, 2020
source : https://commons.wikimedia.org/wiki/File:DSC03802%E4%B8%80%E5%B0%8D%E5%B8%9D%E9%9B%89%E5%87%BA%E7%8F%BE.jpg

又到了聖誕節,去年大約在這個時間發了個文,今年總覺得也該發點文章,散發點負能量,但回頭看看上次發文時間約莫兩個多月前,想想這次不要這麼負能量好了,來首經典好了。

知道這首出自哪裡的大概可以透漏年齡惹,想想坂本龍一那時候還是小鮮肉(沉默。

最近老在想要跳脫舒適圈,要離開 Android 領域去外頭看看,但老總看一會兒就被拖回來。

前言

RecyclerView 是一個很棒的工具,自從有了它之後,佈局在列表上面的 View 終於有辦法清楚的被 Reuse 了。

此外,RecyclerView.adapter 還有提供 notifyItemInserted、notifyItemRemoved、notifyItemMoved 等好用的 method ,可以把列表的異動表現在 RecyclerView 上,這看上去非常實用。

但如果跟我一樣,有用過這些 method 的人應該會發現,這玩意跟使用跟 Android Style 一樣,如果沒有很清楚自己在幹甚麼,那很容易發生不可預期的錯誤。

主要是很多東西都交給 Adapter 去做了,很多時候只是 invoke method 就完成了,還是老話,用這些 method 感覺就是:

如果能照自己想的去跑那也好,但是使用上經常會有 RecyclerView 上的 View 和 items 對不上的時候然後很莫名的噴 Exception ,結果這些好用的 method 因為在使用上難以搞清楚自己在幹嘛,結果反過來變成砍到自己身上的雙刃刀了。

那除了花時間去了解自己使用的這些 method 在幹甚麼,有沒有更無腦的方法呢?有的這有個好用的工具,叫做 DiffUtil ,它顧名思義就是幫你把前後新舊兩個列表拿出來,比對後再幫你把新的列表更新表現在 RecyclerView 上。

DiffUtil.calculateDiff(object : DiffUtil.Callback() {

override fun getOldListSize() = oldItems.size

override fun getNewListSize() = newItems.size

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
//return true are old item same as new item
}

override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
//return true if old item content same as new item content
}
}).dispatchUpdatesTo(RecyclerView.adapter)

恩,就這樣問題解決了,可喜可賀可喜可賀。

到底發生了甚麼事?

如果是這樣我好像就不用寫這篇文章了,會寫這文章背後肯定是有故事的。

狀況是這樣的,一開始用 DiffUtil 無腦用,用得很開心,雖然動畫不是那麼精美,但也無傷大雅,直到有一次,碰到了一個超過四十萬筆的列表造成的 ANR ..。

雖然我不曉得 DiffUtil 背後怎麼做運作的,但這也還好理解,裏頭畢竟要做比對的,四十萬比四十萬,肯定是得迴圈上百萬次的,反正左摸摸西摸摸又這樣度過了美好的一天。

直到這一兩天,又碰上另一個問題,有道是:有 Exception 的錯誤不難處理。那這道的前提是這個 Exception 你要知道是怎麼發生的。

但這 Exception 真的是讓人霧裡看花。

狀況是這樣的:

有一個 300 筆的資料,經過某種過濾變成 30 筆的資料。那因為希望這個過程不要這麼呆版,就在這邊用到了 DiffUtil。300 筆資料是 old list , 30 筆是 new list ,兩個放到 DiffUtil 去比對然後丟給 RecyclerView.Adapter,然後 RecyclerView 就奇蹟似的發難了。

這很常見,就 index 讀到 List 的長度去了,超過最大 index 自然就炸了。但問題是噴這個 Exception 的地方是 GridLayoutManager.SpanSizeLookup ,也就是控制 Grid RecyclerView ViewHolder Grid 個數的地方。

欸不是,它怎麼會吐一個超過 list 長度的 Position 出來?

此外,最要命的地方是,這個問題非得要特定,也就是那個 300 筆變成 30 筆資料的狀況才會發生。

如果說是 300 筆變成另外一個 30 筆,還不會發生。

起初有想是不是 Lookup 這個問題,於是把 Lookup 拿掉,結果

這問題更撲朔迷離惹。

沒辦法,30 筆對 300 筆實在有點龐大,畢竟從宏觀來觀察問題會有太多的不確定性,那要做得首先後來用人工二元搜尋法,逐步把範圍從 300:30 減到 200:20 ,最後拿到了一個 4:2 造成這個錯誤的微觀結果:

val oldList = listOf(
"A",
"A",
"B",
"B"
)

val newList = listOf(
"B",
"A"
)

看起來造成這個錯誤會需要滿足幾個條件:

  • new list 比 old list 少
  • new list 有兩個或兩個以上的 content 對應到 old list
  • old list 被 new list 對應到的 content 重複兩或兩個以上
  • new list 的對應到 old list 的 content 順序是顛倒的

很好,精確地分析出這幾點條件後…

But, Why ?

我只知道這幾個條件造成這個錯誤,但仍舊不知道這個錯誤怎麼發生的,這樣沒甚麼幫助,充其量是我能從順序層面上面去排列繞過這個問題。

DiffUtil 的流程

這段看起來很像廢話,但其實遠遠比想像中重要。

使用 DiffUtil 原則上可以分成三個步驟:

  1. 比對新舊兩個 list 的 item

這個就是大家熟知的 areItemsTheSame 和 areContentsTheSame 。

areItemsTheSame 是兩個 item 本身是否相同,areContentsTheSame 是兩個 item 的內容物是否相同。

這兩個看起來一模一樣,但事實上沒有深究的話,也不會知道這兩者的差異是上面那個問題決定性的差異,這點待會再慢慢敘述。

2. 計算兩個列表的不同

其實 DiffUtil 用的是很著名的演算法 —
The Myers diff algorithm ,邁爾斯差分演算法(這偶翻的,中文圈好像找不太到這個翻譯)。有用 Github 的人都會碰到,沒錯,它就是 git diff 用的演算法,是一個相當有趣的過程,只是這不是本篇的重點,有機會再來詳述,或者是可以參考這篇

3. 運用 DiffResult 來排列新的列表

DiffUtil.calculateDiff(object : DiffUtil.Callback()) 會得到一個 DiffResult ,這個有點廢話,大多時候我們會直接把它接上 dispatchUpdatesTo(RecyclerView.adapter),但這裡其實還有提供另一個方法。

DiffResult.dispatchUpdatesTo(object : ListUpdateCallback{....})

ListUpdateCallback 是個 interface,裡面長這樣:

public interface ListUpdateCallback {
void onInserted(int position, int count);
void onRemoved(int position, int count);
void onMoved(int fromPosition, int toPosition);
void onChanged(int position, int count, @Nullable Object payload);
}

現在回頭讓我看 Java ,粉不習慣。

言而總之,我們可以看出來,這個方法會從 DiffResult ,告訴我們要 insert 還是要 move ,還是要 changed 之類的。

那事實上如果我們直接 dispatchUpdatesTo(RecyclerView.adapter)

會發現其實只是把 adapter 放進一個繼承 ListUpdateCall(Adapter) ,叫 AdapterListUpdateCallback 的實體裏頭,再看看 AdapterListUpdateCallback 裏頭。

其實就是幫我們做了那些 notifyItem 一大堆有的沒有的事情。

那…知道這些資訊有啥幫助呢?

其實看到 AdapterListUpdateCallback 我有了個想法,那我是不是可以照抄這段然後 Log 出來,看看是哪一步造成上面那個問題呢?

然後印出來的結果:

然後噴了個一模一樣的錯誤:

恩,那知道這些資訊有啥幫助阿,錯誤仍然在,問題依舊沒有解決。

Emm…這邊有趣的點是,因為我已經把 list 微觀化了,onRemoved 和 onMove 就是這個列表所有異動的流程了,那我直接跳過 DiffResult 直接做這兩步是不是有一樣的效果呢?

是的,那就來吧。

不出所料,出了一個一模一樣的錯誤。

還是沒概念,這個錯誤怎麼出來的,那回頭看一下我們的列表。

val oldList = listOf(
"A",
"A",
"B",
"B"
)

val newList = listOf(
"B",
"A"
)

在畫面上我們抽象一點看成是這樣:

items count : 4
position:content;
0:A;
1:A;
2:B;
3:B;

new list assign 進去後最開始會是這樣:

items count : 2
position:content;
0:A;
1:A;
2:B;
3:B;

要注意到下面的 position 和 content 是 bind 到 ViewHolder 上之前的狀況, 新列表在 assign 進去 notify 之前,並不會反應到 ViewHolder 上。

那根據上面的步驟,會先把 index 0 移除掉。

items count : 2
newPosition:oldPosition:content;
0:1:A;
1:2:B;
2:3:B;

然後再把 index 1 放到 index 0

items count : 2
newPosition:oldPosition:content;
0:1:B;
1:0:A;
2:2:B;

好,完成了。

欸不是這不是多了一個嗎?

回頭看看那個看也看不懂的 Exception:

從第一個 stack 的 source 看看

這邊可以把變數帶進去 :2 ≥ 2

可以得知,就是這個多的 index 2 造成這個 Exception 。

等等,這不是我算的阿

為什麼會多的這一個沒有移除? DiffResult 坑我啊?不急,回頭看一下我 DiffUtil 的判斷:

那把 areItemsTheSame 印出來:

欸不是,這到底是在幹嗎?

這邊要敘述在幹嘛的話,可能會需要把邁爾斯差份演算法給整個敘述出來了,那我想我要花更多時間研究,然後篇幅會超過這整篇,我想還是下次有機會吧。

這邊我們把事情簡化,true 是要留下的資料,false 是要移除的資料,要注意是 last match,所以我們把取最後三個可以得到:

舊 list 的 index 0 要移除,index 1, index 2, index 3 要留下。

但為什麼不留 index 1 和 index 2 就好,還要留 index 3 呢?這是因為 index 3 符合 areItemsTheSame 的條件 B == B。

那同樣的道理為什麼 index 0 不符合,它不是也是 A==A 嗎?DiffUtil 雙標嗎?因為它的計算在 index 3 之後。

舊 index 1 會在新 list index 1 這裡,舊的 index 2 會在新 list index 0 的位置, index 3 呢?不知道開發者沒說那就順其自然吧。

於是我們最後得到了要這樣處理的結果:

上面看不懂?沒關係,畢竟我是從結果去推論它怎麼計算的,不一定與事實相同,而且前提是判斷本身就錯誤的,在這裡去理解一個錯誤的判斷為什麼丟出一個錯誤結果意義不大。但我們仍舊可以推測出為什麼會產生這個錯誤。

這裡最根本的問題是在我這裡用 areTheItemSame 去判斷根本沒有甚麼特定資料可以代表它們是不同 item 的資料類別,因為 new list 的 index 0 等於 old list 的 index 2, index 3 , new list 的 index 1 等於 old list 的 index 0, index1,依照這邏輯 new list 的總數應該是 4 個,而非 2 個,但事實上 new list 的總數是 2 個,把 2 個 item 搬成 1 個本身就不合邏輯,所以噴錯了。

那要怎麼解決?

那這個問題有辦法解決嗎?其實根據上面的敘述答案也呼之欲出了。

areItemTheSame 是判斷 item 本身是否相同,那我們需要判斷的是 item 的 content 是否不同,所以我們要用的會是 areContentTheSame。

換成這樣。因為這裡 item 本質上都是一樣的,我們根本沒有辦法 item 本身上頭區分出甚麼不同,所以一律給 true,主要需要判斷的會是 content。

那我們可以得到 DiffResult 是這樣處理的:

那流程大概是這樣:

items count : 2
position:content;
0:A;
1:A;
2:B;
3:B;

一開始要移除 index 3

items count : 2
position:content;
0:A;
1:A;
2:B;

然後告訴 index 1 和 index 2 待會要拿新的資料(要注意 bindViewHolder 會在所有異動之後),把 index 0 移除。

items count : 2
newPosition:oldPosition:content;
0:1:B;
1:2:A;

最後把資料 bind 上。

items count : 2
position:content;
0:B; -> get data from new list[0]
1:A; -> get data from new list[1]

問題解決了。

結語

寫完這篇,我有兩個結論:

  1. areItemTheSame 會傾向把 item 從 index a 搬到 index b ,areContentTheSame 會傾向看哪邊的資料可以重 bind
  2. notify Item 魔幻歸魔幻,但不論如何 old list 和 new list 的 size 一定要對上

那我這個 case 就是把兩個方法用反了所踩到的雷,而且這個 Exception 很容易追錯方向,所以其實花了不少時間在查。

但這個過程其實還蠻有趣的,工程師的樂趣就是這樣地平淡無奇。

如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。

參考文章

https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil.Callback

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.