先把结论放在前面:多类型的核心不是"写 getItemViewType",而是让"type 的划分标准"与"布局结构的差异"严格对齐;DiffUtil 的核心不是"会调 DiffUtil.calculateDiff",而是懂得用 areItemsTheSame / areContentsTheSame / getChangePayload 三层把刷新粒度降下来。 这两件事合起来决定的是用户体验:做对了是"列表几乎不闪",做错了是"每次更新都整屏跳动"。
这题真正的采分点在于工程判断——同一份数据,为什么有的团队用 notifyDataSetChanged 也没人投诉,有的团队一改就掉帧?差别在于列表的数据规模、变化频率、item 复杂度、以及有没有稳定唯一键这四个条件。能把"什么情况下用什么方案"讲成一张决策表,而不是背 API,说明真的做过。
type 划分:判断标准是结构,不是业务分类
最常见的错误是按业务含义分 type。比如"标题、图文、视频、话题"这种分类看起来合理,但真正决定能否复用同一 ViewHolder 的,是布局结构与 View 类型是否完全一致。反过来,两个业务上完全不同的类型,只要布局结构与 View 组合一致(比如都是"单行文字 + 右侧小图"),用同一个 type 反而是正确且高效的。
规范给出三条判断标准:
- type 相同的必要条件:布局资源一致、ViewHolder 内部持有的 View 集合与类型一致、绑定逻辑不需要区分处理。
- type 必须不同的信号:布局结构不同(如一列 vs 三列)、View 类型不同(如 ImageView vs 播放器)、绑定逻辑出现
when分支且互斥。 - type 的粒度代价:type 拆得越细,池的桶越多,每桶都要独立占内存(每桶默认 5 个 View);拆得太粗,绑定期要做多余的分支判断。经验值是 3~6 个 type 最好,超过 8 个就该考虑拆布局或拆列表。
这里有一个必须讲清的机制点:getItemViewType 在 onCreateViewHolder 之前被调用,且它的返回值是 Pool 的分桶依据。这意味着调用位置的时机很敏感——如果在 getItemViewType 里做了耗时操作(比如读数据库、解析 JSON),每帧都会触发,直接掉帧。规范是type 判定只读内存中已解析好的数据字段,不做任何 IO 与解析。
DiffUtil 三层:把"刷新"降级成"改一个字段"
DiffUtil 的核心接口只有四个方法,每个方法的语义边界是这题的必答项:
areItemsTheSame(oldItem, newItem):判断"是不是同一个东西"。规范是只比稳定唯一标识(消息 id、订单号),绝不能比 position 或 index,也不要比整个对象(那会把所有变更都判成不同,退化成全量刷新)。areContentsTheSame(oldItem, newItem):在areItemsTheSame为 true 的前提下,判断"显示内容是否变化"。返回 true 表示不需要重绑,RecyclerView 会跳过onBindViewHolder。getChangePayload(oldItem, newItem):在内容变化时,返回一个精简的变更描述,只列出真正变化的字段(payload)。RecyclerView 收到notifyChanged(pos, payload)后调用的是三参数的onBindViewHolder(holder, position, payloads)。规范是 payload 用 data class 或轻量结构,不要直接把整个实体塞进去——那样等于放弃了粒度优势。onChildViewMoved(oldChild, oldPosition, newPosition):同一 type 的 item 之间移动(排序变化)时回调,返回 true 会跳过移动动画。这里有个重要细节:如果移动前后areContentsTheSame为 true,RecyclerView 仍会执行移动动画;而areItemsTheSame为 false 时,DiffUtil 会先 remove 再 insert,不产生移动动画——这正是聊天列表"新消息插到中间"没有滑动动画的原因。
三层递进的效果是:改一条消息的点赞数 → areContentsTheSame 为 false + getChangePayload 返回 payload(likes=123) → 走三参数 onBindViewHolder 只改点赞 TextView → 其余 View 完全不碰。这就是"聊天列表几乎无感更新"的技术底座。
最常见的坑是
第一层坑:areItemsTheSame 里用 position 或 index 做判定。
这是最严重也最常见的一个。position 在插入/删除后会整体偏移,用它做判定会导致:原本在第 5 位的消息在第 3 条插入后变成第 6 位 → areItemsTheSame 返回 false → Diff 把它识别为"删一条 + 插一条" → 已展示的 item 视觉上跳位,且丢失 item 的位置动画。更糟的是它会让整个 Diff 退化为全量刷新,性能比 notifyDataSetChanged 还差。规范是在数据模型里显式放一个 id 字段,从服务端拿或用本地生成的稳定唯一值。
第二层坑:忘了三参数 onBindViewHolder,payload 形同虚设。
很多人写了 getChangePayload,但 onBindViewHolder 只有两参数版本。两参数版本收到 payload 时,Adapter 仍会走"全量绑定"路径——因为 RecyclerView 是通过 notifyItemChanged(pos, payload) 携带 payload 的,Adapter 若不覆写三参数方法,payload 会被忽略。规范是三参数方法里判断 payloads.isNotEmpty() 时只更新差异字段,为空时走全量绑定:
override fun onBindViewHolder(holder: VH, position: Int) {
onBindViewHolder(holder, position, emptyList()) // 统一收口,避免逻辑两份
}
override fun onBindViewHolder(holder: VH, position: Int, payloads: MutableList<Any>) {
val item = data[position]
if (payloads.isEmpty()) {
holder.bindFull(item) // 全量:结构与文案
} else {
val p = payloads[0] as? ItemPayload // 增量:只改变化字段
when (p) {
is ItemPayload.Likes -> holder.bindLikes(p.likes)
is ItemPayload.Status -> holder.bindStatus(p.status)
else -> holder.bindFull(item)
}
}
}
第三层坑:在主线程做 Diff,数据量一大必掉帧。
DiffUtil.calculateDiff 的复杂度是 O(N + D + M²)(M 为变更数),大列表 + 频繁更新时在主线程执行会造成明显卡顿。规范是用 AsyncListDiffer 或 ListAdapter,把 Diff 放到后台线程,主线程只收 dispatchUpdatesTo 的结果;快照(List 的副本)要在提交时一并生成,不要在后台线程里读还在被业务修改的可变集合。
还有一个更隐蔽的坑:getItemViewType 里做重活或依赖可变状态。 常见写法是在 getItemViewType 里根据"当前某个开关状态"决定返回 banner type 还是普通 type,而这个开关在刷新过程中被另一个线程改了,导致同一位置的 type 在一次布局中前后不一致,RecyclerView 找不到对应池桶、直接 inflate 新 View,缓存全废。规范是type 判定只依赖该 item 自身的数据字段,不依赖全局可变状态。
代码里见真章
先看一个基于 AsyncListDiffer + payload 的完整实现,把多层分类型与增量刷新一次打通:
// payload:只描述"变了的字段",不含整个实体
sealed class MsgPayload {
data class Likes(val count: Int, val voted: Boolean) : MsgPayload()
data class Status(val state: Int) : MsgPayload()
data class Content(val text: String) : MsgPayload()
}
data class Msg(
val id: Long, // 稳定唯一键:Diff 的生命线
val type: Int, // 结构分类:0 文本 / 1 图片 / 2 系统提示
val userName: String,
val text: String,
val imageUrl: String?,
val likeCount: Int,
val voted: Boolean,
val state: Int
)
class MsgAdapter : RecyclerView.Adapter<MsgAdapter.VH>() {
// 异步 Diff:计算在后台线程,结果分批回主线程
private val differ = AsyncListDiffer<Msg>(DiffCallback(), DiffItemCallback())
private val data: List<Msg> get() = differ.currentList
init {
// 稳定 id 是 Diff 精确匹配的前提
setHasStableIds(true)
}
fun submit(newList: List<Msg>) {
// 提交副本,避免后台 diff 期间原集合被业务修改
differ.submitList(newList.toList())
}
override fun getItemId(position: Int): Long = data[position].id // 用 id,不用 position
override fun getItemCount(): Int = data.size
// 关键:type 只读 item 自身字段,不依赖任何全局可变状态
override fun getItemViewType(position: Int): Int = data[position].type
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val inf = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_TEXT -> TextVH(inf.inflate(R.layout.item_msg_text, parent, false))
TYPE_IMAGE -> ImageVH(inf.inflate(R.layout.item_msg_image, parent, false))
else -> SystemVH(inf.inflate(R.layout.item_msg_system, parent, false))
}
}
override fun onBindViewHolder(holder: VH, position: Int, payloads: MutableList<Any>) {
val msg = data[position]
if (payloads.isEmpty()) {
holder.bind(msg) // 全量绑定
} else {
when (val p = payloads.first()) {
// 增量绑定:只碰变化字段
is MsgPayload.Likes -> holder.bindLikes(p.count, p.voted)
is MsgPayload.Status -> holder.bindStatus(p.state)
is MsgPayload.Content -> holder.bindText(p.text)
}
}
}
override fun onViewRecycled(holder: VH) {
super.onViewRecycled(holder)
holder.reset() // 状态复位,防复用串数据
}
// 三层判定:同一性 → 内容是否变 → 变更内容
object DiffCallback : DiffUtil.Callback() {
override fun getOldListSize() = 0 // 由 AsyncListDiffer 接管,示例留空
override fun getNewListSize() = 0
override fun areItemsTheSame(old: Msg, new: Msg) = old.id == new.id
override fun areContentsTheSame(old: Msg, new: Msg) =
old.text == new.text && old.likeCount == new.likeCount &&
old.voted == new.voted && old.state == new.state && old.imageUrl == new.imageUrl
override fun getChangePayload(old: Msg, new: Msg): Any? {
// 只把变化的字段装进 payload,粒度决定收益
val p = mutableListOf<MsgPayload>()
if (old.likeCount != new.likeCount || old.voted != new.voted) {
p += MsgPayload.Likes(new.likeCount, new.voted)
}
if (old.state != new.state) p += MsgPayload.Status(new.state)
if (old.text != new.text) p += MsgPayload.Content(new.text)
return p.takeIf {
it.isNotEmpty() }?.first()
}
}
// 简化的 DiffUtil.ItemCallback(AsyncListDiffer 的构造参数)
object DiffItemCallback : DiffUtil.ItemCallback<Msg>() {
override fun areItemsTheSame(old: Msg, new: Msg) = old.id == new.id
override fun areContentsTheSame(old: Msg, new: Msg) =
old.text == new.text && old.likeCount == new.likeCount
}
}
这段代码值得盯三处:第一处,getItemId 返回 msg.id 而不是 position,这是稳定 id 的正确用法;第二处,三参数 onBindViewHolder 里对 payload 分派,让增量更新真正生效;第三处,areContentsTheSame 覆盖了所有显示字段,少比一个字段就会导致该字段不刷新。
再看 type 划分的判据表落地:
// type 划分:以"布局结构 + View 组合"为准,而非业务分类
enum class CellShape {
SINGLE_TEXT, TEXT_WITH_THUMB, IMAGE_GRID, SYSTEM_TIP, PRODUCT_CARD, AD_BANNER }
fun shapeOf(item: Feed): CellShape = when {
item.isSystemTip -> CellShape.SYSTEM_TIP // 单行居中灰字
item.adSlot != null -> CellShape.AD_BANNER // 固定高度图 + 角标
item.products.isNotEmpty() -> CellShape.PRODUCT_CARD // 横向嵌套列表
item.images.size >= 3 -> CellShape.IMAGE_GRID // 三列宫格
item.imageUrl != null -> CellShape.TEXT_WITH_THUMB
else -> CellShape.SINGLE_TEXT
}
// 6 个 type → 6 个独立池桶,每桶 5 个 View,内存预算可控
override fun getItemViewType(position: Int): Int = shapeOf(data[position]).ordinal
这段代码的价值在于把"type 怎么定"从口头约定变成可 review 的函数——新增一种 item 时只改 shapeOf 一处,评审时能直接看出结构是否真的不同。
关于 DiffUtil 在主线程的规避,异步派发的写法是标准答案:
// 方式一:ListAdapter(内部已用 AsyncListDiffer,最省事)
class MsgListAdapter : ListAdapter<Msg, MsgAdapter.VH>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
/* 同上 */ }
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(getItem(position)) }
override fun onBindViewHolder(holder: VH, position: Int, payloads: MutableList<Any>) {
if (payloads.isEmpty()) onBindViewHolder(holder, position)
else holder.bindPartial(getItem(position), payloads)
}
companion object {
val DIFF = object : DiffUtil.ItemCallback<Msg>() {
override fun areItemsTheSame(old: Msg, new: Msg) = old.id == new.id
override fun areContentsTheSame(old: Msg, new: Msg) = old.version == new.version
}
}
}
// 方式二:自己控制线程
val bg = Handler(Executors.newSingleThreadExecutor().asLooper()) // 后台 Looper
fun refresh(newList: List<Msg>) {
val snapshot = newList.toList()
bg.post {
val diff = DiffUtil.calculateDiff(CallbackImpl(snapshot))
main.post {
diff.dispatchUpdatesTo(this) } // 结果必须回主线程
}
}
面试中的经典考点
问:areItemsTheSame 和 areContentsTheSame 有什么区别?写错会怎样?
答:前者判"是不是同一个对象",只看稳定唯一键;后者判"显示内容有没有变",在同一个对象的前提下逐字段比。写错的后果分两种:areItemsTheSame 写得太宽松(比如直接 return true)会让 Diff 认为什么都没变,UI 完全不更新;写得太严格(比如比整个对象的 equals)会让任何字段变化都被判为"不是同一个",退化为 remove + insert,失去移动动画且闪烁。areContentsTheSame 写得太宽松则该刷新的字段不刷新,表现为"点赞数变了但界面没变"。
问:为什么新消息插到列表中间,位置动画没了?
答:因为 areItemsTheSame 对被插入位置之后的所有 item 返回 false(它们被判为"旧 item 消失 + 新 item 出现"),DiffUtil 生成的是 remove + insert 指令而非 move。位置动画的前提是"同一 ViewHolder 位置发生偏移"。要保留位置动画,前提是插入/删除用 notifyItemInserted 精确通知(不用 Diff),Diff 在结构变化时天然牺牲移动动画。工程上的折中是:结构变化用精确 notify,内容变化用 Diff。
问:payload 传什么结构最合适?
答:只装变化字段的轻量对象。规范是用 data class 或 sealed class 声明字段级 payload,而不是把整个实体传进去。原因有三:payload 会在 notifyChanged 时跨对象传递,大对象会造成内存与 GC 压力;整个实体传入时三参数 onBindViewHolder 拿不到"到底哪个字段变了"的信息,只能退化成全量绑定,等于白写 payload;payload 应当可序列化(便于跨进程传递与埋点复现)。
问:多类型列表的内存怎么控制?
答:三个杠杆同时用。① type 数量控制——type 决定池桶数,每桶默认 5 个 View,8 个 type 就是 40 个 View 常驻内存,因此按结构而非业务分类、且定期审视是否有可合并的 type。② 第二层容量控制——setItemViewCacheSize 按 item 复杂度设,纯文本可到 8,带大图保持 2~4。③ 静止期回收——滚动停止时 recycledViewPool.clear() + 图片框架 trimMemory。再补一条:含播放器的 type 必须在 onViewRecycled 释放播放器实例,否则池会把播放器实例长期持有。
问:什么时候不该用 DiffUtil?
答:三种情况。① 数据量小(< 50 条)且更新不频繁——Diff 的调度开销可能超过直接 notifyItemChanged;② 结构变化为主(大量插入删除)——此时 Diff 退化为 remove+insert,直接用精确的 notifyItemInserted/Removed 更省;③ 数据无稳定唯一键——没有键就只能用 position,Diff 反而制造错位,这种情况应该先在模型层补 id,而不是硬上 Diff。能把这三种情况列出来,说明不是"Diff 万能论"。
落到项目里怎么做
一条能写进规范的红线:数据模型必须携带稳定唯一键,getItemId 一律返回该键,绝不使用 position。 这是 Diff 正确性的前提,也是动画正确性的前提。
配套实践三条:① 统一用 ListAdapter/AsyncListDiffer 做内容更新,用精确 notifyItemInserted 做结构变化,两套通道职责分明写在代码注释里;② 三参数 onBindViewHolder 必须实现,payload 用 sealed class 声明,新增字段时同步补 payload 分支(可加 lint 或单测兜住漏分支);③ 埋一条 debug 统计——每次更新打印 Diff 生成的指令条数与被跳过的 bind 次数,用它持续验证 Diff 是否真的在生效,而不是"接了 Diff 但每次都全量"。
给正在准备面试的你
这题的答法要落到"type 按结构划分、Diff 按三层判定、payload 降粒度"这条主线。推荐这条线:先讲 type 判据(结构与 View 组合一致才可同 type,3~6 个最合适)→ 讲 Diff 三层(areItemsTheSame 只比 id、areContentsTheSame 逐字段、getChangePayload 只装变化字段)→ 讲三个坑:id 用 position、忘三参数 bind、Diff 在主线程 → 落到"结构变化用精确 notify + 内容变化用异步 Diff"的组合。能被追问到"为什么插消息没位置动画"并答出"Diff 判为 remove+insert",说明真做过聊天列表;能讲清"什么时候不该用 Diff"的三种情况,说明不是无脑套模板。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:RecyclerView-缓存机制:四层缓存各自干什么
下一篇预告:Handler-消息机制:Looper、MessageQueue-与-ThreadLocal
有任何问题欢迎在评论区留言交流。