RecyclerView 源碼分析(六) - DiffUtil的差量算法分析

  首先,我估計有一部分的同窗可能還不知道DiffUtil是什麼,說實話,以前我也根本不瞭解這是什麼東西。DiffUtil是我在公司實習的時候瞭解到的一個類,在那以前,我使用RecyclerView的方式也是大部分的人差很少,就是RecyclerView和它的四大組成部分任意組合。git

  當時在公司第一次看到這個東西的時候,當即兩眼發光,很是好奇這是什麼東西,就好像在大街上看到美女同樣。後來在非工做時間的時候,我去了解了一下這個類,不過當時也只是簡單的瞭解這個東西。如今在系統的學習RecyclerView的源碼,我以爲有必要深刻的瞭解和學習一下這個東西--DiffUtil。   本文參考資料:github

  1. Investigating Myers' diff algorithm: Part 1 of 2

  本文有一部分的內容來自上文的翻譯。個人建議是,各位同窗能夠直接看上面的文章,大佬的文章已經將DiffUtil的核心算法講的很是透徹。算法

  本文打算從三個角度來分析DiffUtil數組

  1. DiffUtil的基本使用
  2. Myers差量算法的深刻探究
  3. DiffUtilMyers算法實現以及DiffUtil怎麼跟Adapter聯繫起來的

1. 概述

  在正式分析DiffUtil以前,咱們先來對DiffUtil有一個大概的瞭解--DiffUtil究竟是什麼東西。   咱們相信你們都遇到一種狀況,那就是在一次操做裏面可能會同時出現removeaddchange三種操做。像這種狀況,咱們不能調用notifyItemRemovednotifyItemInserted或者notifyItemChanged方法,爲了視圖當即刷新,咱們只能經過調用notifyDataSetChanged方法來實現。bash

  而notifyDataSetChanged方法有什麼缺點呢?沒有動畫!對,經過調用notifyDataSetChanged方法來刷新視圖,每一個操做是沒有動畫,這就很難受了!網絡

  有沒有一種方式能夠實現既能保留動畫,又能刷新動畫呢?咱們單從解決問題的角度來講,咱們能夠設計一種算法,來比較變化先後的數據源有哪些變化,這裏的變化包括,如上的三種操做。哪些位置進行了change操做,哪些地方進行了add操做,哪些地方進行了remove操做,能夠經過這種算法計算出來。ide

  Google爸爸考慮到這個問題你們都能遇到,那我幫大家實現,這樣大家就不用本身去實現了,這就是DiffUtil的由來。post

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的對象,而後經過DiffUtilcalculateDiff方法來進行差量計算,最後就是調用dispatchUpdatesTo方法進行notify操做。

  整個過程仍是比較簡單的,咱們來看看展現效果:

  瞭解完DiffUtil是怎麼使用,接下來咱們將正式 DiffUtil的差量計算算法,若是還有同窗不明白 DiffUtil怎麼使用,能夠到個人github下載上面的Demo: DiffUtilDemo

3. Myers算法

  DiffUtil進行差量計算採用的是著名的Myers算法。對於咱們這種移動開發的菜逼,不多接觸到算法,因此知道這個算法的同窗應該比較少,何況還深刻了解它。固然你們不要怕,本文會詳細的介紹Myers算法,包括它的理論和實現。放心吧,這個算法比較簡單,我以爲比看毛片算法還簡單。

  本部分的大部份內容來自於Investigating Myers' diff algorithm: Part 1 of 2這篇文章,有興趣的同窗能夠直接看這篇文章。

(1). 定義概念

  咱們先來簡單分析一下咱們須要達到的目的。好比說有A數組和B數組,咱們想要達到的目的就是,從A數組變成B數組,分別要進行哪些操做。這些操做裏面無非是removeadd(在這裏,move操做和change操做咱們將拆分爲removeadd操做),這裏就讓我想起來算法題中一道題是編輯距離編輯距離的意思就是:從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一個字符。

  在分析尋找路徑的算法以前,咱們先來定義幾個概念:

  1. snakes:一個snake表示向右或者向下走了一步,這個過程包括n個對角線。
  2. k lines: k lines表示長的對角線,其中每一個k = x - y。假設一個點m(m,n),那麼它所在的k line值爲m - n。如圖:
      其中橙色線表示偶數k line,棕色線表示奇數k line。
  3. 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 + nsnake(向下一步或者向右一步就是一個snake),因此d的最大值是 m + n

  而在內循環裏面,咱們須要遍歷在每種d值,通過了哪些k lines,因此內循環應該來遍歷k lines。這裏我先將內循環的代碼寫出來,而後再解釋幾個問題。

for ( int k = -d ; k <= d ; k += 2 )
複製代碼

  從上面的代碼中,咱們會有2個問題:

  1. 爲何 k的範圍是[-d, d]?
  2. 爲何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。

  1. 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)。
  2. 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。
  3. 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)。
  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方法只作一件事情:更新mOldItemStatusesmNewItemStatuses數組。同時若是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方法看上去比較難,其實表達的意思很是簡單,就是根據前面計算出來的mOldItemStatusesmNewItemStatuses數組來調用Adapter不一樣的方法。這裏不一樣的就是,沒有直接調用Adapter的方法,而是使用了適配器模式,用AdapterListUpdateCallback來包裹了一下Adapter,而後經過AdapterListUpdateCallback的方法來調用Adapter的方法。

  這樣作有什麼好處呢?在DiffUtil看到的不是Adapter,而是ListUpdateCallback接口,因此後面若是Adapter的API有啥變化,能夠只改AdapterListUpdateCallback類,而不用更改DiffUtil類。這樣設計很是的友好,同時咱們在這裏能夠學習到兩點:

  1. 適當的使用適配器模式,將一些操做封裝到適配器類裏面,當依賴類的API有所改變,咱們只需改變適配器類就行,而不用更改那麼複雜的類,由於複雜類更改起來很是的麻煩。在這裏,依賴類是Adapter,複雜類是DiffUtil
  2. 若是使用一個類,可是必須保證這個類實現某個接口。咱們不妨使用適配器模式,設計一個適配器類來實現接口,在適配器操做想要使用的那個類。這樣能避免每一個類去實現不必的接口,在這裏Adapter就不必實現ListUpdateCallback的接口,因此可使用適配器模式來包裹一下Adapter就好了。

5. 總結

  到這裏,咱們對DiffUtil的算法已經有必定的理解了,最後,我再對此進行簡單的總結。

  1. DiffUtil應開發者的需求產生,咱們應該去使用而且理解它。
  2. DiffUtil的差量計算採用的是Myers算法,具體的算法分析能夠參考上面的描述。
  3. 適當的使用適配器模式,能夠減小一個類去實現一些不必的接口。

  若是不出意外的話,接下來我將分析LayoutManager

相關文章
相關標籤/搜索