RecyclerView 之 DiffUtil

一、前言

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 mOldDatas, mNewDatas;

  public DiffCallBack(List oldDatas, List 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() {
    @Override
    public void subscribe(ObservableEmitter e) {
      DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(mOldList, mList), false);
      e.onNext(diffResult);
    }
  }).subscribeOn(Schedulers.computation())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe(new Consumer() {
        @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

——乐于分享,共同进步,欢迎留言讨论
——Treat Warnings As Errors
——Any comments greatly appreciated
——Talking is cheap, show me the code
——CSDN:https://blog.csdn.net/u011489043
——:https://www.jianshu.com/u/4968682d58d1
——GitHub:https://github.com/selfconzrr

你可能感兴趣的:(RecyclerView 之 DiffUtil)