RecyclerView 原始碼分析(六) - DiffUtil的差量演算法分析
首先,我估計有一部分的同學可能還不知道 DiffUtil
是什麼,說實話,之前我也根本不瞭解這是什麼東西。 DiffUtil
是我在公司實習的時候瞭解到的一個類,在那之前,我使用 RecyclerView
的方式也是大部分的人差不多,就是 RecyclerView
和它的四大組成部分任意組合。
當時在公司第一次看到這個東西的時候,立即兩眼發光,非常好奇這是什麼東西,就好像在大街上看到美女一樣。後來在非工作時間的時候,我去了解了一下這個類,不過當時也只是簡單的瞭解這個東西。現在在系統的學習 RecyclerView
的原始碼,我覺得有必要深入的瞭解和學習一下這個東西-- DiffUtil
。
本文參考資料:
本文有一部分的內容來自上文的翻譯。我的建議是,各位同學可以直接看上面的文章,大佬的文章已經將 DiffUtil
的核心演算法講的非常透徹。
本文打算從三個角度來分析 DiffUtil
-
DiffUtil
的基本使用 -
Myers
差量演算法的深入探究 -
DiffUtil
的Myers
演算法實現以及DiffUtil
怎麼跟Adapter
聯絡起來的
1. 概述
在正式分析 DiffUtil
之前,我們先來對 DiffUtil
有一個大概的瞭解-- DiffUtil
到底是什麼東西。
我們相信大家都遇到一種情況,那就是在一次操作裡面可能會同時出現 remove
、 add
、 change
三種操作。像這種情況,我們不能呼叫 notifyItemRemoved
、 notifyItemInserted
或者 notifyItemChanged
方法,為了檢視立即重新整理,我們只能通過呼叫 notifyDataSetChanged
方法來實現。
而 notifyDataSetChanged
方法有什麼缺點呢?沒有動畫!對,通過呼叫 notifyDataSetChanged
方法來重新整理檢視,每個操作是沒有動畫,這就很難受了!
有沒有一種方式可以實現既能保留動畫,又能重新整理動畫呢?我們單從解決問題的角度來說,我們可以設計一種演算法,來比較變化前後的資料來源有哪些變化,這裡的變化包括,如上的三種操作。哪些位置進行了change操作,哪些地方進行了add操作,哪些地方進行了remove操作,可以通過這種演算法計算出來。
Google爸爸考慮到這個問題大家都能遇到,那我幫你們實現,這樣你們就不用自己去實現了,這就是 DiffUtil
的由來。
2. DiffUtil的基本使用
在正式分析 DiffUtil
的原始碼之前,我們先來看看DiffUtil的基本使用,然後我們從基本使用入手,這樣看程式碼的時候才不會迷茫。
我們想要使用 DiffUtil
時,有一個抽象類 Callback
是我們必須瞭解的,我們來看看,瞭解它的每個方法都都有什麼作用。
方法名 | 作用 |
---|---|
getOldListSize | 原資料來源的大小 |
getNewListSize | 新資料來源的大小 |
areItemsTheSame | 判斷給定兩個Item的是否同一個Item。給定的是兩個Position,分別是原資料來源的位置和新資料來源的位置。判斷兩個Item是否是同一個Item,如果是不同的物件(新資料來源和舊資料來源持有的不是同一批物件,新資料來源可能是從舊資料來源那裡深拷貝過來,也有重新進行網路請求返回的),可以給每個Item設定一個id,如果是同一個物件,可以直接使用==來判斷 |
areContentsTheSame | 判斷給定的兩個Item內容是否相同。只有 areItemsTheSame 返回為true,才會回撥此方法。也就是說,只能當兩個Item是同一個Item,才會呼叫此方法來判斷給定的兩個Item內容是否相同。 |
getChangePayload | 用於區域性重新整理,回撥此方法表示所給定的位置肯定進行change操作,所以這裡不需要判斷是否為change操作。 |
簡單的瞭解 Callback
每個方法的作用之後,我們現在來看看 DiffUtil
是怎麼使用的。
我們先來看看 ItemCallback
是怎麼實現的:
public class RecyclerItemCallback extends DiffUtil.Callback { private List<Bean> mOldDataList; private List<Bean> mNewDataList; public RecyclerItemCallback(List<Bean> oldDataList, List<Bean> newDataList) { this.mOldDataList = oldDataList; this.mNewDataList = newDataList; } @Override public int getOldListSize() { return mOldDataList.size(); } @Override public int getNewListSize() { return mNewDataList.size(); } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { return Objects.equals(mNewDataList.get(newItemPosition).getId(), mOldDataList.get(oldItemPosition).getId()); } @Override public boolean areContentsTheSame(int i, int i1) { return Objects.equals(mOldDataList.get(i).getContent(), mNewDataList.get(i1).getContent()); } }
這裡, areItemsTheSame
方法是通過id來判斷兩個Item是不是同一個Item,其次 areContentsTheSame
方法是通過判斷content來判斷兩個Item的內容是否相同。
然後,我們再來看看 DiffUtil
是怎麼使用的:
private void refreshData() { final List<Bean> oldDataList = new ArrayList<>(); final List<Bean> newDataList = mDataList; // deep copy for (int i = 0; i < mDataList.size(); i++) { oldDataList.add(mDataList.get(i).deepCopy()); } // change for (int i = 0; i < newDataList.size(); i++) { if (i % 5 == 0) { newDataList.get(i).setContent("change data = " + i); } } // remove newDataList.remove(0); newDataList.remove(0); // add addData(5, newDataList); // diffUtil RecyclerItemCallback recyclerItemCallback = new RecyclerItemCallback(oldDataList, newDataList); DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(recyclerItemCallback, false); diffResult.dispatchUpdatesTo(mRecyclerAdapter); }
這裡我們進行一些操作,來該改變資料來源某些資料。請注意的是: 所有的操作都必須在 Adapter
的資料來源進行操作,否則這裡重新整理完全沒有意義 。正如上面的實現,在變換之前,我先將源資料深拷貝到 oldDataList
陣列,然後所有的變化操作都在 mDataList
陣列(因為它是 Adapter
的資料來源,操作它才有意義),然後將改變之後的資料稱為 newDataList
。
如下便是 DiffUtil
的真正使用:
RecyclerItemCallback recyclerItemCallback = new RecyclerItemCallback(oldDataList, newDataList); DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(recyclerItemCallback, false); diffResult.dispatchUpdatesTo(mRecyclerAdapter);
上面便是使用 DiffUtil
的固定步驟:顯示建立 ItemCallback
的物件,然後通過 DiffUtil
的 calculateDiff
方法來進行差量計算,最後就是呼叫 dispatchUpdatesTo
方法進行notify操作。
整個過程還是比較簡單的,我們來看看展示效果:

DiffUtil
的差量計算演算法,如果還有同學不明白
DiffUtil
怎麼使用,可以到我的github下載上面的Demo:
DiffUtilDemo
。
3. Myers演算法
DiffUtil
進行差量計算採用的是著名的 Myers
演算法。對於我們這種移動開發的菜逼,很少接觸到演算法,所以知道這個演算法的同學應該比較少,況且還深入瞭解它。當然大家不要怕,本文會詳細的介紹 Myers
演算法,包括它的理論和實現。放心吧,這個演算法比較簡單,我覺得比看毛片演算法還簡單。
本部分的大部分內容來自於 Investigating Myers' diff algorithm: Part 1 of 2 這篇文章,有興趣的同學可以直接看這篇文章。
(1). 定義概念
我們先來簡單分析一下我們需要達到的目的。比如說有 A
陣列和 B
陣列,我們想要達到的目的就是,從 A
陣列變成 B
陣列,分別要進行哪些操作。這些操作裡面無非是 remove
和 add
(在這裡, move
操作和 change
操作我們將拆分為 remove
和 add
操作),這裡就讓我想起來演算法題中一道題是 編輯距離
。 編輯距離
的意思就是:從A字串變成B字串的最小操作步數,這裡的操作就是上面的兩種操作,有興趣的可以看我之前的一篇文章: Java 演算法-編輯距離(動態規劃) 。
我們就可以求解從 A
陣列變成 B
陣列的問題轉換成為求解從 A
字串變成 B
字串的問題(其實,字串就是字元陣列)。
我們一步一步的分析這個問題,我們假設A字串為 ABCABBA
,B字串為 CBABAC
。然後我們可以得到下面的一個二維陣列(如下的軟體連線: DiffTutorial )。

從上面的圖片中,我們可以看出來,我們假設X軸是原字串,Y軸是新字串。其中,這個問題的目的就是我們需要從點(0,0)(原點)到點(m,n)(終點)的最短路徑,學過基本演算法的同學應該都知道,這個就是回溯法的基本操作。
然後我們在來看一張圖片:

這張圖片相對於上面的圖片,就是多了一些對角線。我們知道要想求解從(0,0)到(m,n)的最短路徑,我們只能往右或者往下走,因為往上或者往左走都是在繞路。而多了對角線之後,我們還可以走對角線,如果能走對角線,相對於往右或者往下走的話,就更加的近了。那這些對角線的是按照什麼規則畫出來的呢?
其實非常的簡單,我們就從左往右,從上往下掃描整個二維陣列,如果當前位置的x表示的字元跟y表示的字元相同的話,就畫一條對角線(從左上到右下)。從這裡,我們就可以看出來,我們想要的答案就是路徑裡面儘可能包含多的對角線。
這裡,我們簡單的定義一下,向右走一個格子或者向下走一個格子表示一步,而走一條對角線不計入步數。
我們假設向右移動一步表示從A字串中remove刪除一個字元,向下移動一步表示向B字串add一個字元。
在分析尋找路徑的演算法之前,我們先來定義幾個概念:
- snakes :一個snake表示向右或者向下走了一步,這個過程包括n個對角線。
- k lines : k lines表示長的對角線,其中每個k = x - y。假設一個點m(m,n),那麼它所在的k line值為m - n。如圖:
- d contours :每條有效路徑(能從(0,0)到(m,n)的路徑都稱為有效路徑)的步數。形象點,一條path有多個snake組成,這裡
d contours
表示snake的個數。
如上就是我們定義幾個概念。其中,如果向右走的話,k會+1,向下走,k會-1。
(2). 演算法
我們想要的答案尋找最短的有效路徑,那麼就是尋找 d contours
最小的路徑。那麼我們很容易的能實現一個迴圈,用來找到最小路徑:
for ( int d = 0 ; d <= m + n ; d++ )
我們從0開始遍歷,只要能第一次找到有效路徑,那麼這條路徑就是我們需要的答案。那麼最大值為什麼是 m + n
呢?假設這個過程沒有對角線,只能向下或者向右走,那麼最終會有 m + n
個 snake
(向下一步或者向右一步就是一個 snake
),所以d的最大值是 m + n
。
而在內迴圈裡面,我們需要遍歷在每種d值,經過了哪些k lines,所以內迴圈應該來遍歷k lines。這裡我先將內迴圈的程式碼寫出來,然後再解釋幾個問題。
for ( int k = -d ; k <= d ; k += 2 )
從上面的程式碼中,我們會有2個問題:
- 為什麼 k的範圍是[-d, d]?
- 為什麼k每次+2,而不是+1?
針對上面的問題,我進行一一的解答。首先來看看第一個問題。
k = -d,全部都向下走,因為一共d步,一共會向下走d步,所以k為-d(向下走,k會-1);當然,k = d就表示全部都向右走。
再來看看第二個問題吧。
根據我們的觀察,如果終點所在的k line是偶數,那麼最終的步數d也是偶數,反之亦然。這幾句話是什麼意思呢?這樣來說吧,如果我們經過d步就能到達終點,那麼如果d為偶數,終點所在k line也為偶數,奇數也是一樣的道理。所以k直接+2就行了,不用加1。
理解了這些的問題,現在我們需要解決的問題是,給定一個k值,怎麼來尋找有效路徑?
給定的k值,我們從k + 1向下移動一步或者從k - 1向右移動一步,然後我們就可以基於這個規則來解決我們的問題。
這裡,我們用一個例子來看一下具體是怎麼解決問題的。
A. 假設d = 3
如果d為3,那麼k的取值範圍是[-3,-1,1,-3] (根據上面的內迴圈得到的)。為了方便理解,我將所有的snake記錄成一張表,如圖:

接下來,我們將分情況來討論不同值的k。
- k = -3 :這種情況下,只有當k = -2,d = 2時,向下移動一步(k = -4, d = 2這種情況不存在)。所以,我們可以這麼來走,從(2,4)點開始向下走到(2,5),由於(2,5)和(3,6)之間存在一個對角線,可以走到(3,6)。所以著整個snake是:(2,4) -> (2,5) -> (3,6)。
- k = -1 :當k = -1時,有兩種情況需要來討論:分別是k = 0,d = 2向下走;k = -2 ,d = 2向右走。
當k = 0,d = 2時,是(2,2)點。所以當從(2,2)點向下走一步,到達(2,3),由於(2,3)沒有對角線連線,所以整個snake是:(2,3) -> (2,4)。
當k = -2 ,d = 2時,是(2,4)點。所以當從(2,4)點向右走一步,到達(2,5),由於(2,5)與(3,6)存在對角線,所以整個snake是:(2,4) -> (2,5) -> (3,6)。
在整個過程中,存在兩條snake,我們選擇是沿著k line走的最遠的snake,所以選擇第二條snake。 - k = 1 :當k = 1時,存在兩種可能性,分別是從k = 0向右走一步,或者k = 2向下走一步,我們分別來討論一下。
當k = 0,d = 2時,是(2,2)點。所以當從(2,2)向右走一步,到達(3,2),由於(3,2)與(5,4)存在對角線,所以整個snake是:(2,2) ->(3,2) ->(5,4)。
當k = 2,d = 2時,是(3,1)點。所以當從(3,1)向下走一步,到達(3,2)。所以這個snake是:(3,1) ->(3,2) ->(5,4)。
在整個過程中,存在兩條snake,我們選擇起點x值較大的snake,所以是:(3,1) ->(3,2) ->(5,4)。 - k = 3 :這種情況下,(k = 4, d = 2)是不可能的,所以我們必須在(k = 2,d = 2)時向右移動一步。當k = 2, d = 2時, 是(3,1)點。所以從(3,1)點向右移動一步是(4,1)點。所以整個snake是:(3,1) -> (4,1) -> (5,2).
B. 演算法實現
整個過程我們是很明白了,但是怎麼用程式碼來實現整個過程呢?
需要我們知道的是,d(n)的計算基於d(n - 1)的計算,同時對於每個偶數d,我們在偶數k line上面去尋找snake的終點,當然這個尋找過程是基於上一條snake在奇數k line上面的終點(因為k 是從k - 1或者 k + 1,推匯出來,如果k為偶數,那麼k - 1和k + 1肯定為奇數)。
我們假設一個V陣列,其中k作為它的index,x作為它的值,y值可以由x 和k推匯出來(k = x - y)。同時給定一個d值,k的範圍是 [-d, d],這個可以限制V陣列的值的大小。
我們必須從d = 0開始,所以我們假設V[1] = 0,這個表示(k = 1,x = 0),所在點是(0, -1)。我們必須從(0, -1)向下移動,從而保證(0,0)是必經之地。
V[ 1 ] = 0; for ( int d = 0 ; d <= N + M ; d++ ) { for ( int k = -d ; k <= d ; k += 2 ) { // down or right? bool down = ( k == -d || ( k != d && V[ k - 1 ] < V[ k + 1 ] ) ); int kPrev = down ? k + 1 : k - 1; // start point int xStart = V[ kPrev ]; int yStart = xStart - kPrev; // mid point int xMid = down ? xStart : xStart + 1; int yMid = xMid - k; // end point int xEnd = xMid; int yEnd = yMid; // follow diagonal int snake = 0; while ( xEnd < N && yEnd < M && A[ xEnd ] == B[ yEnd ] ) { xEnd++; yEnd++; snake++; } // save end point V[ k ] = xEnd; // check for solution if ( xEnd >= N && yEnd >= M ) /* solution has been found */ } }
上面的程式碼尋找一條到達終點的snake。因為V數組裡面儲存的是在k line最新端點的座標,所以為了尋找到所有的snake,我們在d的每次迴圈完畢之後,從d(Solution)遍歷到0。如下:
List<int[]> Vs; // saved V's indexed on d List<Snake> snakes; // list to hold solution Point p = new Point(n, n); // start at the end for ( int d = vs.Count - 1 ; p.X > 0 || p.Y > 0 ; d-- ) { int[] V = Vs[d]; int k = p.X - p.Y; // end point is in V int xEnd = V[k]; int yEnd = x - k; // down or right? bool down = ( k == -d || ( k != d && V[ k - 1 ] < V[ k + 1 ] ) ); int kPrev = down ? k + 1 : k - 1; // start point int xStart = V[ kPrev ]; int yStart = xStart - kPrev; // mid point int xMid = down ? xStart : xStart + 1; int yMid = xMid - k; snakes.Insert( 0, new Snake( /* start, mid and end points */ ) ); p.X = xStart; p.Y = yStart; }
Investigating Myers' diff algorithm: Part 1 of 2 文章是用C#寫的,我這裡將它簡單改寫稱為Java。
為什麼這裡會倒著來遍歷,也就是說,為什麼從最後一條snake遍歷到第一條snake呢?最後一條snake肯定是我們想要的有效路徑的必經之路,所以倒著來尋找snake,肯定是找到的snake都是在有效路徑上,因為Vs數組裡面還有其他情況下的snake。
4. DiffUtil的實現
(1). DiffUtil生成DiffResult
我相信,經過上面的理解,大家在看 DiffUtil
的演算法時,應該都能明白。 DiffUtils
程式碼實現主要集中在 diffPartial
方法裡面。
diffPartial
方法主要是來尋找一條snake,它的核心也就是 Myers
演算法,這裡我們將不分析了。 calculateDiff
方法是不斷的呼叫 diffPartial
方法,然後將尋找到的snake放入一個數組裡面,最後就是建立一個 DiffResult
物件,將所有的snake作為引數傳遞過去。
在 DiffResult
類的內部,分別有兩個陣列來儲存狀態,分別是: mOldItemStatuses
,用來的舊Item的狀態; mNewItemStatuses
,用來儲存新Item的狀態。那麼這兩個陣列是在哪裡被賦值呢?答案就在 findMatchingItems
方法(在 DiffResult
的構造方法裡面呼叫):
private void findMatchingItems() { int posOld = mOldListSize; int posNew = mNewListSize; // traverse the matrix from right bottom to 0,0. for (int i = mSnakes.size() - 1; i >= 0; i--) { final Snake snake = mSnakes.get(i); final int endX = snake.x + snake.size; final int endY = snake.y + snake.size; if (mDetectMoves) { while (posOld > endX) { // this is a removal. Check remaining snakes to see if this was added before findAddition(posOld, posNew, i); posOld--; } while (posNew > endY) { // this is an addition. Check remaining snakes to see if this was removed // before findRemoval(posOld, posNew, i); posNew--; } } for (int j = 0; j < snake.size; j++) { // matching items. Check if it is changed or not final int oldItemPos = snake.x + j; final int newItemPos = snake.y + j; final boolean theSame = mCallback .areContentsTheSame(oldItemPos, newItemPos); final int changeFlag = theSame ? FLAG_NOT_CHANGED : FLAG_CHANGED; mOldItemStatuses[oldItemPos] = (newItemPos << FLAG_OFFSET) | changeFlag; mNewItemStatuses[newItemPos] = (oldItemPos << FLAG_OFFSET) | changeFlag; } posOld = snake.x; posNew = snake.y; } }
findMatchingItems
方法的具體細節這裡我們就不討論了,其中 findMatchingItems
方法只做一件事情:更新 mOldItemStatuses
和 mNewItemStatuses
陣列。同時如果 mDetectMoves
為true,會計算move的操作,通常來說,我們都會設定為true。
當這裡我們對 DiffUtil
生成 DiffResult
的過程已經瞭解的差不多了,加下來,我們在討論一個方法就是 dispatchUpdatesTo
方法
(2). DiffResult和Adapter
整個 DiffResult
構造完成之後,就需要將整個變化過程作用於 Adapter
的更新,也就是 dispatchUpdatesTo
方法呼叫。
public void dispatchUpdatesTo(ListUpdateCallback updateCallback) { final BatchingListUpdateCallback batchingCallback; if (updateCallback instanceof BatchingListUpdateCallback) { batchingCallback = (BatchingListUpdateCallback) updateCallback; } else { batchingCallback = new BatchingListUpdateCallback(updateCallback); // replace updateCallback with a batching callback and override references to // updateCallback so that we don't call it directly by mistake //noinspection UnusedAssignment updateCallback = batchingCallback; } // These are add/remove ops that are converted to moves. We track their positions until // their respective update operations are processed. final List<PostponedUpdate> postponedUpdates = new ArrayList<>(); int posOld = mOldListSize; int posNew = mNewListSize; for (int snakeIndex = mSnakes.size() - 1; snakeIndex >= 0; snakeIndex--) { final Snake snake = mSnakes.get(snakeIndex); final int snakeSize = snake.size; final int endX = snake.x + snakeSize; final int endY = snake.y + snakeSize; if (endX < posOld) { dispatchRemovals(postponedUpdates, batchingCallback, endX, posOld - endX, endX); } if (endY < posNew) { dispatchAdditions(postponedUpdates, batchingCallback, endX, posNew - endY, endY); } for (int i = snakeSize - 1; i >= 0; i--) { if ((mOldItemStatuses[snake.x + i] & FLAG_MASK) == FLAG_CHANGED) { batchingCallback.onChanged(snake.x + i, 1, mCallback.getChangePayload(snake.x + i, snake.y + i)); } } posOld = snake.x; posNew = snake.y; } batchingCallback.dispatchLastEvent(); }
dispatchUpdatesTo
方法看上去比較難,其實表達的意思非常簡單,就是根據不同的狀態呼叫 Adapter
不同的方法。這裡不同的就是,沒有直接呼叫 Adapter
的方法,而是使用了介面卡模式,用 AdapterListUpdateCallback
來包裹了一下 Adapter
,然後通過 AdapterListUpdateCallback
的方法來呼叫 Adapter
的方法。
這樣做有什麼好處呢?在 DiffUtil
看到的不是 Adapter
,而是 ListUpdateCallback
介面,所以後面如果 Adapter
的API有啥變化,可以只改 AdapterListUpdateCallback
類,而不用更改 DiffUtil
類。這樣做非常的友好,同時我們在這裡可以學習到兩點:
- 適當的使用介面卡模式,將一些操作封裝到介面卡類裡面,當依賴類的API有所改變,我們只需改變介面卡類就行,而不用更改那麼複雜的類,因為複雜類更改起來非常的麻煩。在這裡,依賴類是
Adapter
,複雜類是DiffUtil
。 - 如果使用一個類,但是必須保證這個類實現某個介面。我們不妨使用介面卡模式,設計一個介面卡類來實現介面,在介面卡操作想要使用的那個類。這樣能避免每個類去實現沒必要的介面,在這裡
Adapter
就沒必要實現ListUpdateCallback
的介面,所以可以使用介面卡模式來包裹一下Adapter
就行了。
5. 總結
到這裡,我們對 DiffUtil
的演算法已經有一定的理解了,最後,我再對此進行簡單的總結。
-
DiffUtil
應開發者的需求產生,我們應該去使用並且理解它。 -
DiffUtil
的差量計算採用的是Myers
演算法,具體的演算法分析可以參考上面的描述。 - 適當的使用介面卡模式,可以減少一個類去實現一些沒必要的介面。
如果不出意外的話,接下來我將分析 LayoutManager
。