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 小时前
kotlin的修饰符
android·开发语言·kotlin
l1t2 小时前
在Android设备上利用Termux安装llama.cpp并启动webui
android·llama
浩宇软件开发2 小时前
基于Android天气预报应用开发APP
android·java·android studio·android开发
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于Android的电子日记APP的设计与实现为例,包含答辩的问题和答案
android
AdMergeX2 小时前
出海行业热点 | App开发商起诉苹果抄袭;欧盟要求Google开放Android AI权限;Google搜索推AI对话模式;中国小游戏冲上美国游戏总榜;
android·人工智能·游戏
艾莉丝努力练剑2 小时前
【QT】常用控件(一):初识控件,熟悉QWidget
android·linux·数据库·qt·学习·mysql·qt5
2501_915918412 小时前
iOS App HTTPS 抓包工具,代理抓包和数据线直连 iPhone 抓包的流程
android·ios·小程序·https·uni-app·iphone·webview
urkay-2 小时前
Android 当前Activity内显示的浮窗
android·java·iphone·androidx
奔跑吧 android2 小时前
【车载audio】【AudioService 01】【Android 音频子系统分析:按键音(Sound Effects)开启与关闭机制深度解析】
android·音视频·audioflinger·audioservice·audiohal