17、DiffUtil 高效更新:用 DiffUtil 计算新旧数据差异,局部刷新

说实话,做 Android 列表开发这么多年,我最怕听到的一句话就是:「这个列表数据变了,你刷新一下。」

早期我确实这么干——数据一变,直接 notifyDataSetChanged()。简单粗暴,但后果也很明显:整个列表闪一下,用户正在看的条目被顶到不知道哪里去了,性能差的机器上甚至卡顿半秒。我自己用过一些体验很差的 App,列表动不动就整个刷新,那种感觉真的很糟糕。

后来我接触到 DiffUtil,才意识到:原来列表更新可以这么优雅。它就像一个「差异计算器」,帮你精确找出新旧数据之间哪些条目变了、哪些新增了、哪些删除了,然后只刷新那些真正变化的部分。

为什么需要 DiffUtil?

你想想看,一个列表可能有几百条数据。用户只是给其中一条点了个赞,后台返回了全新的数据列表。如果你直接全量刷新,那所有条目都得重新绑定——即使它们的内容根本没变。

这不仅仅是性能问题。全量刷新会导致:

  • 列表滚动位置丢失
  • 条目闪烁(特别是带图片的)
  • 动画效果生硬
  • 不必要的视图重建

DiffUtil 就是来解决这个问题的。它内部使用 Eugene W. Myers 的差异算法,能高效计算出两个列表的最小更新操作集。

核心思想:DiffUtil 会生成一个 DiffResult 对象,里面包含了从旧列表到新列表所需的全部变更操作——哪些条目需要移动、哪些需要更新、哪些需要删除或插入。

DiffUtil 的基本用法

使用 DiffUtil 其实不复杂,核心就是实现一个 DiffUtil.Callback。我直接上代码,这样更直观:

class MyDiffCallback(
    private val oldList: List<ItemData>,
    private val newList: List<ItemData>
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int = oldList.size

    override fun getNewListSize(): Int = newList.size

    // 判断两个对象是否是同一个 item(通常用 id 判断)
    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldList[oldItemPosition].id == newList[newItemPosition].id
    }

    // 判断两个 item 的内容是否相同(用于决定是否需要刷新)
    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldList[oldItemPosition] == newList[newItemPosition]
    }
}

然后在使用的时候,这样调用:

val diffResult = DiffUtil.calculateDiff(
    MyDiffCallback(oldList, newList)
)
adapter.submitList(newList)  // 或者用 adapter.setList(newList)
diffResult.dispatchUpdatesTo(adapter)

嗯,这里要注意:dispatchUpdatesTo 必须在 submitList 之后调用,否则适配器里的数据还没更新,DiffUtil 的索引会错乱。我早期踩过这个坑,排查了半天才发现是调用顺序的问题。

深入理解两个核心方法

DiffUtil.Callback 里有几个方法,但最核心的就是 areItemsTheSameareContentsTheSame。这两个方法决定了 DiffUtil 的行为:

方法 作用 返回 true 表示 返回 false 表示
areItemsTheSame 判断两个位置的 item 是否是同一个对象 同一个 item(比如 id 相同) 不同的 item,需要执行移除或插入
areContentsTheSame 判断同一个 item 的内容是否变化 内容没变,不需要刷新 内容变了,需要调用 onBindViewHolder 更新

说白了,areItemsTheSame 决定了「是不是同一个东西」,areContentsTheSame 决定了「东西变了没有」。这两个方法配合使用,DiffUtil 就能精确知道哪些条目需要刷新。

我的习惯areItemsTheSame 里我通常用唯一 ID 做比较,比如数据库的主键。而 areContentsTheSame 里我直接用 data class 的 equals 方法,因为 Kotlin 的 data class 默认实现了结构相等比较,非常方便。

DiffUtil 的性能考量

DiffUtil 的算法复杂度是 O(N + D²),其中 N 是列表总长度,D 是差异数量。对于大多数场景,这个性能完全够用。但如果你列表特别大(比如上千条),建议在后台线程执行计算:

// 在协程或 AsyncTask 中执行
val diffResult = withContext(Dispatchers.Default) {
    DiffUtil.calculateDiff(MyDiffCallback(oldList, newList))
}
// 回到主线程更新 UI
withContext(Dispatchers.Main) {
    adapter.submitList(newList)
    diffResult.dispatchUpdatesTo(adapter)
}

我曾经在一个聊天记录列表里用过 DiffUtil,数据量大概 500 条左右,计算时间基本在 10 毫秒以内,完全不需要切线程。但如果你遇到列表超过 2000 条的情况,还是建议异步处理,避免卡主线程。

注意:DiffUtil 的计算结果依赖于 areItemsTheSameareContentsTheSame 的正确实现。如果这两个方法写错了,DiffUtil 会给出错误的差异结果,导致列表显示异常。我曾经见过一个同事把 areItemsTheSame 写成了比较所有字段,结果每个 item 都被判定为「不同」,DiffUtil 相当于白算了。

ListAdapter:更优雅的封装

其实 Google 官方也意识到 DiffUtil 的调用有点繁琐,所以在 RecyclerView 的扩展库中提供了 ListAdapter。它内部封装了 DiffUtil 的计算和分发逻辑,你只需要实现 AsyncDifferConfig 或者直接重写 areItemsTheSameareContentsTheSame 方法:

class MyListAdapter : ListAdapter<ItemData, MyViewHolder>(object : DiffUtil.ItemCallback<ItemData>() {
    override fun areItemsTheSame(oldItem: ItemData, newItem: ItemData): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: ItemData, newItem: ItemData): Boolean {
        return oldItem == newItem
    }
}) {
    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
}

使用 ListAdapter 后,你只需要调用 adapter.submitList(newList),它就会自动在后台线程计算差异,然后局部刷新。我个人非常推荐这种方式,代码简洁很多,也不容易出错。

DiffUtil 的局限性

任何技术都有它的适用场景,DiffUtil 也不例外:

  • 不适合频繁变动的列表:如果列表每秒钟都在变,DiffUtil 的计算开销反而可能比全量刷新还大。
  • 不适合超大列表:超过 5000 条时,建议考虑分页加载,而不是一次性全量 Diff。
  • 不适合复杂对象比较:如果 areContentsTheSame 里做了大量计算(比如比较嵌套对象),反而会拖慢性能。

避坑指南:我曾经在一个实时股票列表里用 DiffUtil,数据每秒刷新一次。结果发现 DiffUtil 的计算时间比全量刷新还长,因为每次都要对比几百只股票的所有字段。后来我改成只对比价格字段,并且用 ListAdaptersubmitList 做节流,才解决了问题。

知识体系图

下面这张图总结了 DiffUtil 的核心逻辑和它在 RecyclerView 更新流程中的位置:

DiffUtil 高效更新核心逻辑 旧数据列表 新数据列表 DiffUtil calculateDiff() DiffResult 移除 / 插入 内容更新 移动 局部刷新 dispatchUpdatesTo() areItemsTheSame → 判断是否同一个 item | areContentsTheSame → 判断内容是否变化

总结

DiffUtil 是 RecyclerView 高效更新的基石。它通过精确计算新旧数据的差异,实现了局部刷新,避免了全量刷新带来的性能问题和视觉抖动。

在实际项目中,我强烈建议使用 ListAdapter 配合 DiffUtil,这样既能享受自动差异计算的便利,又能保持代码的简洁性。记住:不是所有刷新都需要 notifyDataSetChanged,用 DiffUtil 让你的列表更新更智能、更流畅。

一句话记住:DiffUtil 帮你找到「变」与「不变」,只刷新该刷新的部分。

公众号:蓝海资料掘金营,微信deep3321