先把结论放在前面: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:列表更新的正确姿势
有任何问题欢迎在评论区留言交流。