一、四级缓存是谁
从"最近、最贵"到"最远、最便宜"的顺序:
-
Scrap(临时废料堆)
- 包含:mAttachedScrap、mChangedScrap,以及隐藏但未移出父容器的 child(hidden)。
- 特征:仍绑定(bound)到当前位置的数据 ,用于同一轮布局快速复用。
- 来源:布局过程 detach/更新、notifyItemChanged(payload) 等。
- 生命周期:只跨一次布局/动画帧,不会长期留存。
-
CachedViews(内存小缓存)
- 字段:mCachedViews,默认容量 2(可调)。
- 特征:已脱离父容器 ,但仍保持已绑定状态(带 position/type)。
- 命中快,不需要重新 bind;适合"左右滑动、相邻位置回来的复用"。
-
ViewCacheExtension(可选自定义层)
- 回调:RecyclerView.ViewCacheExtension#getViewForPositionAndType(...)。
- 特征:给你一个钩子,可从应用自己的缓存返回 View/ViewHolder。
- 默认 为 null,少数场景会用(比如复杂卡片跨 RV 共享)。
-
RecycledViewPool(共享回收池)
-
字段/类:mRecyclerPool(RecycledViewPool),按 viewType 分桶 ,默认每型容量 5(可调)。
-
特征:只存 未绑定 的 ViewHolder;取出后需 重新 bind。
-
可在多个 RecyclerView 间共享(例如 ViewPager2 的各页列表)。
-
注:有时你会在文章里看到"屏幕内已附着的 child"也被称作"第 0 级缓存"。严格说它们不在 Recycler 的缓存列表,但"在屏里直接复用"确实是最便宜的一层。
二、取用顺序(getViewForPosition()的核心流程)
当布局需要 position=p 的 item 时,Recycler 按下面先近后远去找 ViewHolder:
-
Scrap / Hidden / Cached
- 先找 mChangedScrap(处理带 payload/变更的项,pre-layout 用得多)
- 再找 mAttachedScrap / hidden(已从父移除但还在本次布局可用)
- 再找 mCachedViews (命中则通常无需 rebind)
-
ViewCacheExtension(如果注册了)
-
RecycledViewPool (按 viewType 取;取到后需要 rebind)
-
创建新 ViewHolder:adapter.createViewHolder() → adapter.bindViewHolder()
有 stableIds 时,还会尝试按 id 在 scrap 里定位,命中率更高。
三、各级的"进出条件"和大小
1) Scrap(mAttachedScrap / mChangedScrap)
-
进入:布局开始、动画/更新触发时,旧的可复用 child 会被 scrap;notifyItemChanged(payload) 的项会进 mChangedScrap。
-
移出:同一轮布局内被再次复用;布局结束会清空或移到下一层。
2) CachedViews(mCachedViews)
-
进入 :item 被移出屏幕且仍然有效(未被 remove/invalid),放入 mCachedViews。
-
大小:setItemViewCacheSize(n)(默认 2)。
-
溢出 :超容量时会把最旧的 移入 RecycledViewPool(前提是它依旧有效)。
3) ViewCacheExtension
- 你控制放与取;Recycler 只调用一次回调,如果你返回视图,它会接管这个 ViewHolder 的后续生命周期。
4) RecycledViewPool
- 进入:从 mCachedViews 溢出;或 item 被 remove/动画结束复用;或你手动 recycleView()。
- 大小:按 viewType 设置 setMaxRecycledViews(type, count),默认 5。
- 共享:childRv.setRecycledViewPool(sharedPool) 可让多个 RV 共用。
四、为什么要四级?------延迟与成本的权衡
层级 | 是否已绑定 | 作用域 | 速度 | 典型命中场景 |
---|---|---|---|---|
Scrap | 已绑定 | 单次布局 | ⭐️⭐️⭐️⭐️ | 同一帧内位置变化/动画 |
CachedViews | 已绑定 | 当前 RV | ⭐️⭐️⭐️ | 相邻页面来回/小范围滚动 |
ViewCacheExt | 取决于你 | 自定义 | 变化 | 自有跨 RV 缓存 |
Pool | 未绑定 | 跨 RV 共享 | ⭐️⭐️ | 大范围滚动/跨页面复用 |
- 越上层 命中越快(少或不需要 bind);越下层命中率更稳(容量大/可共享)。
- 对应优化就围绕"提高上层命中 、合理设置下层容量 、减少新建/重绑"。
五、实用优化清单(直接可用)
-
用对适配器:
- 普通列表:ListAdapter + DiffUtil(减少 notifyDataSetChanged 造成的失效)。
- ViewPager2 场景:多页列表共享 RecycledViewPool。
scss
val sharedPool = RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(VIEW_TYPE_ITEM, 50)
}
childRv.setRecycledViewPool(sharedPool)
-
调好两级容量:
- setItemViewCacheSize(n) :相邻来回滑可适当增大(如 4~8)。
- pool.setMaxRecycledViews(type, count) :按屏内最多可见项×2~3 估算。
-
稳定 ID 提高命中
kotlin
adapter.setHasStableIds(true)
override fun getItemId(pos: Int) = data[pos].id
-
使 Scrap/Cached 命中更精准、动画更平滑。
-
避免无谓失效
- 少用全量 notifyDataSetChanged();用 notifyItemRangeChanged 或 DiffUtil。
- payload 局部刷新,减少重 bind。
-
复用池跨页面共享(如 Tab/VP2 多 RecyclerView)
- 大幅降低"刚换页就新建一堆 ViewHolder"的抖动。
-
谨慎使用 ViewCacheExtension
- 只有当你能保证返回的 View 与数据一一对应且生命周期可控时再用。否则交给内建四级就够。
-
预取(Prefetch)
- 线性布局:layoutManager.isItemPrefetchEnabled = true(默认开),initialPrefetchItemCount = 6~12。
- VP2 内嵌 RV:为子 RV 设置 LinearLayoutManager#setInitialPrefetchItemCount,让下页列表先把 ViewHolder/数据准备好。
六、调试与排坑
-
打开 adb shell setprop log.tag.RecyclerView VERBOSE,看 log 中"recycle / cache / scrap / pool"命中情况。
-
观察 新建 ViewHolder 次数 (onCreateViewHolder 次数越少越好)与 重绑次数(onBindViewHolder)。
-
若 mCachedViews 命中低、onCreateViewHolder 频繁:
- 检查是否大量 notifyDataSetChanged 使缓存失效;
- viewType 是否过多导致池分桶过细;
- setItemViewCacheSize 是否太小。
-
若 OOM:
- 降低 pool 每型容量;
- 控制图片解码尺寸与持有时间;
- 避免在 offscreen 大量保活(offscreenPageLimit 等)。
一句话总结
RecyclerView 的四级缓存 = Scrap → CachedViews → ViewCacheExtension → RecycledViewPool ,取用顺序"近处先找,找不到再远 "。调好 itemViewCacheSize 和 共享 RecycledViewPool ,配合 稳定 ID / DiffUtil / 预取,就能显著降低 onCreateViewHolder/onBindViewHolder 的次数、减少卡顿与内存波动。