1. 整体架构:四层缓存速查表,先把地图装进脑子
1.1 一张表看清RecyclerView回收后的所有去处
很多文章喜欢从“为什么要用RecyclerView”开始铺垫,我直接跳过去,先上结论。一个Item滚出屏幕之后,它的ViewHolder在RecyclerView内部会按顺序进入下面四种位置之一,这也是所有人说的“四级缓存”。
| 缓存位置 | 内部结构 | 是否绑定position | 命中后是否重新bind | 默认容量 |
|---|---|---|---|---|
| mAttachedScrap | ArrayList<ViewHolder> | 是 | 不bind,原样再用 | 等于当前attach的child数 |
| mCachedViews | ArrayList<ViewHolder> | 是 | 大多数场景下不bind | 2(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。完整顺序如下:
| 查找次序 | 查找目标 | 命中条件 | 命中后成本 |
|---|---|---|---|
| 1 | mAttachedScrap | position精确匹配 | 直接复用,不bind |
| 2 | mCachedViews | position精确匹配 | 直接复用,多数场景不bind |
| 3 | mViewCacheExtension | 自定义实现 | 看自身实现 |
| 4 | mRecycledViewPool | viewType匹配 | 重新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的计数器,基本能定位九成以上的性能问题。这套方法论,比死记硬背任何缓存源码都实用。