RecyclerView 缓存与复用机制:从一次滑动讲明白(2026 版)

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 行进入屏幕":

  1. RecyclerView 需要为"第 3 行的 5 个 position"拿到 ViewHolder(复用或创建)。
  2. 此时"第 1 行"还没有被回收进缓存,所以缓存里可用的通常只有历史残留(多数情况下没有)。
  3. 因此第 3 行经常会触发 5 次创建(onCreateViewHolder)。
  4. 随后第 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 背后的优先级(简化版)

你不需要背完整源码,只要记住这个优先级序列:

  1. 先尝试从 Scrap(attached/changed 等)里拿到
  2. 再尝试从 mCachedViews 按位置命中拿到
  3. 如果启用了 stableId,则可能按 itemId 再尝试一轮更精确的匹配
  4. 再尝试 ViewCacheExtension(如果你自己提供)
  5. 再尝试从 RecycledViewPool 按 viewType 拿到
  6. 最后才调用 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/缓存容量),而不是先凭感觉改参数。
相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui