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

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,但深挖后发现根因有三:

  1. 每个 Item 的 ImageView 每次都重新加载(Glide 缓存未命中)

  2. ViewHolder 的 onBindViewHolder 里做了同步 IO(读取本地收藏状态)

  3. 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。 对比 :在 onDrawnew 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. 总结

  1. 理解四级缓存mAttachedScrap > mCachedViews > ViewCacheExtension > RecycledViewPool,命中层级越高,性能越好,bind 越少触发。

  2. DiffUtil 是必选项 :任何列表刷新都应通过 ListAdapter 或手动 DiffUtilnotifyDataSetChanged() 仅作最后手段。

  3. 异步计算 diffDiffUtil.calculateDiff 应在 Dispatchers.Default 执行,避免大列表阻塞主线程。

  4. onBindViewHolder 零 IO 原则:bind 阶段只做 UI 赋值,数据预处理(IO、计算)必须在数据层完成。

  5. 按需调整缓存容量setItemViewCacheSizeRecycledViewPool.setMaxRecycledViewsinitialPrefetchItemCount 三个参数按实际场景调整,不要照搬默认值。

> 核心结论:RecyclerView 性能优化的本质是减少 onCreateViewHolder(减少 inflate)和 onBindViewHolder(减少重绑)的调用次数,一切手段都围绕这两个目标展开。


参考资料

相关推荐
zfoo-framework1 小时前
kotlin中体会到一些比较好用的点
android·开发语言·kotlin
我是唐青枫1 小时前
Kotlin also 详解:附加操作、链式调试与实战示例
kotlin
189228048611 小时前
NV077固态MT29F16T08ESLCHL6-QAES:C
c语言·开发语言·性能优化
●VON3 小时前
AtomGit Flutter鸿蒙客户端:文件树与代码浏览
android·服务器·安全·flutter·harmonyos·鸿蒙
故渊at10 小时前
系列三:组件化与模块化进阶 | 第11篇 组件化项目规范与问题根治:依赖、资源、Manifest 与混淆的全链路管控
android·架构·mvvm·模块化·组件化
故渊at10 小时前
系列二:MVVM 深度实战与项目重构 | 第7篇 LiveData & StateFlow 状态管理实战:从“粘包弹”到“丝滑流式”
android·重构
是阿建吖!10 小时前
【Linux】信号
android·linux·c语言·c++
心之伊始11 小时前
Java 后端接入大模型:从 Token、并发到推理成本的完整估算方法
java·spring boot·性能优化·大模型·llm
alexhilton12 小时前
AppFunctions:让你的Android应用更容易被AI智能体发现
android·kotlin·android jetpack