我们对 DiffUtil 的使用可能被带偏了

我们对 DiffUtil 的使用可能被带偏了

前面都是我的流水账, 觉得看起来很没劲的, 可以直接跳转到本质小节,.

DiffUtil 的优势

我在最初接触 DiffUtil 时, 心中便对它有颇多的好感, 包括:

  1. 算法听提来就很nb, 一定是个好东西;
  2. 简化了 RecyclerView 的刷新逻辑, 无须关心该调用 notifyItemInserted 还是 notifyItemChanged, 一律submitList 就完事了(虽然 notifyDataSetChanged 也能做到, 但是性能拉胯, 而且没有动画);
  3. LiveData 或者 Flow 监听单一 List 数据源时, 往往很难知道, 整个 List 中到底哪些数据项被更新了, 只能调用notifyDataSetChanged 方法, 而 DiffUtil 恰好就能解决这个问题, 无脑 submitList 就完事了.

DiffUtil 代码示例

使用 DiffUtil 时, 代码大致如下:

data class Item (
    var id: Long = 0,
    var data: String = ""
)

class DiffAdapter : RecyclerView.Adapter() {
    // AsyncListDiffer 类位于 androidx.recyclerview.widget 包下
    // 这里以 AsyncListDiffer 的使用来举例, 使用 ListAdapter 或者直接用 DiffUtil, 也存在后面的问题
    private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
            // Kotlin中的 == 运算符, 相当于 Java 中调用 equals 方法
            return oldItem == newItem
        }

        private val payloadResult = Any()
        override fun getChangePayload(oldItem: Item, newItem: Item): Any {
            // payload 用于Item局部字段更新的时候使用,具体用法可以自行搜索了解
            // 当检测到同一个Item有更新时, 会调用此方法
            // 此方法默认返回null, 此时会触发Item的更新动画, 表现为Item会闪一下
            // 当返回值不为null时, 可以关闭Item的更新动画
            return payloadResult
        }
    })

    class MyViewHolder(val view: View):RecyclerView.ViewHolder(view){
        private val dataTv:TextView by lazy{ view.findViewById(R.id.dataTv) }
        fun bind(item: Item){
            dataTv.text = item.data
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder(LayoutInflater.from(parent.context).inflate(
            R.layout.item_xxx,
            parent,
            false
        ))
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(differ.currentList[position])
    }

    override fun getItemCount(): Int {
        return differ.currentList.size
    }
    
    public fun submitList(newList: List) {
        differ.submitList(newList)
    }
}

val dataList = mutableListOf(item1, item2)
differ.submitList(dataList)

以上代码的关键在于以下两个方法的实现, 作用分别是:

  • areItemsTheSame(oldItem: Item, newItem: Item) :比较两个 Item 是否表示同一项数据;
  • areContentsTheSame(oldItem: Item, newItem: Item) :比较两个 Item 的数据是否相同.

DiffUtil 的踩坑过程

上述示例代码很看起来简单, 也比较好理解, 当我们尝试添加一条数据时:

val dataList = mutableListOf(item1, item2)
differ.submitList(dataList)

// 增加数据
dataList.add(item3)
differ.submitList(dataList)

发现 item3 并未在界面上显示出来, 怎么回事呢? 我们来看 AsyncListDiffer 关键代码的实现:

public void submitList(@Nullable final List newList) {
    submitList(newList, null);
}

public void submitList(@Nullable final List newList, @Nullable final Runnable commitCallback) {
    // ...省略无关代码
    // 注意这里是 Java 代码, 正在比较 newList 与 mList 是否为同一个引用
    if (newList == mList) {
        // nothing to do (Note - still had to inc generation, since may have ongoing work)
        if (commitCallback != null) {
            commitCallback.run();
        }
        return;
    }
    // ...省略无关代码

    mConfig.getBackgroundThreadExecutor().execute(new Runnable() {
        @Override
        public void run() {
            final DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() {
                // ...省略 newList 和 mList 的 Diff 算法比较代码
            });

            // ...省略无关代码
            mMainThreadExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    if (mMaxScheduledGeneration == runGeneration) {
                        latchList(newList, result, commitCallback);
                    }
                }
            });
        }
    });
}

void latchList(
        @NonNull List newList,
        @NonNull DiffUtil.DiffResult diffResult,
        @Nullable Runnable commitCallback) {
    // ...省略无关代码
    mList = newList;
    // 将结果更新到具体的
    diffResult.dispatchUpdatesTo(mUpdateCallback);
    // ...省略无关代码
}

可以看到, 单参数的 submitList 方法会调用 双参数的 submitList 方法, 重点在于双参数的submitList 方的实现:

  • 首先检查新提交的 newList 与内部持有的 mList 的引用是否相同, 如果相同, 就直接返回;
  • 如果不同的引用, 就对 newListmListDiff 算法比较, 并生成比较结果 DiffUtil.DiffResult;
  • 最后通过 latchList 方法将 newList 赋值给 mList , 并将 Diff 算法的结果 DiffUtil.DiffResult 应用给 mUpdateCallback.

最后的 mUpdateCallback, 其实就是上述示例代码中, 创建 AsyncListDiffer 对象时, 传入的 RecyclerView.Adapter 对象, 这里就不贴代码了.

浅拷贝

分析代码后, 我们可以知道, 每次 submitList 时, 必须传入不同的 List 对象, 否者方法内部不会做 Diff 算法比较, 而是直接返回, 界面也不会刷新. 需要要不同的 List 是吧? 哪还不简单, 我创建一个新的 List 不就行了?

于是我们修改一下 submitList 方法:

class DiffAdapter : RecyclerView.Adapter() {
    private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem == newItem
        }
    })

    // ...省略无关代码

    public fun submitList(newList: List) {
        // 创建一个新的 List, 再调用 submitList
        differ.submitList(newList.toList())
    }
}

相应的测试代码也变成了:

val diffAdapter = ...
val dataList = mutableListOf(item1, item2)
diffAdapter.submitList(dataList)

// 增加数据
dataList.add(item3)
diffAdapter.submitList(dataList)

// 删除数据
dataList.removeAt(0)
diffAdapter.submitList(dataList)

// 更新数据
dataList[1].data = "最新的数据"
diffAdapter.submitList(dataList)

运行代码后发现, 单独运行"增加数据"和"删除数据"的测试代码, 表现都是正常的, 唯独"更新数据"单独运行时, 界面毫无反应.

其实仔细想想也能明白, 虽然我们调用 differ.submitList(newList.toList()) 方法时, 确实对 List 做了一份拷贝, 但却是浅拷贝, 真正在运行 Diff 算法比较时, 其实是同一个 Item 对象在自己和自己比较(areContentsTheSame 方法参数的 oldItemnewItem 为同一个对象引用), 也就判定为没有数据更新.

data class 的 copy

有的同学,可能有话要说: “你应该在更新 data 字段时, 应该调用 copy 方法, 拷贝一个新的对象, 更新新的值后, 再把原始的 Item 替换掉!”.

于是就有了以下代码:

val diffAdapter = ...
val dataList = mutableListOf(item1, item2)
diffAdapter.submitList(dataList)

// 更新数据
dataList[0] = dataList[0].copy(data = "最新的数据")
diffAdapter.submitList(dataList)

运行代码后, "更新数据"也变得正常了, 当业务比较简单时, 也就到此为止了, 没有新的坑来踩了. 但如果业务比较复杂时, 更新数据的代码可能是这样的:

data class InnerItem(
    val innerData: String = ""
)
data class Item(
    val id: Long = 0,
    val data: String = "",
    val innerItem: InnerItem = InnerItem()
)

val diffAdapter = ...
val dataList = mutableListOf(item1, item2)
diffAdapter.submitList(dataList)

// 更新数据
val item = dataList[0]
// 内部的数据也不能直接赋值, 需要拷贝一份, 否者和上面的情况类似了
val innerNewItem = item.innerItem.copy(innerData = "内部最新的数据")
dataList[0] = item.copy(innerItem = innerNewItem)
diffAdapter.submitList(dataList)

好像稍微有些复杂的样子, 那如果我们嵌套再深一些呢? 我里面还嵌套了一个List呢? 就要依次递归copy, 代码好像就比较复杂了.

此时我们再回想起开篇提到的 DiffUtil 第 2 点优势要打个疑问了. 本来以为会简化代码, 反而使代码变得更复杂了, 我还不如手动赋值, 然后自己去调用 notifyItemXxx , 代码怕是要简单一些.

深拷贝

于是乎, 为了避免递归copy, 导致更新数据的代码变得过于复杂, 就有了深拷贝的方案. 我管你套了几层, 我先深拷贝一份, 我直接对深拷贝的数据进行修改, 然后直接设置回去, 代码如下:

data class InnerItem(
    var innerData: String = ""
)
data class Item(
    val id: Long = 0,
    var data: String = "",
    var innerItem: InnerItem = InnerItem()
)


val diffAdapter = ...
val dataList = mutableListOf(item1, item2)
diffAdapter.submitList(dataList)

// 更新数据
dataList[0] = dataList[0].deepCopy().apply { 
    innerItem.innerData = "内部最新的数据"
}
diffAdapter.submitList(dataList)

代码看上去又变得简洁了许多, 我们的关注点又来到了深拷贝的如何实现:

利用 Serializable 或者 Parcelable 即可实现对象深拷贝, 具体可自行搜索.

  • 使用 Serializable, 代码看起来比较简单, 但是性能稍差;
  • 使用 Parcelable, 性能好, 但是需要生成更多额外的代码, 看起来不够简洁;

其实选择 Serializable 或者 Parcelable 都无所谓, 看个人喜好即可. 关键在于实现了 Serializable 或者 Parcelable 接口后, Item 中的数据类型会被限制, 要求 Item 中所有的直接或间接字段也必须实现 Serializable Parcelable 接口, 否者就会序列化失败.

比如说, Item 中就不能声明类型为 android.text.SpannableString 的字段(用于显示富文本), 因为 SpannableString 既没有实现 Serializable 接口, 也没有实现 Parcelable 接口.

本质

回过头去, 我们再来审视一下 DiffUtil 两个核心方法:

  • areItemsTheSame(oldItem: Item, newItem: Item) : 比较两个 Item 是否表示同一项数据;
  • areContentsTheSame(oldItem: Item, newItem: Item) : 比较两个 Item 的数据是否相同.

先问个问题, 这两个方法分别为了实现什么目的呢, 或者说他们在算法中起的作用是什么?

简单, 就算不懂 DiffUtil 算法实现(其实是我不懂 o( ̄▽ ̄)o ), 也能猜到, 仅凭 areItemsTheSame 方法我们就能实现以下三种操作:

  • itemRemove
  • itemInsert
  • itemMove

而最后一种 itemChange 操作, 需要 areItemsTheSame 方法先返回 true, 然后调用 areContentsTheSame 方法返回 false, 才能判定为 itemChange 操作, 这也和此方法的注释说明相对应:

This method is called only if {@link #areItemsTheSame(T, T)} returns {@code true} for these items.

所以, areContentsTheSame 方法的作用, 仅仅是为了判定 Item 用于界面显示的部分是否有更新, 而不一定需要调用 equals 方法来全量比较两个item的所有字段. 其实 areContentsTheSame 方法的代码注释也有说明:

This method to check equality instead of {@link Object#equals(Object)} so that you can change its behavior depending on your UI.
For example, if you are using DiffUtil with a {@link RecyclerView.Adapter RecyclerView.Adapter}, you should return whether the items' visual representations are the same.

你会发现, 网上很多教程的代码示例就是用的 equals 来判定数据是否被修改, 然后基于 equals 的比较前提, 更新数据的时候, 又是递归copy, 又是深拷贝, 其实是被带偏了, 思想被限制住了.

改进办法

既然 areItemsTheSame 方法仅用于判定 Item 用于显示的部分是否有更新, 从而判定 itemChange 操作, 那我们完全可以新起一个 contentId 字段, 用于标识内容的唯一性, areItemsTheSame 方法的实现也仅比较 contentId , 代码看起来像这样:

private val contentIdCreator = AtomicLong()
abstract class BaseItem(
    open val id: Long,
    val contentId: Long = contentIdCreator.incrementAndGet()
)
data class InnerItem(
    var innerData: String = ""
)
data class ItemImpl(
    override val id: Long,
    var data: String = "",
    var innerItem: InnerItem = InnerItem()
) : BaseItem(id)

class DiffAdapter : RecyclerView.Adapter() {
    private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
            // contentId不一致时, 就认为此Item数据有更新
            return oldItem.contentId == newItem.contentId
        }
        
        private val payloadResult = Any()
        override fun getChangePayload(oldItem: Item, newItem: Item): Any {
            // payload 用于Item局部字段更新的时候使用,具体用法可以自行搜索了解
            // 当检测到同一个Item有更新时, 会调用此方法 
            // 此方法默认返回null, 此时会触发Item的更新动画, 表现为Item会闪一下
            // 当返回值不为null时, 可以关闭Item的更新动画
            return payloadResult
        }
    })

    // ... 省略无关代码

    public fun submitList(newList: List) {
        // 创建新的List的浅拷贝, 再调用submitList
        differ.submitList(newList.toList())
    }
}


val diffAdapter: DiffAdapter = ...
val dataList = mutableListOf(item1, item2)
diffAdapter.submitList(dataList)

// 更新数据
// 仅需copy外层的数据,保证contentId不一致即可, 内部嵌套的数据仅需直接赋值即可
// 由于ItemImpl继承自BaseItem, 当执行ItemImpl.copy方法时, 会调用父类BaseItem的构造方法, 生成新的contentId
dataList[0] = dataList[0].copy().apply { 
    data = "最新的数据"
    innerItem.innerData = "内部最新的数据"
}
diffAdapter.submitList(dataList)

因为 areContentsTheSame 方法执行时,需要不同的两个对象比较,所以有字段更新时,还是需要通过 copy 方法生成新的对象.

这种方式存在误判的可能, 因为 ItemImpl 中的一些字段的更新可能不会影响到界面的显示, 此时 areContentsTheSame 方法应该返回 false. 但个人认为这种情况是少数, 误判是可以接受的, 代价仅仅只会额外多更新了一次界面 item 而已.

其实, 了解了本质后, 我们还可以根据自己的业务需求按自己的方式来定制. 比如说用Java该怎么办? 我们也许可以这么做:

class Item{
    int id;
    boolean isUpdate; // 此字段用于标记此Item是否有更新
    String data;
}

class JavaDiffAdapter extends RecyclerView.Adapter{
    public void submitList(List dataList){
        differ.submitList(new ArrayList<>(dataList));
    }
    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return new MyViewHolder(LayoutInflater.from(parent.getContext()).inflate(
                R.layout.item_xxx, 
                parent,
                false
        ));
    }
    @Override
    public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
        // 绑定一次数据后, 将需要更新的标识赋值为false
        differ.getCurrentList().get(position).isUpdate = false;
        holder.bind(differ.getCurrentList().get(position));
    }
    @Override
    public int getItemCount() {
        return differ.getCurrentList().size();
    }
    class MyViewHolder extends RecyclerView.ViewHolder{
        private TextView dataTv;
        public MyViewHolder(View itemView) {
            super(itemView);
            dataTv = itemView.findViewById(R.id.dataTv);
        }
        private void bind(Item item){
            dataTv.setText(item.data);
        }
    }
    private AsyncListDiffer differ = new AsyncListDiffer(this, new DiffUtil.ItemCallback() {
        @Override
        public boolean areItemsTheSame(@NonNull Item oldItem, @NonNull Item newItem) {
            return oldItem.id == newItem.id;
        }
        @Override
        public boolean areContentsTheSame(@NonNull Item oldItem, @NonNull Item newItem) {
            // 通过读取isUpdate来确定数据是否有更新
            return !newItem.isUpdate;
        }
        private final Object payloadResult = new Object();
        @Nullable
        @Override
        public Object getChangePayload(@NonNull Item oldItem, @NonNull Item newItem) {
            // payload 用于Item局部字段更新的时候使用,具体用法可以自行搜索了解
            // 当检测到同一个Item有更新时, 会调用此方法
            // 此方法默认返回null, 此时会触发Item的更新动画, 表现为Item会闪一下
            // 当返回值不为null时, 可以关闭Item的更新动画
            return payloadResult;
        }
    });
}


// 更新数据
List dataList = ...;
Item target = dataList.get(0);
// 标识数据有更新
target.isUpdate = true;
target.data = "新的数据";
adapter.submitList(dataList);

最后

其实, 如果我们的 List 来源于 Room, 其实没有这么多麻烦事, 直接调用 submitList 即可, 不用考虑这里提到的问题, 因为 Room 数据有更新时, 会自动生成新的 List, 里面的每项 Item 也是新的, 具体代码示例可参考 AsyncListDiffer 或者 ListAdapter 类的顶部的注释. 需要注意的是数据更新太频繁时, 会不会生成了太多的临时对象.

你可能感兴趣的:(Android,android,kotlin,DiffUtil,RecyclerView)