☰
RecyclerView四级缓存机制详解:从Scrap到RecycledViewPool的复用原理与性能调优
2026/10/16 11:02:08 网站建设 项目流程

1. 整体架构:四层缓存速查表,先把地图装进脑子

1.1 一张表看清RecyclerView回收后的所有去处

很多文章喜欢从“为什么要用RecyclerView”开始铺垫,我直接跳过去,先上结论。一个Item滚出屏幕之后,它的ViewHolder在RecyclerView内部会按顺序进入下面四种位置之一,这也是所有人说的“四级缓存”。

缓存位置内部结构是否绑定position命中后是否重新bind默认容量
mAttachedScrapArrayList<ViewHolder>是不bind,原样再用等于当前attach的child数
mCachedViewsArrayList<ViewHolder>是大多数场景下不bind2(setViewCacheSize可改)
mViewCacheExtension开发者自定义由实现决定由实现决定默认无
mRecycledViewPool按viewType分桶否,已和position解绑必须重新bind每个viewType最多5个

这个表格是全文的地基,后续所有排查和调参都围绕它展开。多说一句,很多人会把“默认缓存5个”挂在嘴边,其实这要分清楚:mCachedViews的默认上限是2,RecycledViewPool里每个viewType的默认上限才是5。这两个数字搞混,面试时一追问就露馅。

1.2 为什么要拆这么多层,一个池子不行吗

想理解层级设计,先得算清楚一次Item从无到有的成本账。

创建一个ViewHolder,主要开销来自三块:

  • inflate布局:解析XML、构建整棵View树,复杂item这一步可能耗费几百微秒甚至几毫秒;
  • findViewById:旧代码习惯每个子控件查一次,遍历树查找id也要时间;
  • 附加LayoutParams、初始化默认状态等零碎工作。

绑定数据(bind)则是另一笔开销,onBindViewHolder里要做图片加载、文本赋值、状态切换,很多复杂item的bind成本甚至比create还贵。

复用机制的本质,是想办法让“贵的部分”尽量少执行。所以缓存不是简单搞一个大数组,而是按照“对这个ViewHolder原本样子的还原能力”分档。Scrap和CachedViews因为牢牢绑定position,能把刚滚出去的View原样还回去,连bind都能省;RecycledViewPool只知道viewType,完全不认识position,所以拿回来必须重新bind。层级多少不是设计者拍脑袋定的,是“能省多少成本”决定的。

1.3 滚动一圈,看一段完整的回收与复用过程

拿最常见的纵向列表举例。假设屏幕能显示5个Item,从顶部往下滑:

  • Item 1滚出屏幕顶部,RecyclerView把它从子视图列表里移除,先放进mCachedViews;
  • Item 6从底部露头,布局阶段先去mCachedViews里找position 6对应的View,没找到,再去RecycledViewPool里找同viewType的ViewHolder,找到后调用bind绑定数据;
  • mCachedViews默认只能装2个,已经装了Item 1,再滚出去Item 2就会发现容量满了,于是Item 1被降级挪到RecycledViewPool,Item 2住进mCachedViews;
  • 当你把列表滑回去,Item 1要重新出现在顶部时,RecyclerView先去mCachedViews找,没找到(它已经挪去Pool了),再去Pool里按viewType取出来重新bind。

这个“先CachedViews后Pool”的顺序,解释了很多性能现象:小范围来回滑动快,因为view在CachedViews里原样保鲜;大幅长距离滑动慢一些,因为大量View从Pool重新bind。理解了这层,后面调参就有方向了。

2. Scrap与CachedViews的边界:都查position,命运完全不同

2.1 Scrap只是布局期间的临时寄存区,不是常驻缓存

Scrap是四层缓存里最容易被误解的一层。它不是“垃圾回收站”,甚至算不上缓存,我习惯叫它“布局期的临时寄存处”。

RecyclerView在重新布局时,LayoutManager先要把当前所有child从自己的视图列表里摘下来,做一个“清理现场”的动作。这个摘除不是真正移除,而是调用detachAndScrapAttachedViews一类的逻辑,把每个child临时放进mAttachedScrap。随后LayoutManager根据新的滚动位置,按顺序重新向Recycler索取每个position对应的View,此时Scrap里的ViewHolder因为刚摘下来,位置信息都还是热的,直接原样取回使用,不需要bind,连重新创建LayoutParams都省了。

这里有个所有自定义LayoutManager的人都应该踩过的坑:照抄官方demo里的detachAndScrapAttachedViews(),但在自己的layoutChunk里没有正确地从Recycler重新取回View,结果Item数量越布局越少,甚至闪退。Scrap只活在一个布局周期内部,布局结束后ViewHolder要么被重新attach回去,要么降级到CachedViews或Pool,它不是保险柜,不能指望把东西寄存在这里慢慢用。

2.2 CachedViews:滚动邻域的“保鲜柜”

CachedViews是真正意义上的短期缓存,存的ViewHolder已经彻底离开了RecyclerView的子视图树。它解决的问题非常具体:你刚把Item 3滑出屏幕,手指一停又滑回来,能不能把Item 3原封不动请回来,连bind都不调?

默认情况下,mCachedViews最多保存2个ViewHolder。它不是先进先出,也不是严格意义的LRU,而是按照“滚出屏幕的顺序”存放,滚回来的顺序正好是后进先出,所以在小范围往复滑动场景里命中率极高。

为什么默认只给2个?因为这种缓存代价高——ViewHolder和position绑定,数据不变才能原样用,数据一变就得作废。保留太多既占内存,又容易在数据刷新时产生一堆“过期”对象。但当你的场景是“快速来回滑动”时,2个明显不够。很多人第一次体验setViewCacheSize(10)或20,会发现滑动顺畅度有肉眼可见的提升,原因就在这里:更多的View滚出去后还能原路返回,省掉了Pool里拿View、重新bind整棵View树的成本。

2.3 写个小工具,观察一次真实滚动中的缓存变化

口说无凭,我建议你在自己的项目里跑一段调试代码。Recycler内部字段用反射读,生产环境不要这么干,但本地调试很香。

private void dumpCacheState(String tag, RecyclerView.Recycler recycler) { try { Field scrapField = RecyclerView.Recycler.class.getDeclaredField("mAttachedScrap"); scrapField.setAccessible(true); ArrayList scrap = (ArrayList) scrapField.get(recycler); Field cacheField = RecyclerView.Recycler.class.getDeclaredField("mCachedViews"); cacheField.setAccessible(true); ArrayList cache = (ArrayList) cacheField.get(recycler); Field poolField = RecyclerView.Recycler.class.getDeclaredField("mRecycledViewPool"); poolField.setAccessible(true); RecyclerView.RecycledViewPool pool = (RecyclerView.RecycledViewPool) poolField.get(recycler); Log.d(tag, "scrap=" + scrap.size() + " cached=" + cache.size() + " poolCount(type0)=" + pool.getRecycledViewCount(0)); } catch (Exception e) { // 仅用于调试 } }

在RecyclerView的OnScrollListener里滚动时持续打印,你会看到:滚动前scrap为0,cached可能为2;滚动的某一帧滑出去一个Item时,scrap瞬间从0变成非0,下一帧又变回0,同时cached数量发生变化;如果滚动距离较长,poolCount会开始往上走。这个瞬间变化的规律,就是后续排查“Item错位”“布局闪烁”问题时的判断依据。

3. RecycledViewPool:跨位置、跨列表复用的底池

3.1 池子按viewType分桶,position在这里没有任何意义

RecycledViewPool和前面两级最大的区别是:它完全不在乎ViewHolder之前对应哪个position,只按viewType归属。源码里每个viewType挂一个ViewHolder列表,最多挂5个。

为什么这么设计?因为进入Pool的ViewHolder已经和要求它滚出去的那个位置彻底脱钩了。比如列表第3个item滚出去时进入Pool,之后第10个item要出现时正好需要同viewType,RecyclerView取出Pool里的这个ViewHolder直接绑定第10条数据。如果Pool还傻乎乎地绑定position,遇到数据插入删除,整个索引全乱套。位置解耦,换来的是跨区域复用的灵活度。

代价也很明显:从Pool取出的View必然重新执行onBindViewHolder。也就是说Pool节省的是inflate和findViewById的钱,bind的钱一分都省不了。这也是为什么我反复强调,性能优化的重点要分清是“创建贵”还是“绑定贵”。

3.2 默认一个viewType只留5个,池满直接被丢弃

DEFAULT_MAX_SCRAP = 5这个数字是经验产物。对常规列表够用,但有几个典型场景会明显不足:

  • 一屏能显示20条的大列表,快速滑动时滚出的View远超5个;
  • 某viewType的Item数量随数据增长不定期膨胀;
  • 横向列表和纵向列表共用同一种复杂Card布局时,两边同时滚动,需求量直接翻倍。

调大单个viewType的容量很简单:

RecyclerView.RecycledViewPool pool = recyclerView.getRecycledViewPool(); // 第一个参数是viewType,不是position! pool.setMaxRecycledViews(1, 20);

第一个参数坑过不少人,有人直接把数据长度传进去,代码没崩但结果完全不是预期。你可以理解为:第一个参数告诉池子“我准备扩容哪一格抽屉”,第二个才是“这格抽屉能放几个”。

3.3 多个RecyclerView共享一个Pool,减少重复inflate的关键操作

嵌套列表和Tab页场景里,每个RecyclerView默认持有独立的Pool。两个列表展示完全相同的Item样式时,各自建树,各自复用,互不干扰但也互不帮衬。此时可以共享一个Pool。

// 让recyclerViewTwo复用recyclerViewOne的池子 RecyclerView.RecycledViewPool sharedPool = recyclerViewOne.getRecycledViewPool(); recyclerViewTwo.setRecycledViewPool(sharedPool);

共享之后,一个列表滚出去的ViewHolder可以被另一个列表直接取用。我维护过一个多Tab信息流项目,每个Tab都是同一套Card布局,共享Pool之后onCreateViewHolder的调用次数下降了一半以上,inflate的耗时几乎消失。

但这里有个隐藏风险:两个Adapter的viewType编号必须完全一致。如果Adpater A把viewType 1定义为“图片Card”,Adapter B把viewType 1定义为“文字Card”,那么共享Pool时B就会拿到A的图片Card,交给自己的bind逻辑,轻则显示错乱,重则ClassCastException。我在实际项目里排查过这种“偶发错乱”,最后定位到居然是多个列表的viewType约定不统一。共享Pool之前,先统一viewType的语义表,这步不能省。

3.4 数据刷新时,Pool为什么比CachedViews抗造

notifyDataSetChanged之后,很多人以为所有缓存都清了。实际没那么简单。

在没有开启StableIds的情况下,notifyDataSetChanged会触发RecyclerView把mCachedViews里的缓存View标记为无效或直接清理,因为CachedViews是绑定position的,position语义变了,旧View就不能原样使用。而Pool里的View本来就是“无身份对象”,只看viewType,只要新数据仍然使用相同的viewType,Pool里的ViewHolder完全可以在后续滚动中继续复用,只需要在onBindViewHolder里把新数据填进去。

所以一个直觉上的反论:“数据变了,缓存应该全清”是错的。也正因如此,如果你的业务经常局部刷新但viewType层稳定,不用过度担心Pool的命中率会暴跌,真正被打击的是CachedViews这一层。

4. 一次getViewForPosition请求,到底走了怎样的路

4.1 查找顺序表:越早命中的位置,成本越低

RecyclerView.Recycler.getViewForPosition()是整套缓存机制的心脏。它内部按顺序去四个方向找可用的ViewHolder,实在找不到才调用Adapter.createViewHolder。完整顺序如下:

查找次序查找目标命中条件命中后成本
1mAttachedScrapposition精确匹配直接复用,不bind
2mCachedViewsposition精确匹配直接复用,多数场景不bind
3mViewCacheExtension自定义实现看自身实现
4mRecycledViewPoolviewType匹配重新bind
5新建ViewHolder无条件create + bind

这个顺序不是随意排的。前两级的定位是“把原装货原样找回来”,所以命中成本极低;Pool只能提供“通用半成品”,回来还要加工;最后一级则是实在没办法才“造新的”。如果你观察线上性能,发现某种item的创建次数一直在涨,那基本可以断定它要么没进入Pool,要么Pool容量太小,根本存不住。

4.2 真正的性能瓶颈,往往在bind而不在create

我见过太多人把“复用失效”当作第一嫌疑,最后发现onCreateViewHolder调用次数早就趋近于0,卡顿的原因全在onBindViewHolder里:图片异步加载没有取消、每一次都new对象、动画反复触发重绘。

用计数器一眼就能分辨:

class CounterAdapter extends RecyclerView.Adapter<VH> { AtomicInteger createCount = new AtomicInteger(); AtomicInteger bindCount = new AtomicInteger(); @Override public VH onCreateViewHolder(ViewGroup parent, int viewType) { createCount.incrementAndGet(); return new VH(LayoutInflater.from(parent.getContext()).inflate(...)); } @Override public void onBindViewHolder(VH holder, int position) { bindCount.incrementAndGet(); // 正常绑定逻辑 } }

跑一轮滚动,如果bindCount远远大于屏幕可见Item数,说明大量View来自Pool或新建,每一次都在重复做绑定工作,优化的重心就应该放到bind方法内部,而不是继续调缓存参数。如果createCount增长很快,才轮到考虑“缓存没命中”和“inflate太贵”的问题。

4.3 常见异常与缓存状态的对应关系

线上现象缓存层的可能原因常用解法
部分Item显示旧数据复用了缓存ViewHolder,但onBindViewHolder没有对所有字段重新赋值,或StableIds开启后把旧View原样返回每个字段强制赋默认值;开启StableIds前先确保“全量赋值”
列表闪退,日志指向ClassCastException共享Pool后两个Adapter的viewType定义不一致统一各列表的viewType语义
自定义LayoutManager里Item越布局越少Scrap处理不当,detach后没有正确取回检查layoutChunk中是否完整执行getViewForPosition逻辑
快速滑动偶发item闪烁CachedViews命中时数据和position匹配,但bind里有残留状态重置子View的visibility、背景、透明度等状态

这些现象里,“显示旧数据”最值得多说一句。根子往往不在RecyclerView,而是复用后的ViewHolder里某些子View在上一轮绑定时被改了状态,这一轮没被改回来。很多人只知道“要复用”,忘了“复用的前提是bind必须负责把状态重置干净”。

4.4 Scrap匹配的特殊模式:position可以“偏移”

RecyclerView源码里存在一个比较隐蔽的机制:Scrap和CachedViews在匹配position时,支持一种允许position偏移的“宽容模式”。简单说,在没有开启predictive animation时,position匹配是严格的;一旦开启了预测动画,RecyclerView会尝试用相邻position的缓存View去填补当前请求,以便动画过程能平滑过渡。

在绝大多数项目里,默认不开启这个能力,所以不用太深入。但自定义LayoutManager的人要留个心眼:如果在supportsPredictiveItemAnimations()里返回true,却没有正确处理Scrap的缓存,动画过程中就可能出现Item布局串位。遇到类似“预览帧错位”的诡异问题,优先检查这里。

5. 实战调优:命中率观测、参数调整与最容易犯的错

5.1 先量化,再调参:用计数器代替“感觉变快了”

任何性能优化,最忌讳凭感觉调参。我建议先写一个统计工具,把onCreateViewHolder和onBindViewHolder的调用次数打出来,再配合系统自带的Systrace看掉帧情况。

有了计数器之后,你会发现自己对列表的认知清晰很多:如果createCount基本不涨,说明缓存复用已经生效,剩下的问题是bind成本;如果createCount一直涨,说明你的Item进入Pool后一直被取用又一直被创建,大概率是Pool容量太小或者viewType设计有问题。这个数据,比团队里“我改了感觉流畅多了”靠谱得多。

5.2 参数怎么调,CachedViews和Pool分开看

调整项默认哪些场景建议调大操作
mCachedViews容量2快速小范围往复滑动recyclerView.setViewCacheSize(10)
Pool单viewType容量5可显示大量同type Item的信息流pool.setMaxRecycledViews(type, 20)
ViewCacheExtension无需要自定义缓存策略recyclerView.setViewCacheExtension(...)

调大缓存不是免费的。CachedViews和Pool里存活的每个ViewHolder都持有整棵View树的对象引用,复杂Item占用的内存不容小觑。一个item里放几张高清图,缓存调大后内存占用立刻看涨。我的建议是:先用计数器定位“创建成本远高于bind成本”的viewType,再单独调大那个type的Pool容量,不要全局无脑拉高。

5.3 hasStableIds是缓存机制的好朋友,但也是双刃剑

开启hasStableIds(true)并正确实现getItemId(),会让RecyclerView在数据插入、删除、重排时尝试用逻辑ID去匹配缓存,而不是靠position“猜”。比如列表头部插入一条数据,原第3条的position变成第4条,没有StableIds时旧缓存纷纷失效或需要重新bind;有了StableIds,RecyclerView有机会把原本对应第3条的View按ID找回来直接用,复用效率大幅提升。

代价是,StableIds开启后RecyclerView会更信任缓存,bind方法被调用的次数可能变少。如果你的onBindViewHolder不是“全量赋值”,而是“只设置变化字段”,那某些复用场景下残留的旧状态就会暴露出来。所以开启StableIds之前,先把自己的onBindViewHolder改造成每个位置都重新赋一遍核心属性,否则缓存省下来的时间会变成查错的时间。

5.4 面试或评审时,如何把缓存机制讲出层次感

如果是技术评审或者面试被问到RecyclerView回收机制,别只丢出“四级缓存”四个字。更有信息量的回答应该是:

  • 先说结构:Scrap是布局期临时寄存,CachedViews是小范围滚动保鲜,Pool是按viewType解耦的通用复用池;
  • 再讲流程:一次滚动中item被移除时如何从CachedViews降级到Pool,以及getViewForPosition的完整查找顺序;
  • 最后补工程手段:setViewCacheSize、共享Pool、hasStableIds分别解决什么问题,各自要注意什么限制。

这样的回答既覆盖源码原理,又有实际排查思路,不会像背诵文档。当然,前提是你在真实项目里真的动手看过那几段日志,验证过缓存数量变化。速查手册能帮你节省回忆时间,但替代不了你自己在Debugger和Logcat里对照着看一遍的体验。

我自己排查RecyclerView缓存问题时最常用的一招,就是开启动态日志打印缓存数量变化,再配合onCreateViewHolder的计数器,基本能定位九成以上的性能问题。这套方法论,比死记硬背任何缓存源码都实用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询