RecyclerView 缓存与复用机制:从一次滑动讲明白(2026 版)
很多人"会用"RecyclerView,但一遇到这些问题就开始玄学:
- 为什么只滑了一屏,就创建了很多 ViewHolder?
- 为什么有时只触发部分 onBindViewHolder,有时又全量触发?
- 为什么同样的 itemView,复用时有时不需要重新绑定数据,有时必须重新绑定?
- ViewPager2 为什么也会出现同样的现象?
这篇文章用一个可复现的滑动场景把缓存复用讲透:你不需要背源码,只要建立正确的心智模型,就能把现象解释到"可预测"的程度,并知道该怎么观测与调参。
图 1:你在日志里看到的,本质是 create / bind 的组合
create: onCreateViewHolder() bind: onBindViewHolder()
┌───────────────┐ ┌──────────────────┐
│ inflate / init │ │ data -> views │
│ findViewById │ │ text/image/state │
└───────┬───────┘ └─────────┬────────┘
│ │
└──────────────> ViewHolder <────────┘
(itemView + refs)
摘要(先看结论)
- RecyclerView 不是"一个缓存",而是一条有优先级的取用链路:先尝试拿到"原封不动可直接用"的 ViewHolder,再退化到"能复用布局但要重新绑定",最后才"新建"。
- 滑动场景下最关键的两层缓存是:mCachedViews(按位置精确命中)与 RecycledViewPool(按 viewType 命中)。前者命中时往往可以免绑数据,后者命中时必须重新绑定数据。
- 一次滚动里通常是"先为即将进入屏幕的 item 找 View(复用/新建)",随后"再把离开屏幕的 item 回收进缓存"。因此,新进入屏幕的一行通常不会用到"刚刚离开屏幕的那一行"的 ViewHolder。
- "只触发部分 onBindViewHolder"并不神秘:往往意味着有一部分 item 命中了 mCachedViews(位置命中,直接拿来用),其余 item 从 RecycledViewPool 拿到(类型命中,需要 bind)。
- 你能调的核心旋钮只有两个:mCachedViews 的容量(setItemViewCacheSize)与 RecycledViewPool 每种类型的容量(setMaxRecycledViews)。调大并不总是更快,它是在用内存换绑定/创建次数。
快速导航(按问题定位)
| 现象 | 你真正想问的问题 | 优先看哪里 | 最有效的观测点 |
|---|---|---|---|
| onCreateViewHolder 很频繁 | 为什么没复用到? | viewType 设计、Pool 容量、是否有动画/预取 | onCreateViewHolder 次数 + viewType 分布 |
| onBindViewHolder 很频繁 | 为什么没命中"免绑缓存"? | mCachedViews 容量、位置命中条件、notify 方式 | onBindViewHolder 次数 + position/holderId |
| 只绑定了部分 item | 为何一行里只有部分触发 bind? | mCachedViews vs Pool 的命中差异 | 打印"来源:cache/pool/create" |
| 复用后 UI 串台 | 为什么复用出错? | onBind 是否完整重置 View 状态 | 对比 bind 前后 View 状态 |
| 多个列表互相抢资源 | 为什么滚动一个列表另一个更卡? | 是否共享 RecycledViewPool | RV 数量 + Pool 命中率 |
| ViewPager2 也有类似问题 | 它到底是不是 RecyclerView? | ViewPager2 的内部实现 | page 的 create/bind 频率 |
先建立 4 个动作的定义
为了避免"复用/回收/缓存"混在一起,先把四个动作分清:
- 创建(create):Adapter.onCreateViewHolder(),创建 ViewHolder 与 itemView(通常包含 inflate / findViewById 等成本)。
- 绑定(bind):Adapter.onBindViewHolder(),把 position 对应的数据写进 ViewHolder 的 View(文本、图片、可见性、点击态等)。
- 回收(recycle):某个 item 离开屏幕后,RecyclerView 把它的 ViewHolder 放进缓存结构中,等待将来复用。
- 复用(reuse):需要显示某个 position 时,从缓存结构里尝试拿一个可用的 ViewHolder;拿不到才创建新的。
你看到的各种现象,本质是:某次"复用"走到了哪一步,以及某次"回收"把 ViewHolder 放进了哪一层。
图 2:一次滚动里,复用与回收的相对时序(典型情况)
时间轴: ─────────────────────────────────────────────────────────>
阶段 A: 为即将进入屏幕的位置拿 View (复用/新建)
getViewForPosition() x N
阶段 B: 旧位置离屏 -> 回收进缓存
recycle() x M
结论: 新进入的一行,通常不会立刻用到"刚离屏的一行"的 holder
RecyclerView 的缓存不是一个:你至少要认识这四层
下面用"你需要记住的程度"描述每层缓存(不依赖具体版本细节):
1)Scrap:布局过程的临时区(attached / changed)
- 这层更多服务于一次 layout/动画预测过程中的"临时搬运"和一致性校验。
- 很多纯滑动场景里,你感知不到它的存在,因为它不决定你关心的"免绑/要绑"差异。
如果你只想解释滑动时的 create/bind 行为,真正决定性的通常不是它。
2)mCachedViews:一级缓存(位置精确命中,可能免 bind)
-
这是"最香的一层":里面的 ViewHolder 往往仍保留着上一次绑定的数据与状态。
-
命中条件更严格:它倾向于"把原来那个 position 的 ViewHolder 还给原来那个 position"(或者至少满足位置/合法性校验)。
-
默认容量很小(很多版本默认是 2),因此你经常会看到"部分 item 免 bind,部分 item 要 bind"的混合现象。
图 3:两层关键缓存的"命中条件"差异
┌─────────────────────────────────────┐ │ RecyclerView.Recycler │ └───────────────┬─────────────────────┘ │ 位置命中(严格) │ 类型命中(宽松) │ ┌──────────────────────┴──────────────────────┐ │ │┌───────▼────────┐ ┌────────▼────────┐
│ mCachedViews │ │ RecycledViewPool │
│ (L1 cache) │ │ (shared pool) │
├─────────────────┤ ├──────────────────┤
│ 关键: position │ │ 关键: viewType │
│ 命中可能免 bind │ │ 命中一定要 bind │
└─────────────────┘ └──────────────────┘
3)ViewCacheExtension:可选扩展(很少在业务里直接用)
- 给你一个钩子:你可以自己提供某个 position/type 的 View,但多数业务不需要走到这里。
4)RecycledViewPool:共享池(二级缓存,按 viewType 命中,必须 bind)
- 这是"更通用的一层":只要 viewType 匹配,就能拿出来复用 itemView 的结构。
- 但它会把 ViewHolder 当作"可重复使用的空壳",因此通常需要重新绑定数据(也就是会触发 onBindViewHolder)。
- 默认每种 viewType 的容量常见是 5(不同版本可能略有差异),并且它可以在多个 RecyclerView 之间共享。
这两层(mCachedViews + RecycledViewPool)基本就能解释你在日志里看到的绝大多数行为差异。
图 4:RecycledViewPool 的内部结构(按 viewType 分桶)
pool
├── type=0 : [ VH, VH, VH, ... ] (LIFO: 取最后一个)
├── type=1 : [ VH, VH, ... ]
└── type=2 : [ ... ]
命中条件: 只要 viewType 相同即可
代价: 需要重新 bind(把"空壳"填回数据)
一次滑动里:先复用还是先回收?
用一个具体场景讲清楚(可复现,后面给代码):
- GridLayoutManager,spanCount = 5(每行 5 个卡位)
- 屏幕上同时显示 2 行(共 10 个卡位)
- 所有 item 的 viewType 一致
从"第 1、2 行在屏幕上"开始,向下滑一行,让"第 3 行进入屏幕":
- RecyclerView 需要为"第 3 行的 5 个 position"拿到 ViewHolder(复用或创建)。
- 此时"第 1 行"还没有被回收进缓存,所以缓存里可用的通常只有历史残留(多数情况下没有)。
- 因此第 3 行经常会触发 5 次创建(onCreateViewHolder)。
- 随后第 1 行离开屏幕,才被回收进 mCachedViews / RecycledViewPool。
结论:在这种典型滚动里,你看到的往往是"先给新进入屏幕的 item 找 View(复用/创建)",再"把旧的 item 回收"。这也解释了为什么"新进入的一行"通常不会直接复用"刚离开的一行"。
图 5:5 列网格 + 屏幕 2 行 的可视化场景
屏幕(可见区域)
┌───────────────────────────────────────────┐
│ row 1: [0] [1] [2] [3] [4] │
│ row 2: [5] [6] [7] [8] [9] │
└───────────────────────────────────────────┘
向下滑一行后
┌───────────────────────────────────────────┐
│ row 2: [5] [6] [7] [8] [9] │
│ row 3: [10][11][12][13][14] (新进入) │
└───────────────────────────────────────────┘
典型现象: 先为 row3 拿 View,再回收 row1
复用链路:getViewForPosition 背后的优先级(简化版)
你不需要背完整源码,只要记住这个优先级序列:
- 先尝试从 Scrap(attached/changed 等)里拿到
- 再尝试从 mCachedViews 按位置命中拿到
- 如果启用了 stableId,则可能按 itemId 再尝试一轮更精确的匹配
- 再尝试 ViewCacheExtension(如果你自己提供)
- 再尝试从 RecycledViewPool 按 viewType 拿到
- 最后才调用 onCreateViewHolder 新建
然后决定是否要绑定:
-
命中 mCachedViews 且 holder 仍然"已绑定且不需要更新",可能不会触发 onBindViewHolder。
-
命中 RecycledViewPool 或新建 holder,通常会触发 onBindViewHolder。
图 6:复用链路优先级(从"最省事"到"最费事")
getViewForPosition(pos)
│
├─ Scrap (layout 临时区)
│
├─ mCachedViews (按 position 命中) -> 可能免 bind
│
├─ (stableId) 按 itemId 再匹配一轮
│
├─ ViewCacheExtension (可选扩展)
│
├─ RecycledViewPool (按 viewType 命中) -> 必须 bind
│
└─ onCreateViewHolder() 新建 -> 必须 bind
回收链路:离屏的 ViewHolder 会去哪?
仍然只记住一个关键点:回收时也有优先级。
- 优先塞进 mCachedViews(直到它满)
- mCachedViews 满了就把更老的挤到 RecycledViewPool(按 viewType 分桶)
- 如果某些 holder 状态不允许缓存(例如被标记为 invalid/removed/needsUpdate 等),它可能绕过 mCachedViews,直接进 Pool 或被丢弃(取决于内部条件)
这就给了你一个非常实用的直觉:
-
mCachedViews:更像"短期、位置敏感"的缓存,专门用来减少 bind
-
RecycledViewPool:更像"长期、类型敏感"的缓存,专门用来减少 create
图 7:回收时的"先塞 L1,再挤到 Pool"
recycle(holder)
│
├─ 先尝试放入 mCachedViews
│ ┌──────────────────────┐
│ │ mCachedViews (max=2) │
│ │ [ newest ... oldest ] │
│ └──────────┬───────────┘
│ │ 满了就挤出
│ ▼
└─ 放入 RecycledViewPool (按 viewType 分桶)
pool[type] <- evicted holder
三个经典现象:用同一个场景一次解释完
下面把最常见的三个"看起来像 bug 的现象"用同一个场景讲透。
现象 1:为什么会"先复用/创建,再回收"?
因为 LayoutManager 在填充下一屏时,需要先为"将要显示"的 position 拿 View(否则它没法把布局撑起来);而"离屏"发生在布局推进之后,回收自然发生在后面。
你可以把它理解成:
- 先把"下一屏要用的砖头"搬过来(复用/新建)
- 再把"上一屏不用的砖头"收进仓库(回收)
现象 2:为什么一行 5 个卡位里,只有 3 个触发了 onBindViewHolder?
继续沿用"每行 5 个卡位、屏幕 2 行"的场景。假设:
- mCachedViews 容量 = 2
- RecycledViewPool 每种类型容量 = 5
当你从"显示第 1、2 行"滑到"显示第 2、3 行"后,第 1 行离屏会被回收:
- 最新回收的 2 个 ViewHolder 进入 mCachedViews
- 剩余 3 个 ViewHolder 进入 RecycledViewPool
这里有个容易误会的点:mCachedViews 不是"随机塞两个",而是更偏向"保留最近回收的两个"。在 GridLayoutManager(内部复用 LinearLayoutManager 的回收逻辑)的常见滚动场景里,一行 5 个 position 往往会按从后往前回收,因此更常见的是 [3,4] 留在 mCachedViews,而 [0,1,2] 进入 Pool;这正好对应"只 bind 3 个"的日志现象。
当你再向上滑回到"显示第 1、2 行"时,第 1 行需要 5 个 ViewHolder:
- 其中 2 个 position 刚好命中 mCachedViews:直接拿来用,可能不需要重新 bind
- 剩余 3 个从 RecycledViewPool 拿:必须重新 bind
于是你在日志里看到:只触发了 3 次 onBindViewHolder。
图 8:为什么一行只 bind 3 个(cache=2, pool=5)
离屏 row1 回收后:
mCachedViews: 2 个 (免 bind 候选)
pool[type=0]: 3 个 (需要 bind)
回到 row1 重新显示时需要 5 个:
从 mCachedViews 命中 2 个 -> bind 0 次 (可能)
从 pool 命中 3 个 -> bind 3 次
现象 3:为什么最后总共创建了 17 个 ViewHolder,而不是 15 或 10?
继续在"已经创建了 15 个 ViewHolder"的基础上向下滑,让第 4 行进入屏幕:
- RecycledViewPool 里可能只有 3 个可用(来自之前回收的那 3 个)
- mCachedViews 里虽然有 2 个,但它们通常是"位置敏感"的(例如对应的是其他 position),此时不一定能拿来给第 4 行的 position 用
因此第 4 行需要的 5 个里:
- 3 个从 Pool 复用(触发 bind)
- 2 个不得不新建(触发 create + bind)
于是创建总数从 15 变成 17。
到这里你会发现:这不是玄学,而是"两个容量 + 一个命中条件"共同决定的结果。
图 9:为什么会 create 到 17(pool 只剩 3 个可用)
第 4 行要进屏,需求: 5 个 holder
可用资源:
pool[type=0] = 3 个 -> 可复用 (但要 bind)
mCachedViews = 2 个 -> 位置敏感,不一定能给 row4 用
结果:
reuse from pool = 3
create new = 2
total created = 15 + 2 = 17
最小可复现实验(Kotlin)
下面给一个最小实验,专门用来验证你对"create/bind/复用来源"的理解。你只需要把它放进一个空 Activity 或 Fragment 里跑起来,滑动就能看到规律。
kotlin
class LoggingAdapter(
private val items: List<String>
) : RecyclerView.Adapter<LoggingAdapter.VH>() {
class VH(val root: TextView) : RecyclerView.ViewHolder(root) {
val holderId: Int = nextId.getAndIncrement()
companion object {
private val nextId = AtomicInteger(1)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val tv = TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
200
)
setPadding(24, 24, 24, 24)
}
val holder = VH(tv)
Log.d("RV", "create holderId=${holder.holderId}")
return holder
}
override fun onBindViewHolder(holder: VH, position: Int) {
holder.root.text = "pos=$position holderId=${holder.holderId} item=${items[position]}"
Log.d("RV", "bind pos=$position holderId=${holder.holderId}")
}
override fun getItemCount(): Int = items.size
}
kotlin
class DemoActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val rv = RecyclerView(this)
setContentView(rv)
rv.layoutManager = GridLayoutManager(this, 5)
rv.adapter = LoggingAdapter(List(200) { "item-$it" })
rv.setItemViewCacheSize(2)
rv.recycledViewPool.setMaxRecycledViews(0, 5)
}
}
你可以观察两个结论是否成立:
- 初次渲染会创建"屏幕可见 item 数"个 ViewHolder(例如 10 个)
- 往下滚一行时,可能先 create(为了填充新一行),再回收上一行(因此不会立即复用刚离屏的那一行)
如果你把 setItemViewCacheSize(2) 改成 setItemViewCacheSize(10),你会明显看到 bind 次数下降(更多位置命中 mCachedViews),但内存占用也会上升。
图 10:两个旋钮分别影响什么(不要混为一谈)
┌─────────────────────────────┐
│ 你想优化的目标是什么? │
└──────────────┬──────────────┘
│
┌────────────────┴────────────────┐
│ │
少 create(少 inflate) 少 bind(少改 View)
│ │
调大 pool[type] 容量 调大 itemViewCacheSize
setMaxRecycledViews() setItemViewCacheSize()
工程落地:真正有效的 8 条建议
1)把"减少 create"和"减少 bind"分开思考
- 想减少 create:优先关注 viewType 是否过多、Pool 是否太小、是否能共享 Pool。
- 想减少 bind:优先关注 mCachedViews 的容量、是否频繁触发全量刷新、是否能用 payload 做局部更新。
2)viewType 设计决定了 Pool 的"可复用粒度"
Pool 是按 viewType 分桶的。你把一个列表拆成 10 种 viewType,就等于把"可复用的池子"拆成 10 个小池子,命中率会下降。
3)在多个 RecyclerView 之间共享 RecycledViewPool
如果你有多个列表(例如页面里多个模块、或嵌套 RV),共享 Pool 往往能显著减少 create:
kotlin
val sharedPool = RecyclerView.RecycledViewPool()
rv1.setRecycledViewPool(sharedPool)
rv2.setRecycledViewPool(sharedPool)
图 11:多个 RecyclerView 共享同一个 Pool
┌───────────────┐ ┌────────────────────┐
│ RecyclerView A │ ───────►│ │
└───────────────┘ │ RecycledViewPool │
│ (shared) │
┌───────────────┐ │ │
│ RecyclerView B │ ───────►│ │
└───────────────┘ └────────────────────┘
效果: A 回收进 pool 的 holder,B 也能按 viewType 复用到
4)谨慎调大 setItemViewCacheSize
它能降低 bind,但这是"用内存换 CPU"。在 Feed 这种卡片复杂、图片多的场景里,盲目调大可能适得其反(更容易触发内存抖动、GC)。
5)避免无差别 notifyDataSetChanged
全量刷新会让"位置敏感的缓存"更难命中,bind 次数上升。能用 DiffUtil / ListAdapter,就尽量让更新变成"可预测的局部变化"。
更进一步,如果你的变化是"同一个 item 里只有一小块 UI 变了",可以用 payload 做局部刷新,减少无意义的全量 bind。
图 13:payload 局部刷新的调用链
notifyItemChanged(pos, payload)
│
▼
onBindViewHolder(holder, pos, payloads)
│
├─ payloads 为空 -> 按全量 bind 处理
└─ payloads 非空 -> 只更新变更的字段
最小用法分三步:
1)定义 payload 类型,并在更新时携带它:
kotlin
sealed interface RowPayload {
data class TitleChanged(val title: String) : RowPayload
data class LikeChanged(val liked: Boolean) : RowPayload
}
kotlin
adapter.notifyItemChanged(position, RowPayload.LikeChanged(liked = true))
2)重载带 payloads 的 onBindViewHolder,做到"局部更新 + 回退全量更新":
kotlin
override fun onBindViewHolder(holder: VH, position: Int, payloads: MutableList<Any>) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
return
}
payloads.forEach { p ->
when (p) {
is RowPayload.TitleChanged -> holder.root.text = p.title
is RowPayload.LikeChanged -> holder.likeView.isSelected = p.liked
}
}
}
3)确保"全量 bind"仍然完整重置状态(payload 不是稳定承诺):
- RecyclerView 允许在任意时刻退化为全量 bind(例如 view 被重新 attach、动画/预测布局、payload 合并丢失等),所以你的全量 onBindViewHolder 必须永远正确。
- payload 只是一种"尽量少做事"的提示,不是"保证只做这点事"的契约。
如果你用的是 ListAdapter / DiffUtil,还可以让 DiffUtil 自动产出 payload:在 ItemCallback 里实现 getChangePayload,然后 ListAdapter 会把它透传到 payloads。
kotlin
class Diff : DiffUtil.ItemCallback<Row>() {
override fun areItemsTheSame(oldItem: Row, newItem: Row): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Row, newItem: Row): Boolean =
oldItem == newItem
override fun getChangePayload(oldItem: Row, newItem: Row): Any? =
when {
oldItem.title != newItem.title -> RowPayload.TitleChanged(newItem.title)
oldItem.liked != newItem.liked -> RowPayload.LikeChanged(newItem.liked)
else -> null
}
}
6)onBind 必须完整重置 View 状态
复用出错几乎都来自这里:你只设置了"有值时"的 UI,没有重置"没值时"的 UI。任何可变状态都应该在 bind 中有确定的最终赋值(包括可见性、选中态、监听器、占位图等)。
7)启用 stableId 是一把双刃剑
它可以帮助更精确地匹配 holder(尤其在插入/删除、动画预测场景),但前提是你的 itemId 真正稳定且唯一;否则会引入更难排查的错乱。
图 12:stableId 的"更精确匹配"直觉(仅用于理解)
无 stableId:
主要靠 position / viewType 复用
有 stableId:
尝试按 itemId 找回"曾经属于同一个数据项"的 holder
前提:
itemId 必须稳定、唯一
否则:
可能出现"拿错历史 holder"的错乱
8)如果你在用 ViewPager2,把它当 RecyclerView 看
ViewPager2 基于 RecyclerView 构建:页面本质上就是一组 ViewHolder 的复用与回收。你看到的"某些页面不重新 bind / 某些页面频繁 create"与上文完全同源。
自检:你读懂的标志
- 你能用"mCachedViews 命中 / Pool 命中 / 新建"解释任意一次滚动里的 create/bind 分布。
- 你能说清楚:同一个 viewType 下,调大 Pool 会影响 create,而调大 itemViewCacheSize 会影响 bind。
- 你能在遇到性能问题时先做观测(create/bind/viewType/缓存容量),而不是先凭感觉改参数。