RecyclerView 性能优化与缓存机制:从卡顿到丝滑的完整指南

> 一句话收益:深入理解 RecyclerView 四级缓存体系与绘制流程,掌握 DiffUtil、预取、局部刷新等核心优化手段,彻底解决列表卡顿问题。
> 适用版本:Android API 21+,RecyclerView 1.3+,Kotlin 1.9+
> 阅读时长:约 18 分钟
1. 从一个真实 Bug 切入
业务场景:电商首页瀑布流,每次刷新 50 条商品数据,用 notifyDataSetChanged() 全量刷新。在低端机(4GB RAM + Snapdragon 680)上,每次下拉刷新都会出现明显白屏闪烁,Profiler 显示主线程在刷新瞬间有 120ms+ 的 Choreographer 帧超时。
表象是 notifyDataSetChanged() 导致的全量 rebind,但深挖后发现根因有三:
-
每个 Item 的
ImageView每次都重新加载(Glide 缓存未命中) -
ViewHolder 的
onBindViewHolder里做了同步 IO(读取本地收藏状态) -
RecyclerView 的
RecycledViewPool未跨 Fragment 复用
这三个问题分别对应缓存机制、线程模型、全局 Pool 管理,是 RecyclerView 性能问题的典型组合。
2. RecyclerView 缓存体系全景
2.1 四级缓存结构
RecyclerView 的 Recycler 内部维护四个级别的缓存,命中优先级从高到低:
┌─────────────────────────────────────────────────────────────┐
│ RecyclerView.Recycler │
│ │
│ Level 1: mAttachedScrap / mChangedScrap │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 当前屏幕内、本次 layout pass 被临时 detach 的 VH │ │
│ │ 命中后直接复用,不触发 onBindViewHolder │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ miss │
│ Level 2: mCachedViews (默认容量 2) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 最近滑出屏幕的 VH,按 position 精确匹配 │ │
│ │ 命中后直接复用,不触发 onBindViewHolder │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ miss │
│ Level 3: ViewCacheExtension (自定义扩展) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 开发者自定义缓存逻辑,默认为 null │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ miss │
│ Level 4: RecycledViewPool │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 按 viewType 分桶,默认每桶 5 个 VH │ │
│ │ 命中后触发 onBindViewHolder 重绑数据 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ miss │
│ Create: onCreateViewHolder → inflate + onBindViewHolder │
└─────────────────────────────────────────────────────────────┘
关键源码路径:androidx.recyclerview.widget.RecyclerView.Recycler#tryGetViewHolderForPositionByDeadline
2.2 mCachedViews vs RecycledViewPool 的本质区别
| 维度 | mCachedViews | RecycledViewPool |
|------|-------------|-----------------|
| 匹配方式 | 按 position 精确匹配 | 按 viewType 匹配 |
| 是否重新 bind | 否 | 是 |
| 默认容量 | 2 | 每个 type 5 个 |
| 跨 RecyclerView | 不支持 | 支持共享 |
| 典型场景 | 小幅滑动回退 | 多列表共享 ViewHolder |
理解这张表是做针对性优化的前提:如果你的问题是"来回小幅滑动卡顿",先扩大 mCachedViews;如果是"多个嵌套 RV 首次展开慢",用共享 RecycledViewPool。
3. 核心原理:notifyDataSetChanged 的真实代价
3.1 全量刷新的调用链
notifyDataSetChanged()
└── RecyclerView.RecyclerViewDataObserver#onChanged()
└── RecyclerView#requestLayout()
└── LinearLayoutManager#onLayoutChildren()
├── detachAndScrapAttachedViews() ← 全部移入 mChangedScrap
└── fill()
└── 每个 position 重新 tryGetViewHolderForPositionByDeadline
└── 因为 DataSetChanged,mChangedScrap 中 VH 标记为 invalid
└── 全部落入 RecycledViewPool → onBindViewHolder 全量触发
notifyDataSetChanged() 会让所有缓存的 ViewHolder 标记上 FLAG_INVALID,导致即使命中 Level 1/2 缓存,也必须重新 bind。这就是它"全量刷新"的真正含义------不是重新 inflate(inflate 会被缓存命中避免),而是 所有可见 item 都触发 onBindViewHolder 。
3.2 DiffUtil 的差量计算原理
DiffUtil 内部使用 Eugene W. Myers 的 O(N) 差量算法(DiffUtil#calculateDiff),将两个列表的差异计算为最小编辑距列表,仅生成必要的 notifyItemInserted/Removed/Moved/Changed 序列。
旧列表: [A, B, C, D, E]
新列表: [A, C, D, F, E]
↑ ↑
B 被删 F 被插入
DiffUtil 输出:
notifyItemRemoved(1) // 删除 B
notifyItemInserted(3) // 插入 F
命中 Level 1 缓存(mAttachedScrap)的 item 直接复用,不会触发 onBindViewHolder,这是局部刷新性能优于全量刷新的根本原因。
4. 代码示例
4.1 正确的 DiffUtil 实现(含注释)
// 数据类需实现 equals,或自定义 areContentsTheSame
data class ProductItem(
val id: Long,
val title: String,
val price: Double,
val imageUrl: String
)
class ProductDiffCallback(
private val oldList: List
,
private val newList: List
) : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
// 判断是否是同一个 Item(通常用 id)
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
return oldList[oldPos].id == newList[newPos].id
}
// 判断内容是否相同(决定是否触发 bind)
// data class 的 equals 会比较所有字段
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
return oldList[oldPos] == newList[newPos]
}
// 可选:返回 payload 实现字段级局部刷新
override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
val oldItem = oldList[oldPos]
val newItem = newList[newPos]
val payload = Bundle()
if (oldItem.price != newItem.price) {
payload.putDouble("price", newItem.price)
}
if (oldItem.title != newItem.title) {
payload.putString("title", newItem.title)
}
return if (payload.isEmpty) null else payload
}
}
// ViewModel 中异步计算 diff(必须在 IO 线程)
class ProductViewModel : ViewModel() {
private val _products = MutableStateFlow
>(emptyList())
val products = _products.asStateFlow()
fun refreshProducts(newData: List
) {
viewModelScope.launch(Dispatchers.Default) { // IO 线程计算 diff
val oldData = _products.value
val diffResult = DiffUtil.calculateDiff(ProductDiffCallback(oldData, newData))
withContext(Dispatchers.Main) {
_products.value = newData
// 注意:必须先更新数据源再 dispatch
adapter.submitDiffResult(diffResult)
}
}
}
}
// Adapter 中处理 payload 局部刷新
class ProductAdapter : RecyclerView.Adapter
() {
override fun onBindViewHolder(holder: VH, position: Int, payloads: MutableList
) {
if (payloads.isEmpty()) {
// 全量绑定
super.onBindViewHolder(holder, position, payloads)
return
}
// 处理局部更新(只刷新变化的字段,避免图片闪烁)
for (payload in payloads) {
if (payload is Bundle) {
payload.getString("title")?.let { holder.tvTitle.text = it }
if (payload.containsKey("price")) {
holder.tvPrice.text = "¥${payload.getDouble("price")}"
}
}
}
}
// ...
}
4.2 错误写法 → 问题 → 正确写法
错误写法一:在 onBindViewHolder 中做同步 IO
// ❌ 错误:主线程同步读取数据库
override fun onBindViewHolder(holder: VH, position: Int) {
val item = items[position]
val isFavorite = database.favoriteDao().isFavorite(item.id) // 同步 IO!
holder.ivFavorite.isSelected = isFavorite
}
问题 : onBindViewHolder 在主线程执行,同步 IO 直接阻塞 Choreographer,每帧多出几十 ms。
// ✅ 正确:数据预加载 + 合并进 item model
data class ProductItem(
val id: Long,
val title: String,
val isFavorite: Boolean // 提前加载好,bind 时直接用
)
override fun onBindViewHolder(holder: VH, position: Int) {
val item = items[position]
holder.ivFavorite.isSelected = item.isFavorite // 纯 UI 赋值,无 IO
}
错误写法二:滥用 notifyDataSetChanged
// ❌ 错误:任何数据变化都全量刷新
fun updateData(newList: List
) {
items = newList
notifyDataSetChanged() // 全量 rebind,白屏闪烁
}
// ✅ 正确:使用 ListAdapter(内置异步 DiffUtil)
class ProductAdapter : ListAdapter
(
object : DiffUtil.ItemCallback
() {
override fun areItemsTheSame(a: ProductItem, b: ProductItem) = a.id == b.id
override fun areContentsTheSame(a: ProductItem, b: ProductItem) = a == b
}
) {
// submitList 会在后台线程自动计算 diff,主线程只负责 dispatch 动画
}
// ViewModel 中直接 submit
viewModel.products.collect { adapter.submitList(it) }
5. 最佳实践
5.1 扩大 mCachedViews 容量
做法 :
recyclerView.setItemViewCacheSize(8) // 默认 2,建议设为预期屏幕行数
原因 :mCachedViews 命中时不触发 onBindViewHolder,滑动回退体验最佳。容量太小时,滑出屏幕超过 2 个 item 就会落入 Pool,触发不必要的 rebind。 对比 :不扩大时,每次回退滑动都要重新 bind,Glide 重新解码图片,肉眼可见的"重绘"闪烁。
5.2 共享 RecycledViewPool
做法 :
// 在 Fragment 的父容器(如 Activity)层创建共享 Pool
val sharedPool = RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(VIEW_TYPE_PRODUCT, 20) // 根据 viewType 设置容量
}
// 多个列表(如 Tab 切换)共享同一个 Pool
recyclerViewA.setRecycledViewPool(sharedPool)
recyclerViewB.setRecycledViewPool(sharedPool)
原因 :Tab 切换时同类型 ViewHolder 可直接从共享 Pool 取出复用,跳过 inflate 和部分 bind 开销。 对比 :不共享时,每次 Tab 切换都要重新 inflate ViewHolder,在复杂 Item 布局下 inflate 耗时可达 10-30ms/个。
5.3 开启预取(GapWorker)
做法 :
// RecyclerView 1.1+ 默认已启用预取,无需手动开启
// 但嵌套 RV 需要手动配置内层预取数量
innerLayoutManager.initialPrefetchItemCount = 4 // 外层 RV 滑动时预取内层 item 数
原因 : GapWorker 利用帧间空闲时间在后台线程预先 inflate + bind 下一帧需要的 ViewHolder,主线程仅需从 mCachedViews 取出直接展示。 对比 :不配置嵌套预取时,外层 RV 每次滑出新的内层 RV,内层需同步 inflate N 个 item,直接导致帧超时。
5.4 避免 ItemDecoration 中的重复对象创建
做法 :
class SpaceItemDecoration(private val space: Int) : RecyclerView.ItemDecoration() {
// ✅ Rect 在类字段中声明,避免每帧创建
private val rect = Rect()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
outRect.set(0, 0, 0, space) // 直接复用 outRect,不要 new Rect()
}
}
原因 : getItemOffsets 在每次 layout 时对每个可见 item 调用一次, onDraw 在每帧调用,对象创建会触发 GC。 对比 :在 onDraw 里 new Paint() 是经典反例,会显著增加 GC 压力,Profiler 里可见 GC 暂停毛刺。
5.5 精确使用局部刷新 API
// 根据变化类型选择最精确的 notify API
adapter.notifyItemChanged(position) // 单条内容变化
adapter.notifyItemChanged(position, payload) // 单条字段级变化
adapter.notifyItemRangeChanged(startPos, itemCount) // 范围变化
adapter.notifyItemInserted(position) // 插入
adapter.notifyItemRemoved(position) // 删除
adapter.notifyItemMoved(fromPosition, toPosition) // 移动
原则 :只有在无法确定变化范围时才用 notifyDataSetChanged(),其余情况一律用精确 API 或 DiffUtil。
6. 常见坑点
坑点一:ListAdapter 的 submitList 重复列表对象无效
现象 :调用 adapter.submitList(list) 后 UI 不更新。 原因 : ListAdapter 内部会检查 newList === currentList,如果是同一个对象引用,会直接 skip,不计算 diff。 复现 :
val list = mutableListOf
()
list.add(Item(1, "new"))
adapter.submitList(list) // ❌ 如果 list 和上次是同一个实例,UI 不更新
解决 :
adapter.submitList(list.toList()) // ✅ 创建新的 List 实例
// 或者在 ViewModel 中始终用不可变列表
_products.value = newData.toList()
坑点二:StaggeredGridLayoutManager 与 DiffUtil 的移动动画崩溃
现象 :使用瀑布流布局 + DiffUtil 时,item 移动动画偶发 IndexOutOfBoundsException。 原因 : StaggeredGridLayoutManager 的 span 管理与 RecyclerView 动画系统存在已知 race condition(AOSP issue #37007087)。 解决 :
(recyclerView.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
// 或直接禁用移动动画
recyclerView.itemAnimator = null
坑点三:在 RecyclerView 滚动时加载图片导致卡顿
现象 :快速滑动时 CPU 使用率飙升,帧率下降至 30fps 以下。 原因 :滑动状态下仍在触发图片解码,竞争主线程资源。 解决 :
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING,
RecyclerView.SCROLL_STATE_SETTLING -> Glide.with(context).pauseRequests()
RecyclerView.SCROLL_STATE_IDLE -> Glide.with(context).resumeRequests()
}
}
})
坑点四:setHasStableIds(true) 使用不当导致 Item 错位
现象 :开启 stableIds 后,删除/插入 item 时出现错位或重复显示。 原因 : setHasStableIds(true) 要求 getItemId() 对同一个数据对象始终返回唯一且稳定的 id,若 id 不稳定(如用 position 作为 id)会导致缓存匹配错误。 解决 :
// ✅ 使用业务 id(如数据库主键)
override fun getItemId(position: Int): Long = items[position].id
// ❌ 绝对不能用 position 作为 id
override fun getItemId(position: Int): Long = position.toLong()
坑点五:嵌套 RecyclerView 导致内层 RV 高度计算异常
现象 :外层垂直 RV 嵌套内层水平 RV,内层 RV 高度为 0 或显示不全。 原因 :外层 RV 测量子 View 时,内层 RV 的 wrap_content 需要测量所有子 item,默认情况下会触发内层 RV 预测量失败。 解决 :
// 方案一:给内层 RV 固定高度
innerRv.layoutParams.height = 200.dp
// 方案二:开启 hasFixedSize(当 item 尺寸不变时)
innerRv.setHasFixedSize(true)
// 方案三:使用 LinearLayoutManager 的 wrap_content 修复(已在 1.1+ 修复)
innerRv.layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
7. 总结
-
理解四级缓存 :
mAttachedScrap>mCachedViews>ViewCacheExtension>RecycledViewPool,命中层级越高,性能越好,bind 越少触发。 -
DiffUtil 是必选项 :任何列表刷新都应通过
ListAdapter或手动DiffUtil,notifyDataSetChanged()仅作最后手段。 -
异步计算 diff :
DiffUtil.calculateDiff应在Dispatchers.Default执行,避免大列表阻塞主线程。 -
onBindViewHolder 零 IO 原则:bind 阶段只做 UI 赋值,数据预处理(IO、计算)必须在数据层完成。
-
按需调整缓存容量 :
setItemViewCacheSize、RecycledViewPool.setMaxRecycledViews、initialPrefetchItemCount三个参数按实际场景调整,不要照搬默认值。
> 核心结论:RecyclerView 性能优化的本质是减少 onCreateViewHolder(减少 inflate)和 onBindViewHolder(减少重绑)的调用次数,一切手段都围绕这两个目标展开。
参考资料
-
AOSP 源码:
frameworks/support/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/RecyclerView.java -
AOSP 源码:
frameworks/support/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DiffUtil.java