第142篇RecyclerView 缓存机制:四层缓存各自干什么

简介: RecyclerView 缓存非单池,而是四层协同机制:`mAttachedScrap`(屏内重排免绑定)、`mCacheViews`(屏外按位置复用,免创建免绑定)、`mViewCacheExtension`(业务预创建扩展)、`mRecycledViewPool`(跨列表按 type 共享,免 inflate)。核心在于理解各层设计意图,而非死记字段。

先把结论放在前面:RecyclerView 的缓存不是一个池子,而是四层职责不同的池——mAttachedScrap(屏内刚被移除、马上要回填)、mCacheViews(屏外暂存、已绑定好可直接用)、mViewCacheExtension(自定义扩展,按需批量Inflate)、mRecycledViewPool(按 type 跨 RecyclerView 共享的回收池)。 判断"这次滑动会不会重新 onCreateViewHolder",本质就是判断这四层有没有命中。

这题答好需要的不是背层级,而是理解每一层的设计意图:mCacheViews 存在的意义是"滚出屏幕的条目很快滚回来时避免重绑数据";mRecycledViewPool 存在的意义是"多个列表共用同一种 item 时避免重复 inflate"。能把这两条意图讲清,就说明理解了 RecyclerView 的复用策略,而不是记住了五个字段名。

四层缓存逐层拆开

第一层:mAttachedScrap(屏内 Scrap)。 RecyclerView 布局时会把"暂时不需要但可能马上回来"的子 View 收进这里,典型场景是布局重排(比如 notifyDataSetChanged 后重新布局,或 LayoutManager 换 orientation)——旧布局的 View 会被收进 Scrap,新布局时优先从 Scrap 拿回来,连数据都不用重绑。它按 holder 的 mPosition 直接匹配,这一层不需要 onBindViewHolder,是 RecyclerView 性能的关键机制之一。规范上要记住:notifyDataSetChanged 在多数场景下并不会触发全量重绑,因为 Scrap 会在同一次布局周期内补回来——这也是它比 notifyDataSetChanged 更"轻"的原因之一(虽然从语义上仍然不该用它)。

第二层:mCacheViews(CachedView)。 从 mAttachedScrap 里挑出来"屏外、已绑定、短期内可能回来"的 View 放这里。它的命中条件是"位置匹配"(getPosition() == adapterPosition),命中后直接 attach,不走 onCreateViewHolder 也不走 onBindViewHolder。容量默认 CachedViewPool 的 maxRecycledViews = 2,也就是每个 viewType 最多缓存 2 个。这个数字偏小是有代价的:快速滑动来回甩动时容易反复 miss,表现是"快速回滑时 item 背景闪烁"——因为 View 重建后没有数据绑定,视觉上短暂空白。

第三层:mViewCacheExtension。 这是给业务方的扩展点,RecyclerView 在需要新 View 时先问它"能不能批量返回几个",典型用途是页签切换时预取下一页数据并提前 inflate。它的价值在于把"滚到边缘才创建"变成"数据到达时就创建",代价是自行管理 View 的生命周期与正确性,写不好会出现 View 被两个地方持有或未回收。

第四层:mRecycledViewPool。 真正跨 RecyclerView 共享的池,按 viewType 分桶,每桶默认 5 个。命中它只需要 getItemViewType 相同,onCreateViewHolder 会被调用,但 View 已经存在,只是不需要 inflate。这一层的设计目的是降低多列表场景的 inflate 开销与内存峰值(Tab 切换时不必每个 Tab 各建一套)。相关的两个 API 必须一起记:setRecycledViewPool(pool) 用于共享,以及 setItemViewCacheSize(n) 用于调第二层容量(默认 2)。

最常见的坑是

第一层坑:在 onBindViewHolder 里做重活。

onBindViewHolder 的调用频次远比"用户看到的条目数"高——每次从 CachedView 池 miss 落到重新绑定都会触发,滑动时每帧可能绑好几个。典型重活包括:网络请求发起、SpannableString 构造、Bitmap 解码、复杂布局测量、notifyItemChanged 递归调用。规范是把重活从 bind 移到 create + 数据层预计算:能在构造 ViewHolder 时一次做完的,不要放到 bind;需要异步的(图片)用框架级方案(Glide/Coil)而不是手写请求。

第二层坑:viewType 混用导致串型。

getItemViewType 返回的整数必须在语义上区分布局结构,而不是按"数据是哪一类"随意分。常见错误是两种结构完全不同的 item 返回同一个 type,从池里取到错误结构的 ViewHolder,onBindViewHolder 强转时抛 ClassCastException,或者布局参数错乱。规范:type 相同就意味着 ViewHolder 类、布局资源、布局结构完全一致;结构一变,type 必须变(可以用 type = 结构枚举.ordinal)。

第三层坑:条目内部状态没在 onBindViewHolder 里重置。

View 从池里取出时带着上一次绑定残留的状态。如果 onBindViewHolder 只在"首次创建"时设置某些属性(比如选中态、展开态、动画进度、可见性),复用后就会出现"上一个 item 的展开状态跑到了下一个 item 上"。规范是onBindViewHolder 必须把 View 的状态写成数据当前应有的样子,而不是"只在需要改变时改";涉及动画还要在 onViewRecycled 里调 recyclerView.clearAnimation() 与 cancel(),否则动画对象会带着旧目标继续跑。

还有一个更隐蔽的坑:在 onViewRecycled 里做重释放。 很多人在这里关闭播放器、取消请求、清理大图缓存,结果每个 View 回收都走一遍全量清理,滑动时的主线程负担反而变大。规范是回收阶段只做"引用解绑"(取消请求、置空监听、移除装饰),重资源释放交给图片框架的 trim 机制或按可见数量阈值触发。

代码里见真章

先看一个规范的多类型 Adapter,它把 type 语义、状态重置、回收清理三件事都做对:

sealed class FeedItem {
   
    data class Header(val title: String) : FeedItem()
    data class Text(val content: String) : FeedItem()
    data class Image(val url: String, val ratio: Float) : FeedItem()
    data class Video(val cover: String, val durationMs: Long) : FeedItem()
}

// 枚举即 type:结构不同 -> type 必不同
enum class FeedType {
    HEADER, TEXT, IMAGE, VIDEO }

class FeedAdapter(
    private val onVideoPlay: (View, FeedItem.Video) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
   

    private val data = mutableListOf<FeedItem>()

    // 第二层容量:默认 2,快速来回甩动时提高可显著减少重建
    init {
    setItemViewCacheSize(6) }

    fun submit(list: List<FeedItem>) {
   
        data.clear(); data.addAll(list)
        // DiffUtil 精确刷新,避免全量重绑
        DiffUtil.calculateDiff(Callback(list, data.toList())).dispatchUpdatesTo(this)
    }

    override fun getItemCount(): Int = data.size

    override fun getItemViewType(position: Int): Int = when (data[position]) {
   
        is FeedItem.Header -> FeedType.HEADER.ordinal
        is FeedItem.Text -> FeedType.TEXT.ordinal
        is FeedItem.Image -> FeedType.IMAGE.ordinal
        is FeedItem.Video -> FeedType.VIDEO.ordinal
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
   
        val inflater = LayoutInflater.from(parent.context)
        // 关键:inflate 在这里发生,只在真正需要新建 View 时执行
        return when (FeedType.values()[viewType]) {
   
            FeedType.HEADER -> HeaderVH(inflater.inflate(R.layout.item_feed_header, parent, false))
            FeedType.TEXT -> TextVH(inflater.inflate(R.layout.item_feed_text, parent, false))
            FeedType.IMAGE -> ImageVH(inflater.inflate(R.layout.item_feed_image, parent, false))
            FeedType.VIDEO -> VideoVH(inflater.inflate(R.layout.item_feed_video, parent, false))
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
   
        when (holder) {
   
            is HeaderVH -> holder.bind(data[position] as FeedItem.Header)
            is TextVH -> holder.bind(data[position] as FeedItem.Text)
            is ImageVH -> holder.bind(data[position] as FeedItem.Image)
            is VideoVH -> holder.bind(data[position] as FeedItem.Video)
        }
    }

    // 有稳定 id 时开启,DiffUtil 的 item 匹配才准确
    override fun setHasStableIds(enable: Boolean) {
    /* 由 ListAdapter 场景决定 */ }

    override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
   
        super.onViewRecycled(holder)
        // 回收阶段只做解绑,不做重释放
        (holder.itemView as? RecyclerView)?.recycled()
    }
}

class ImageVH(val v: View) : RecyclerView.ViewHolder(v) {
   
    fun bind(item: FeedItem.Image) {
   
        // 每次 bind 都重置所有状态,池中取出的 View 带着旧值
        v.findViewById<View>(R.id.root).visibility = View.VISIBLE
        Glide.with(v).load(item.url).into(v.findViewById(R.id.iv))
    }
}

class VideoVH(val v: View) : RecyclerView.ViewHolder(v) {
   
    fun bind(item: FeedItem.Video) {
   
        v.findViewById<TextView>(R.id.tv_duration).text = formatDuration(item.durationMs)
        v.setOnClickListener {
    onVideoPlay(v, item) }
        // 关键:重置展开/播放状态,避免复用后继承上一条的状态
        v.findViewById<PlayerView>(R.id.player).visibility = View.GONE
        v.findViewById<View>(R.id.root).scaleX = 1f
        v.findViewById<View>(R.id.root).scaleY = 1f
    }
}

这段代码值得盯三处:第一处,setItemViewCacheSize(6) 显式提高第二层容量,减少快速回滑时的重建闪烁;第二处,onBindViewHolder 里把 scaleX/scaleY/visibility 这类状态显式重置——这正是"展开状态跑到下一条"的根治点;第三处,onViewRecycled 只解绑不释放,把重释放交给框架的 trim 策略。

再看第二层的容量与第四层的共享怎么配合:

// 多 Tab 共享同一个 RecycledViewPool:切 Tab 时不再重复 inflate
class TabFragment : Fragment() {
   
    private val pool = RecyclerView.RecycledViewPool().apply {
   
        // 每 type 容量默认 5,图片流列表建议提到 8~10
        setMaxRecycledViews(FeedType.IMAGE.ordinal, 10)
    }

    override fun onViewCreated(v: View, s: Bundle?) {
   
        v.findViewById<RecyclerView>(R.id.rv).apply {
   
            layoutManager = LinearLayoutManager(requireContext())
            setRecycledViewPool(pool)      // 四个 Tab 传同一个实例即可
            adapter = FeedAdapter {
    _, item -> openPlayer(item) }
            // 第二层与第四层的关系:CacheViews 命中"免绑",RecycledPool 命中"免 inflate"
            setItemViewCacheSize(4)
            addOnScrollListener(object : RecyclerView.OnScrollListener() {
   
                override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
   
                    // 滚动停止后主动 trim,把内存还给系统
                    if (newState == RecyclerView.SCROLL_STATE_IDLE) {
   
                        rv.recycledViewPool.clear()
                        Glide.with(rv).trimMemory(Glide.TRIM_MEMORY_MODERATE)
                    }
                }
            })
        }
    }
}

这段代码值得盯两处:第一处,setRecycledViewPool 传同一个实例才是"共享",每个 Tab 各建一个池等于没共享;第二处,滚动停止后 clear() + trimMemory,这是"既保流畅又把内存还给系统"的标准配对——平时靠池提速,静止时靠 trim 降峰。

ViewCacheExtension 的正确用法要谨慎,它适合"分页数据已在内存、只差 View"的场景:

// 自定义扩展:批量预创建,代价是自行保证 View 生命周期正确
val extension = object : RecyclerView.ViewCacheExtension() {
   
    private val preinflated = mutableListOf<RecyclerView.ViewHolder>()

    override fun getViewForPosition(
        recycler: RecyclerView.Recycler,
        position: Int
    ): RecyclerView.ViewHolder? {
   
        // position 已被 RecyclerView 判定为"缓存中没有",这里返回一个新 View
        return preinflated.removeFirstOrNull()
    }

    override fun recycleView(
        recycler: RecyclerView.Recycler,
        holder: RecyclerView.ViewHolder
    ) {
   
        // 必须在这里回收,否则池无上限增长直接 OOM
        if (preinflated.size < 12) preinflated.add(holder)
    }
}

这段代码的关键是 recycleView 必须实现——只写 getViewForPosition 不写回收,池会无上限增长,这是"用了 ViewCacheExtension 之后内存反而涨得更快"的直接原因。

面试中的经典考点

问:setItemViewCacheSize 调大有什么代价?

答:代价是内存线性上升。CachedView 里存的是已绑定、已持有图片/文本/子 View 引用的完整 View,容量从 2 提到 10,极端情况下会同时多持有 8 个 item 的完整 View 树。收益是回滑命中率提升、减少重建与重绑。规范是按 item 复杂度定容量——纯文本列表可以到 8~10,带大图或复杂布局的列表 2~4 就够,并且配合滚动停止后的 recycledViewPool.clear() 平衡内存。

问:notifyDataSetChanged 和 notifyDataSetInserted 差在哪?为什么前者慢?

答:差在是否能复用现有 ViewHolder。notifyDataSetChanged 语义是"整表失效",RecyclerView 会走 mState.mStructureChanged = true 路径,布局时旧 View 收进 Scrap 但按"结构已变"处理,通常触发全量 onBindViewHolder,同时所有 item 的位置语义变化也让 DiffUtil 无从插入。notifyDataSetInserted(count) 只声明"末尾新增 count 个",前面已有的 ViewHolder 位置不变,可直接复用。更优的答案是 ListAdapter + DiffUtil 做增量更新。

问:为什么加了 DiffUtil 还是掉帧?

答:因为 DiffUtil 解决的是"少刷新",不是"刷新得快"。三个常见原因:① Diff 在主线程跑,大列表 areItemsTheSame 里做业务判断导致计算量爆炸,规范是把 DiffUtil 的旧新快照预计算好再传入;② 刷新时 payload 粒度太粗,虽然只 notifyItemChanged 了目标 item,但 item 内部布局复杂导致单次 bind 超过一帧预算;③ 刷新时机在滑动过程中,应在滑动停止或用 post 延后到空闲。

问:RecycledViewPool 跨页面共享有什么风险?

答:三个风险:① 内存耦合——任一页面的大列表会把池撑大,另一个页面的低峰期无法回收(所以要配静止期 clear());② 布局上下文耦合——池里存的是已创建 View,若不同页面用不同 Context(如 Dialog 主题、动态上下文)会造成主题或资源错乱,规范是只用 Application 上下文创建 View;③ 动画状态残留——带动画的 View 入池后动画未清,复用时出现跳变,规范是在 onViewRecycled 里 clearAnimation() 并把动画属性复位。

问:setHasStableIds(true) 什么时候必须开?

答:当 item 有稳定唯一标识、且列表会做局部更新/多选/删除时。它的作用是让 RecyclerView 在布局阶段能跨位置追踪同一个 item,对动画和局部刷新有直接收益。风险是必须保证每个 item 的 id 唯一且稳定——用 position 当 id 是错的(notifyDataSetChanged 或插入删除后 position 会变,导致 ViewHolder 错位与动画异常)。规范是用数据本身的唯一键(消息 id、订单号)做 id。

落到项目里怎么做

一条能写进规范的红线:onBindViewHolder 里只做"把数据映射到已存在的 View 上",不做 inflate、不做请求发起、不做重计算。 凡是发现 bind 里出现了 LayoutInflater、网络调用、Bitmap 解码,都视为缺陷。

配套实践三条:① 多 Tab / 多列表统一共享一个 RecycledViewPool,并在各页面 SCROLL_STATE_IDLE 时 clear() 平衡内存;② item 里带动画、播放器、GIF 的,onViewRecycled 强制取消动画与播放并复位属性;③ 用 RecyclerView.RecycledViewPool.Adapter 统计各 type 的创建次数,某个 type 的创建次数远高于它应出现的次数,就说明缓存配置有问题——这是定位"快速回滑闪烁"最快的量化手段。

给正在准备面试的你

这题的答法要落到"四层缓存各自的命中条件与设计意图"。推荐这条线:先讲清 mAttachedScrap(屏内重排复用,免 bind)、mCacheViews(按位置命中,免创建免绑定)、mViewCacheExtension(业务扩展)、mRecycledViewPool(按 type 跨列表共享,免 inflate)→ 讲两个代价:CacheViews 默认只有 2 导致回滑闪烁、RecycledPool 每桶 5 导致首次创建仍有成本 → 讲三个坑:bind 做重活、type 混用、bind 不重置状态 → 落到"调容量 + 静止期 clear + 用 stableIds"这套组合拳。能被追问到"为什么 notifyDataSetChanged 也可能不触发全量重绑"(答 Scrap 会补回),说明真读过布局流程;能讲清 recycleView 不实现会 OOM,说明真写过 ViewCacheExtension。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:View-的滑动实现:scrollTo、Scroller-与属性动画

下一篇预告:RecyclerView-多类型与-DiffUtil:列表更新的正确姿势

有任何问题欢迎在评论区留言交流。

相关文章
|
20天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8846 25
|
18天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3646 16
|
18天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2223 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
4天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
398 1
|
12天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
6天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
7天前
|
人工智能 Linux Windows
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
千问办公(QwenWork)是阿里云推出的AI智能办公平台,支持网页端直接使用及Windows/Mac/Linux客户端下载。提供PPT生成、财报分析、网页搭建等AI功能,个人版免费,企业版198元/席/月。详情见官网qwenwork.cn或阿里云产品页。
902 0
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
|
18天前
|
云安全 人工智能 安全

热门文章

最新文章