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

3,226 阅读18分钟

  首先,我估计有一部分的同学可能还不知道DiffUtil是什么,说实话,之前我也根本不了解这是什么东西。DiffUtil是我在公司实习的时候了解到的一个类,在那之前,我使用RecyclerView的方式也是大部分的人差不多,就是RecyclerView和它的四大组成部分任意组合。

  当时在公司第一次看到这个东西的时候,立即两眼发光,非常好奇这是什么东西,就好像在大街上看到美女一样。后来在非工作时间的时候,我去了解了一下这个类,不过当时也只是简单的了解这个东西。现在在系统的学习RecyclerView的源码,我觉得有必要深入的了解和学习一下这个东西--DiffUtil。   本文参考资料:

  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方法来实现。

  而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的对象,然后通过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