RecyclerView 之 DiffUtil

作者: BugFree张瑞 | 来源:发表于2018-12-04 15:03 被阅读41次

    一、前言

    DiffUtils 是 Support-v7:24:2.0 中,更新的工具类,主要是为了配合RecyclerView 使用,通过比对新、旧两个数据集的差异,生成旧数据到新数据的最小变动,然后对有变动的数据项,进行局部刷新。

    DiffUtil is a utility class that can calculate the difference between two lists and output a list of update operations that converts the first list into the second one.

    官方文档:

    https://developer.android.com/reference/android/support/v7/util/DiffUtil

    参考链接:

    https://blog.csdn.net/zxt0601/article/details/52562770

    https://www.jianshu.com/p/b9af71778b0d

    https://medium.com/@iammert/using-diffutil-in-android-recyclerview-bdca8e4fbb00

    https://medium.com/mindorks/diffutils-improving-performance-of-recyclerview-102b254a9e4a

    https://segmentfault.com/a/1190000007205469

    https://juejin.im/entry/57bbb7f60a2b58006cbd9e0c

    https://proandroiddev.com/diffutil-is-a-must-797502bc1149

    二、为什么会推出DiffUtil

    RecyclerView是我们日常开发中最常用的组件之一。当我们滑动列表,我们要去更新视图,更新数据。我们会从服务器获取新的数据,需要处理旧的数据。通常,随着每个item越来越复杂,这个处理过程所需的时间也就越多。在列表滑动过程中的处理延迟的长短,决定着对用户体验的影响的多少。所以,我们会希望需要进行的计算越少越好。

    RecyclerView 自从被发布以来,一直被说成是 ListView、GridView 等一系列列表控件的完美替代品。并且它本身使用起来也非常的好用,布局切换方便、自带ViewHolder、局部更新并且可带更新动画等等。

    局部更新、并且可以很方便的设置更新动画这一点,是 RecyclerView 一个不错的亮点。它为此提供了对应的方法:

    • adapter.notifyItemChange()
    • adapter.notifyItemInserted()
    • adapter.notifyItemRemoved()
    • adapter.notifyItemMoved()

    以上方法都是为了对数据集中,单一项进行操作,并且为了操作连续的数据集的变动,还提供了对应的 notifyRangeXxx() 方法。虽然 RecyclerView 提供的局部更新的方法,看似非常的好用,但是实际上,其实并没有什么用。在实际开发中,最方便的做法就是无脑调用 notifyDataSetChanged(),用于更新 adapter 的数据集。

    虽然 notifyDataSetChanged 有一些缺点:

    • 不会触发 RecyclerView 的局部更新的动画。
    • 性能低,会刷新整个 RecyclerView 可视区域((all visible view on screen and few buffer view above and below the screen))

    但是真有需要频繁刷新,前后有两个数据集的场景,一个 notifyDataSetChanged() 方法,会比自己写一个数据集比对方法,然后去计算他们的差值,最后调用对应的方法更新到 RecyclerView 中去要更方便。于是,Google就发布了DiffUtil。

    有一个特别适合使用的场景便是下拉刷新,不仅有动画,效率也有提高,尤其是下拉刷新操作后,Adapter内集合数据并没有发生改变,不需要进行重新绘制RecyclerView时。

    三、介绍DiffUtil

    它能很方便的对两个数据集之间进行比对,然后计算出变动情况,配合RecyclerView.Adapter ,可以自动根据变动情况,调用 adapter 的对应方法。当然,DiffUtil 不仅只能配合 RecyclerView 使用,它实际上可以单独用于比对两个数据集,然后如何操作是可以定制的,那么在什么场景下使用,就全凭我们自己发挥了。

    DiffUtil 在使用起来,主要需要关注几个类:

    • DiffUtil.Callback:具体用于限定数据集比对规则。
    • DiffUtil.DiffResult:比对数据集之后,返回的差异结果。

    1、DiffUtil.Callback

    DiffUtil.Callback 主要就是为了限定两个数据集中子项的比对规则。毕竟开发者面对的数据结构多种多样,既然没法做一套通用的内容比对方式,那么就将比对的规则,交还给开发者来实现即可。

    它拥有 4 个抽象方法和 1 个非抽象方法的抽象类。我们需要继承并实现它的所有方法:在自定义的 Callback 中,其实需要实现 4 个方法:

    • getOldListSize():旧数据集的长度。
    • getNewListSize():新数据集的长度
    • areItemsTheSame():判断是否是同一个Item。
    • areContentsTheSame():如果是通一个Item(即areItemsTheSame返回true),此方法用于判断是否同一个 Item 的内容也相同。

    前两个是获取数据集长度的方法,这没什么好说的。但是后两个方法,主要是为了对应多布局的情况产生的,也就是存在多个 viewType 和多个 ViewHodler 的情况。首先需要使用 areItemsTheSame() 方法比对是否是同一个 viewType(也就是同一个ViewHolder) ,然后再通过 areContentsTheSame() 方法比对其内容是否也相等。

    其实 Callback 还有一个 getChangePayload() 的方法,它可以在 ViewType 相同,但是内容不相同的时候,用 payLoad 记录需要在这个 ViewHolder 中,具体需要更新的View。

    areItemsTheSame()、areContentsTheSame()、getChangePayload() 分别代表了不同量级的刷新。

    首先会通过 areItemsTheSame() 判断当前 position 下,ViewType是否一致,如果不一致就表明当前position下,从数据到UI结构上全部变化了,那么就不关心内容,直接更新就好了。如果一致的话,那么其实View是可以复用的,就还需要再通过 areContentsTheSame() 方法判断其内容是否一致,如果一致,则表示是同一条数据,不需要做额外的操作。但是一旦不一致,则还会调用 getChangePayload() 来标记到底是哪个地方的不一样,最终标记需要更新的地方,最终返回给 DiffResult 。

    当然,对性能要是要求没那么高的情况下,是可以不使用 getChangedPayload() 方法的。

    2、DiffUtil.DiffResult

    DiffUtil.DiffResult 其实就是 DiffUtil 通过 DiffUtil.Callback 计算出来,两个数据集的差异。它是可以直接使用在 RecyclerView 上的。

    3、使用DiffUtil

    介绍了 Callback 和 DiffResult 之后,其实就可以正常使用 DiffUtil 来进行数据集的比对了。

    在这个过程中,其实其实很简单,只需要调用两个方法:

    DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(mOldList, mList), true);
    diffResult.dispatchUpdatesTo(myAdapter);
    

    calculateDiff 方法主要是用于通过一个具体的 DiffUtils.Callback 实现对象,来计算出两个数据集差异的结果,得到 DiffUtil.DiffResult 。而 calculateDiff 的另外一个参数,用于标记是否需要检测 Item 的移动,

    而 dispatchUpdatesTo() 就是将这个数据集差异的结果,通过 adapter 更新到 RecyclerView 上面,自动调用以下四个方法:

    public void dispatchUpdatesTo(final RecyclerView.Adapter adapter) {
        dispatchUpdatesTo(new ListUpdateCallback() {
            @Override
            public void onInserted(int position, int count) {
                adapter.notifyItemRangeInserted(position, count);
            }
    
            @Override
            public void onRemoved(int position, int count) {
                adapter.notifyItemRangeRemoved(position, count);
            }
    
            @Override
            public void onMoved(int fromPosition, int toPosition) {
                adapter.notifyItemMoved(fromPosition, toPosition);
            }
    
            @Override
            public void onChanged(int position, int count, Object payload) {
                adapter.notifyItemRangeChanged(position, count, payload);
            }
        });
    }
    

    DiffUtil 使用的是 Eugene Myers 的Difference 差别算法,这个算法本身是不检查元素的移动的。也就是说,有元素的移动它也只是会先标记为删除,然后再标记插入(即 calculateDiff 的第三个参数为 false 时)。而如果需要计算元素的移动,它实际上也是在通过 Eugene Myers 算法比对之后,再进行一次移动检查。所以,如果集合本身已经排序过了,可以不进行移动的检查。

    而如果添加了对数据条目移动的识别,复杂度就会提高到O(N^2)。所以如果数据集中数据不存在移位情况,你可以关闭移动识别功能来提高性能。

    四、使用

    1、自定义继承自 DiffUtil.Callback 的类

    RecyclerView 中使用单一 ViewType ,并且使用一个 TextView 承载一个 字符串来显示。

    package com.example.zhangruirui.coordinatorlayoutdemo;
    
    import android.support.v7.util.DiffUtil;
    
    import java.util.List;
    
    public class DiffCallBack extends DiffUtil.Callback {
    
      private List<String> mOldDatas, mNewDatas;
    
      public DiffCallBack(List<String> oldDatas, List<String> newDatas) {
        this.mOldDatas = oldDatas;
        this.mNewDatas = newDatas;
      }
    
      // 老数据集 size
      @Override
      public int getOldListSize() {
        return mOldDatas != null ? mOldDatas.size() : 0;
      }
    
      // 新数据集 size
      @Override
      public int getNewListSize() {
        return mNewDatas != null ? mNewDatas.size() : 0;
      }
    
      /**
       * Called by the DiffUtil to decide whether two object represent the same Item.
       * 被 DiffUtil 调用,用来判断两个对象是否是相同的 Item。
       * For example, if your items have unique ids, this method should check their id equality.
       * 例如,如果你的Item有唯一的id字段,这个方法就判断id是否相等。
       *
       * @param oldItemPosition The position of the item in the old list
       * @param newItemPosition The position of the item in the new list
       * @return True if the two items represent the same object or false if they are different.
       */
    
      @Override
      public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
    //    Log.e("zhangrr", "areItemsTheSame: " + (oldItemPosition == newItemPosition)
    //    + " oldItemPosition = " + oldItemPosition + " newItemPosition = " + newItemPosition);
    //    return oldItemPosition == newItemPosition;
        return mOldDatas.get(oldItemPosition).equals(mNewDatas.get(newItemPosition));
    //    return mOldDatas.get(oldItemPosition).getClass().equals(mNewDatas.get(newItemPosition).getClass());
      }
    
      /**
       * Called by the DiffUtil when it wants to check whether two items have the same data.
       * 被 DiffUtil 调用,用来检查两个 item 是否含有相同的数据
       * DiffUtil uses this information to detect if the contents of an item has changed.
       * DiffUtil 用返回的信息(true false)来检测当前 item 的内容是否发生了变化
       * DiffUtil uses this method to check equality instead of {@link Object#equals(Object)}
       * DiffUtil 用这个方法替代 equals 方法去检查是否相等。
       * so that you can change its behavior depending on your UI.
       * 所以你可以根据你的 UI 去改变它的返回值
       * For example, if you are using DiffUtil with a
       * {@link android.support.v7.widget.RecyclerView.Adapter RecyclerView.Adapter}, you should
       * return whether the items' visual representations are the same.
       * 例如,如果你用 RecyclerView.Adapter 配合 DiffUtil 使用,你需要返回 Item 的视觉表现是否相同。
       * This method is called only if {@link #areItemsTheSame(int, int)} returns
       * {@code true} for these items.
       * 这个方法仅仅在 areItemsTheSame() 返回 true 时,才会被调用。
       *
       * @param oldItemPosition The position of the item in the old list
       * @param newItemPosition The position of the item in the new list which replaces the
       *                        oldItem
       * @return True if the contents of the items are the same or false if they are different.
       */
      @Override
      public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
        String oldData = mOldDatas.get(oldItemPosition);
        String newData = mNewDatas.get(newItemPosition);
    //    Log.e("zhangrr", "areContentsTheSame: " + oldData.equals(newData)
    //        + " oldItemPosition = " + oldItemPosition + " newItemPosition = " + newItemPosition);
        return oldData.equals(newData);
      }
    
      /**
       * When {@link #areItemsTheSame(int, int)} returns {@code true} for two items and
       * {@link #areContentsTheSame(int, int)} returns false for them, DiffUtil
       * calls this method to get a payload about the change.
       * 定向刷新中的局部更新
       * @param oldItemPosition The position of the item in the old list
       * @param newItemPosition The position of the item in the new list which replaces the
       *                        oldItem
       * @return A payload object that represents the change between the two items.
       */
    //  @Nullable
    //  @Override
    //  public Object getChangePayload(int oldItemPosition, int newItemPosition) {
    //    String oldData = mOldDatas.get(oldItemPosition);
    //    String newData = mNewDatas.get(newItemPosition);
    //
    //    Bundle payload = new Bundle();
    //    if (oldData != newData){
    //      payload.putString("NEW_DATA", newData);
    //    }
    //    Log.e("zhangrr", "getChangePayload() called with: oldItemPosition = [" + oldItemPosition + "], newItemPosition = "
    //        + newItemPosition + " oldData = [" + oldData + "], newData = [" + newData + " payload = " + payload.size());
    //    return payload.size() == 0 ? null : payload;
    //  }
    }
    

    2、更新数据集

    此处通过单击item时模拟数据更新操作

    myAdapter.setOnItemClickListener(new MyAdapter.OnItemClickListener() {
          @Override
          public void onClick(int position) {
            Toast.makeText(getActivity(), "您选择了 " + mList.get(position),
                Toast.LENGTH_SHORT).show();
    
            mList.set(position, "new " + " item");
            final long startTime = SystemClock.uptimeMillis();
            DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(mOldList, mList), true);
            Log.e("zhangrr", "onLongClick() called with: dialog = [" + mOldList.size() + "], i = [" + mList.size() + "]"
                + " 计算时延 = " + (SystemClock.uptimeMillis() - startTime));
            diffResult.dispatchUpdatesTo(myAdapter);
            mOldList = new ArrayList<>(mList);
            myAdapter.setDatas(mList);
          }
    

    3、DiffUtil 的效率问题

    通过测试不同量级的数据集,可发现

    private void initData(String titleText) {
      mList = new ArrayList<>(100000);
      // 不新开线程,数据量 1000 的时候,耗时 7ms
      // 不新开线程,数据量 10000 的时候,耗时 29ms
      // 不新开线程,数据量 100000 的时候,耗时 105ms
      // 所以我们应该将获取 DiffResult 的过程放到子线程中,并在主线程中更新 RecyclerView
      // 此处使用 RxJava,当数据量为 100000 的时候,耗时 13ms
    
      for (int i = 0; i < 100000; i++) {
        mList.add(titleText + " 第 " + i + " 个item");
      }
      mOldList = new ArrayList<>(mList);
    }
    

    所以当数据集较大时,你应该在后台线程计算数据集的更新。官网也考虑到这点,于是发布了 AsyncListDiffer 用于在后台执行计算差异的逻辑。

    虽然后面 Google 官方提供了 ListAdapter 和 AsyncListDiffer这连个类,不过其在 version27 之后才引入了,所以在老项目中使用是不显示的,但是 DiffUtil 是在v7包中的。

    此处使用 RxJava 对前面的逻辑进行修改

    private void doCalculate() {
      Observable.create(new ObservableOnSubscribe<DiffUtil.DiffResult>() {
        @Override
        public void subscribe(ObservableEmitter<DiffUtil.DiffResult> e) {
          DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(mOldList, mList), false);
          e.onNext(diffResult);
        }
      }).subscribeOn(Schedulers.computation())
          .observeOn(AndroidSchedulers.mainThread())
          .subscribe(new Consumer<DiffUtil.DiffResult>() {
            @Override
            public void accept(DiffUtil.DiffResult diffResult) {
              diffResult.dispatchUpdatesTo(myAdapter);
              mOldList = new ArrayList<>(mList);
              myAdapter.setDatas(mList);
            }
          });
    }
    

    在监听事件中进行方法调用

    myAdapter.setOnItemClickListener(new MyAdapter.OnItemClickListener() {
          @Override
          public void onClick(int position) {
            Toast.makeText(getActivity(), "您选择了 " + mList.get(position),
                Toast.LENGTH_SHORT).show();
    
            mList.set(position, "new " + " item");
            final long startTime = SystemClock.uptimeMillis();
            doCalculate();
            Log.e("zhangrr", "onLongClick() called with: dialog = [" + mOldList.size() + "], i = [" + mList.size() + "]"
                + " 计算时延 = " + (SystemClock.uptimeMillis() - startTime));
          }
    

    五、备注(待商榷)

    发现之前一个错误的写法,在 dispatchUpdatesTo(adapter) 之后才应该使用 adapter.setDatas 更新 adapter 里面的数据集,因为 Callback 的 getChangePayload 方法是在 dispatchUpdatesTo 之后执行,如果先 adapter.setDatas 更新了数据,那么 adapter 内的数据集和新的数据集内容就是一样了。这样 getChangePayload 就返回 null 了。


    image.png

    相关文章

      网友评论

        本文标题:RecyclerView 之 DiffUtil

        本文链接:https://www.haomeiwen.com/subject/iowmcqtx.html