DiffUtil詳解

kengtion發表於2019-07-29

演算法概述

在使用RecyclerView時,會經常遇到資料變化需要重新整理列表的情況,如果資料變化非常頻繁,而且每次都只改變了其中的一小部分,在這種情況下,通過Adapter.notifyDataSetChanged()直接對整個列表進行重新整理會對app的效能帶來影響。實際上,Adapter也提供了一系列的方法來重新整理發生變化的資料,如notifyItemChanged、notifyItemMoved等,但是在使用這些方法時,就需要對資料的變化進行計算,而且為了達到最好的效能表現,這個計算也不能太過複雜,否則就違背了減少效能損耗的初衷,而DiffUtil,就是對這一計算過程的封裝。

DiffUtil是support包V25版本引入的工具類,用於計算兩個List之間的差異,並且得到一個可以將舊的List轉換成新的List的編輯指令碼,這個結果會被封裝成DiffResult。在計算量很大的時候,可以使用AsyncListDiffer在後臺程式中進行計算,然後在主執行緒中獲取DiffResult並應用到RecyclerView中。

在DiifUtil中,計算List間的差異,並得到編輯過程的演算法是Myers差分演算法,這個演算法將通過有向無環圖來表示將舊的序列轉換成新序列的過程,然後在這個圖中能連通左上和右下頂點的最短的路徑就是問題的最優解。演算法的具體過程如下:

假設舊的序列A=a1,a2,a3……an,目標序列B=b1,b2,b3……bm 是兩個長度分別為n和m的序列。以A序列為橫軸座標,B序列為縱軸座標,通過邊連線相鄰的點,橫邊連線右鄰點,豎邊連線下鄰點。如果存在點(x,y)使ax = by,則在(x-1,y-1)和(x,y)之間增加一條斜邊,(x,y)稱做匹配點。例如,對於序列A=ABCABBA以及序列B=CBABAC得到的編輯圖如下:

DiffUtil詳解
) 在這個圖中,向右移動表示一次刪除,向下移動表示一次增加,沿斜線移動表示不做任何操作,簡單來說,每條橫邊或豎邊都代表一次操作,那麼尋找能將序列A轉換成序列B的最少的操作可以視作尋找橫邊、豎邊之和最小的路徑。對於每個點(x,y),如果x-y = K,那麼稱這個點位於K斜線上,即K斜線為滿足x-y=K的所有的點,D-Path指移動D步的路徑,沿斜線的移動不計步。在對角線不計入路徑長度的情況下,可以很簡單的證明D-Path的終點必定在k斜線上,其中k為小於D大於-D的數,並且還能證明在D為偶數的情況下K也為偶數。此時,問題就變成了尋找D最小的起點為(0,0)終點為(M,N)的D-Path。

在DiffUtil中,通過查詢位於目標D-path中間的斜線,將查詢區域分隔成斜線的左上方和右下方兩個子區域,然後對子區域進行遞迴查詢的方式來得到。Myers在論文中證明了對於能達到最遠點的(K,D-Path)對,可以通過(K-1,(D-1)-Path)通過貪心查詢來得到。

演算法實現

具體的實現如下:

 while (!stack.isEmpty()) {//stack就是需要進行查詢的區域
            Range range = stack.remove(stack.size() - 1);
            //得到D-Path中的斜線
            Snake snake = diffPartial(callback, range.oldListStart, range.newListStart,
                    range.oldListEnd, range.newListEnd, forward, backward, max);
            if (snake != null) {
                if (snake.size > 0) {
                    //snake的長度大於0,是一個有效的子序列
                    snakes.add(snake);
                }
                //得到的子序列是以區域的左上角為原點的,需要經過二次處理還原為正確的結果
                snake.x += range.oldListStart;
                snake.y += range.newListStart;

                //根據找到的斜線對剩下的區域進行劃分
                Range left = rangePool.isEmpty() ? new Range() : rangePool.remove(rangePool.size() - 1);
                left.oldListStart = range.oldListStart;
                left.newListStart = range.newListStart;
                if (snake.reverse) {
                    left.oldListEnd = snake.x;
                    left.newListEnd = snake.y;
                } else {
                    if (snake.removal) {
                        left.oldListEnd = snake.x - 1;
                        left.newListEnd = snake.y;
                    } else {
                        left.oldListEnd = snake.x;
                        left.newListEnd = snake.y - 1;
                    }
                }
                //加入棧中
                stack.add(left);
                //將右下區域壓入棧的過程省略
                ....
            } 
            //省略
            ....
        }
複製程式碼

從這個方法中可以發現,在DiffUtil中得到的斜線有兩個布林型屬性reverse和removal,這兩個屬性是在diffPartical函式中查詢斜線時賦值的,diffPartical函式的返回值是能以最短的步數連線區域左上頂點和右下頂點的斜線,reverse為true表示這個斜線是在反向查詢時得到的,removal為true表示這個斜線的上一步是沿橫線移動,即移除元素。那麼這兩個引數又是怎麼得到的呢?在diffPartical函式中,通過同時從左上頂點和右下頂點出發,找到某個D值,使從左上頂點出發能到達的點位於從右下頂點出發能到達的點的右下方,則這兩個到達點就位於目標斜線的兩端。很顯然,在實現中,從左上頂點出發和從右下頂點出發這兩個過程是交替進行的,而不是並行的,那就需要在兩個過程中都進行一次判斷,reverse屬性就表示了diffPartical得到的結果是在反向查詢中得到的還是在正向查詢中得到的。在diffPartical函式中還有額外的判斷,在兩個序列的長度差是偶數時,在正向查詢時就能找到貫通線,否則就是在反向查詢中才能找到。對於removal屬性,先回顧一下k-線的概念,可以很簡單的得到當k=d時,只能從(d-1)-path上的k-1線右移到達k-線,當k=-d時,只能從(d-1)-path上的-k+1線下移到達k-線,其他情況下,(d,k)可以由(d-1,k+1)或(d-1,k-1)到達,而從(d-1,k+1)到達(d,k),是沿豎線下移,新增元素,removal就是false,從(d-1,k-1)到達(d,k)就是沿橫線右移,刪除元素,removal為true。diffPartical的具體實現如下:

 public static Snake partical(DiffCallback cb, int startOld, int startNew, int endOld, int endNew, int[] forward, int[] backward, int kOffset) {
        //陣列的下標不能取負,通過offset來處理k<0的情況
        int oldSize = endOld - startOld;
        int newSize = endNew - startNew;
        if (endOld - startOld < 1 || endNew - startNew < 1) {
            return null;
        }
        int dLimit = (oldSize + newSize + 1) / 2;//d的最大值不會超過長度的平均值
        int delta = oldSize - newSize;
        /**
         * forward和backward記錄每一步能到達的最遠點的x座標,forward記錄從(0,0)正向查詢,backward記錄從(m,n)開始的反向查詢
         * 這一步預先填充起始點的位置
         */
        Arrays.fill(forward, kOffset - dLimit - 1, kOffset + dLimit + 1, 0);
        Arrays.fill(backward, kOffset - dLimit - 1 + delta, kOffset + dLimit + 1 + delta, oldSize);
        /**
         * 這個標誌的含義就是認為在delta是偶數時,在正向查詢時就能找到貫通線,否則找不到。如何證明?
         * 先從delta = 0時考慮,兩個序列長度相等,kBackward = k,正向查詢和反向查詢是一致的,由於先進行正向查詢,所以必定會在正向查詢時得出結果
         * 假定能d=d0時在k0線找到的點為(x0,y0)開始的長度為l的線。
         * 向newList插入一個點y1,此時delta = 1;三種情況
         * 1.y1 > y0+l  即原先的貫通線仍然保留,則d=d0時,正向查詢會到達k0線上的(x0+l,y0+l),反向查詢在k0線上是(x0+1+l,y0+1+l),無法找到貫通線
         * 而在d=d0+1時,正向查詢會達到(x0+l+1,y0+l)(刪除優先),此時還沒進行新的反向查詢,是與(x0+1+l,y0+1+l)對比不會得出結果,進行反向查詢後,會
         * 達到(x0,y0),得出結果
         * 剩下的情況類似。
         * 應該是這樣證明,不保證正確性。
         */
        boolean checkInFwd = delta % 2 != 0;
        for (int d = 0; d < dLimit; d++) {
            /**
             * 這一迴圈是從(0,0)出發找到移動d步能達到的最遠點
             * 引理:d和k同奇同偶,所以每次k都遞增2
             */
            for (int k = -d; k <= d; k += 2) {
                int x;
                boolean removal;
                /**
                 * K-線可以由(K+1)-線或(K—1)-線到達,判斷哪一個能達到更遠的位置,k=-d時,只能是從k+1線向下移動到達,此時的x為起始位置的x
                 * forward[kOffset + k - 1] < forward[kOffset + k + 1]表明k+1的位置更遠
                 */
                if (k == -d || (k != -d && forward[kOffset + k - 1] < forward[kOffset + k + 1])) {
                    //從K+1線到K線的過程是x不變,y=y+1,即增加新元素
                    x = forward[kOffset + k + 1];
                    removal = false;
                } else {
                    //從K-1線到K線的過程是y不變,x=x+1,即移除舊元素
                    x = forward[kOffset + k - 1] + 1;
                    removal = true;
                }
                //k = x-y,知道k和x,就可以得到y
                int y = x - k;
                //沿斜線移動到最遠的位置
                while (x < oldSize && y < newSize
                        && cb.areContentSame(startOld + x, startNew + y)) {
                    x++;
                    y++;
                }
                //移動過後的x就是當前的d下,從(0,0)出發,在k線上最遠的位置,經過對k的迴圈後,就是當前d下能達到的最遠點
                forward[kOffset + k] = x;
                if (checkInFwd && k >= delta - d + 1 && k <= delta + d - 1) {//TODO 這一步沒看懂,為什麼是在k<=delta-d+1
                    /**
                     * 如果在k線上正向查詢能到到的位置的x座標比反向查詢達到的y座標小
                     * Xf > Xb
                     * Kf = kB
                     * 則Yf > Yb
                     * 即反向查詢達到的最遠位置在正向查詢達到的位置的左上角,由於都在同一k線上,所以這兩個點必定位於一條斜線的兩端
                     */
                    if (forward[k + kOffset] >= backward[k + kOffset]) {
                        Snake outSnake = new Snake();
                        outSnake.x = backward[k + kOffset];
                        outSnake.y = outSnake.x - k;
                        outSnake.size = forward[k + kOffset] - backward[kOffset + k];//兩個點位於一條斜線的兩端,那麼斜線的長度也就是x的差值
                        outSnake.removal = removal;
                        outSnake.reverse = false;
                        //到這一步就是找到了起始點為(x,x-k),長為size的斜線,也就是公共子序列
                        return outSnake;
                    }
                }
            }
            /**
             * 這一迴圈是從(m,n)出發找到移動d步能達到的最遠點
             */
            for (int k = -d; k <= d; k += 2) {
                /**
                 * 同樣的,k線可以從k-1線和k+1線到達,與正向查詢的情況不太一樣的是,反向查詢是從delta線出發的,所以需要經過一次轉換
                 * kBackward = k + delta;
                 * 簡單的稱之為k差線
                 */
                int kBackward = k + delta;
                int x;
                boolean removal;
                /**
                 * 與k線類似,k差線可以由k-1差線和k+1差線到達,但是這裡的最遠的點是x最小的位置。
                 * kBackward = d + delta 時,只能從kBackward -1 向上移動得到,x不變
                 * kBackward = -d + delta,只能從kBackward + 1向左移動得到
                 * 由於是反向的查詢,只能向上或向左移動,因此這一步對kBackward = d+delta時,即k=d時進行特殊處理
                 */
                if (kBackward == d + delta || (backward[kBackward + kOffset - 1] < backward[kBackward + kOffset + 1])) {
                    x = backward[kOffset + kBackward - 1];
                    removal = false;
                } else {
                    x = backward[kOffset + kBackward + 1] - 1;
                    removal = true;
                }
                /**
                 * 因為在k差線上,所以y=x-kBackward
                 */
                int y = x - kBackward;
                while (x > 0 && y > 0
                        && cb.areContentSame(startOld + x - 1, startNew + y - 1)) {
                    x--;
                    y--;
                }
                //在k差線上的點也在k+delta線上,直接存就完事了
                backward[kOffset + kBackward] = x;
                if (checkInFwd && k + delta >= -d && k + delta <= d) {//TODO 同樣沒看懂
                    /**
                     * 在kBackward線上的看正向反向是否連通了
                     */
                    if (forward[kOffset + kBackward] >= backward[kOffset + kBackward]) {
                        Snake outSnake = new Snake();
                        outSnake.x = backward[kOffset + kBackward];
                        outSnake.y = outSnake.x - kBackward;
                        outSnake.size = forward[kOffset + kBackward] - backward[kOffset + kBackward];
                        outSnake.removal = removal;
                        outSnake.reverse = true;
                        return outSnake;
                    }
                }
            }
        }
        //沒找到就拋異常,正常情況下不可能找不到,對d的遍歷最大可以到2*Math.max(oldListSize,newListSize),沿著座標軸走也走到了
        throw new IllegalStateException("DiffUtil hit an unexpected case while trying to calculate"
                + " the optimal path. Please make sure your data is not changing during the"
                + " diff calculation.");
    }
複製程式碼

到這一步,就得到了一個目標D-path上的所有的斜線的集合,將斜線連線,得到最終的路徑的過程在DiffResult中。

DiffResult

在介紹DiffResult之前,首先需要了解,在Myers演算法中,當多個元素在List中移動時,Myers演算法可能會選擇其中的一個作為錨點,將這個點標記為無變化的同時將其他的點標記為增加或移除。而被標記為NOT_CHANGED的點只是不會被分發為 移動/增加/刪除,而不是真的仍然在序列中保持原樣。

在DiffResult的構造方法中,首先會檢查一下calculateDiff得到的斜邊集合的起點,也就是第一條snake,如果這條線不是從(0,0)開始的,那就增加一條長度為0,位於(0,0)的線作為起始點。這樣在對Snakes從頭到尾遍歷一次時,就可以完成從(0,0)到(m,n)的通路。

Snake firstSnake = mSnakes.isEmpty() ? null : mSnakes.get(0);
            if (firstSnake == null || firstSnake.x != 0 || firstSnake.y != 0) {
                Snake root = new Snake();
                root.x = 0;
                root.y = 0;
                root.removal = false;
                root.size = 0;
                root.reverse = false;
                mSnakes.add(0, root);
            }
複製程式碼

在增加起始點之後,DiffResult的構建方法中還會進行一個狀態的初始化,在這個過程中,會從後到前的遍歷每一個Snake。

//從最後一個item開始,遍歷全部的Snake中的全部節點
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) {
                   .....//先忽略這一步
                }
                for (int j = 0; j < snake.size; j++) {
                    final int oldItemPos = snake.x + j;
                    final int newItemPos = snake.y + j;
                    final boolean theSame = mCallback
                            .areContentsTheSame(oldItemPos, newItemPos);
                    //這個標記item是否發生改變,在DiffResult中,這種標誌有五位,FLAG_OFFSET=5,此外還有一個值為0b11111的掩碼FLAG_MASK
                    final int changeFlag = theSame ? FLAG_NOT_CHANGED : FLAG_CHANGED;
                    //將狀態放入表中,newItemPos這個數的影響實際上會在之後與掩碼的與計算中被消除,實際起作用的是changeFlag
                    mOldItemStatuses[oldItemPos] = (newItemPos << FLAG_OFFSET) | changeFlag;
                    mNewItemStatuses[newItemPos] = (oldItemPos << FLAG_OFFSET) | changeFlag;
                }
                posOld = snake.x;
                posNew = snake.y;
}
複製程式碼

在不考慮detectmove的情況下,DiffResult的構建已經完成,在這之後可以就可以呼叫dispatchUpdateTo(ListUpdateCallback updateCallback)方法將變化分發到執行變化的回撥中,這一步的引數可以直接傳入Adapter,也可以傳入自己實現的UpdateCallback。區別在於直接傳入Adapter的話會建立一個新的AdapterListUpdateCallback物件。分發時,也會從最後一個snake開始對snakes進行一次遍歷

 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;
                //如果snake的最右、最下的點位於上一個snake點的左邊則說明從上一個snake到這一個之間存在移除的過程。不清楚的話可以在回顧一下編輯圖
                if (endX < posOld) {
                    dispatchRemovals(postponedUpdates, batchingCallback, endX, posOld - endX, endX);
                }
                //如果snake的最右、最下的點位於上一個snake點的上邊則說明從上一個snake到這一個之間存在增加的過程。
                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;
            }
複製程式碼

dispatchRemovals和dispatchAdditions方法內部非常簡單,就是對這些點進行遍歷,然後從狀態表中查詢它們的狀態,並根據狀態進行分發。到這一步,detected引數為false時,差異計算、結果分發過程就完成了,adapter或者UpdateCallback會根據接收到的結果進行對應的重新整理。detected為true時,會在構造DiffResult初始化狀態時增加一個步驟,在遍歷snake時,如果存在被移除的節點,會從剩餘的未遍歷的snake中進行一次查詢,看看是否有相同的item被增加,如果是增加的節點則是查詢是否存在內容相同的移除過程。

 private void findMatchingItems() {
              ...//省略
            for (int i = mSnakes.size() - 1; i >= 0; i--) {
                ...//省略
                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--;
                    }
                }
                ...//省略
            }
        }
複製程式碼

以上就是DiffUtil的全部內容,總結一下:

  1. 通過編輯圖的方式表示兩個序列的轉換過程,向右移動表示刪除,向下移動表示增加,將問題轉換為尋找編輯圖中的最短路徑問題。
  2. 通過查詢遞迴查詢貫通線的方法得到尋找最短路勁的解
  3. 遍歷最短路徑標記每個節點的狀態,然後根據狀態進行分發

參考

[1] xmailserver.org/diff2.pdf

[2] juejin.im/post/5b0bbc…