做Android列表优化绕不开的一个硬核话题,就是RecyclerView的缓存机制。很多人都在用RecyclerView,也听说过ViewHolder复用,但一旦被问到“RecyclerView到底有几层缓存?”“为什么同一个item滑回来还会执行onBindViewHolder?”“为什么数据一多滑动就开始掉帧?”往往就卡住了。这篇文章我打算把这几年调列表性能、啃源码、线上排障的经验一次性说清楚,不贴大段源码,只讲关键路径、取舍逻辑和可以直接抄的实践结论。适合看的人:正在做列表性能优化的开发、想系统梳理RecyclerView缓存机制的进阶者、面试前需要把这一块理出头绪的候选人。
1. 先把缓存机制要解决的事情说清楚
1.1 列表卡顿的根源,以及缓存机制到底在省什么
搞懂缓存机制之前,先想明白一个问题:列表滑动为什么容易卡?传统做法里,一个item从“不可见”变成“可见”,系统要做的事包括解析XML、创建View层级、执行measure和layout,最后还要draw。对于复杂一点的item,光是一次完整的View创建开销就可能顶得上好多次数据绑定,如果这项操作在滚动过程中高频发生,主线程瞬间就会被拖垮。
RecyclerView的缓存机制,本质上只做一件事:把“创建View”的次数压到最低,把“绑定数据”变成常态。你可以把它理解成餐厅翻台——不换桌子,只换桌布。桌子是昂贵的资产,桌布是廉价的耗材。对应到列表里,View就是那张桌子,尽量反复用;数据才是桌布,每次换掉就行。
这里还要多说一句,为什么缓存对象是ViewHolder而不是View?因为ViewHolder里保存了item里所有子View的引用,复用时不需要再去findViewById。更关键的是,ViewHolder本身是RecyclerView用来管理子View状态的最小单元,position、viewType、bound状态等信息都被挂在它上面,没有这层封装,光缓存View根本无法高效匹配到正确的数据位置。
1.2 别把缓存机制当成“一个池子”:四层结构先看一眼
很多新手对缓存机制的理解就是“有个池子,回收的ViewHolder扔进去,要用了再拿出来”。这个理解方向是对的,但RecyclerView的实际设计要细得多。它不是一个大池子,而是由四个不同角色组成的组合式缓存链路,每一层都有自己的职责、命中条件和生命周期。
这四层从快到慢分别是:
| 层级 | 名称 | 用途 | 命中后要不要重新bind |
|---|---|---|---|
| 第1层 | mAttachedScrap | 屏幕内局部刷新时临时移除的ViewHolder | 不需要 |
| 第2层 | mCachedViews | 刚滑出屏幕、仍与position/id匹配的ViewHolder | 不需要 |
| 第3层 | mViewCacheExtension | 开发者自定义的扩展缓存 | 由实现方决定 |
| 第4层 | mRecyclerPool | 按viewType分类的通用复用池 | 需要重新bind |
为什么不是一层搞定?因为不同的复用场景对代价的容忍度不一样。屏幕内刷新,要求的是毫秒级恢复原状,不能重建;刚滑出屏幕的item,下次滚动很可能马上滑回来,可以零成本直接用;但一旦进入真正的大池子,position关系就断了,必须重新绑定。把不同热度的缓存分层管理,才能兼顾速度和内存。
2. 四级缓存体系逐层拆解
2.1 mAttachedScrap:屏幕内局部刷新的“临时停车位”
我先讲最容易被忽略的这层。mAttachedScrap的直译是“附着的废弃表项”,听名字很矛盾,但它的作用非常关键:当RecyclerView执行局部刷新时,比如调用了notifyItemChanged、notifyItemInserted、notifyItemRemoved,LayoutManager需要重新摆放所有item。在重新布局期间,那些当前还在屏幕上的item需要被“临时摘下来”,但又不能销毁或真正回收,因为它们马上要被放回新位置。
这个时候,RecyclerView会调用detachAndScrapAttachedView方法,把ViewHolder放进mAttachedScrap。注意这里的detach和remove是完全不同的两件事:remove是把View从RecyclerView中真正移除,detach是临时分离,View仍然认为自己是RecyclerView的子View,只是挂起等待重新attach。这就像你临时把货架上的商品拿下来换个位置,而不是直接扔掉再重新进货。
mAttachedScrap里的ViewHolder有个重要特性:它按照position或id维护着原本的索引,局部刷新时只要数据没大动,LayoutManager重新布局时会优先从scrap里把它找回来直接复用,不需要创建View,也不需要重新bind。这也是为什么局部刷新不会出现整屏闪烁的根本原因。
我特别想提醒一点:scrap层只在layout过程中的短暂窗口内存在,它是整个缓存体系里生命周期最短的一层。如果在这个阶段没有正确处理,比如过度自定义LayoutManager时没有调用detachAndScrapAttachedView而是直接removeView,就很容易引发item错乱、闪屏或者崩溃,这类问题排查起来相当隐蔽。
还有一个几乎没人提的细节:mAttachedScrap实际上分两个集合,一个是mAttachedScrap,一个是mChangedScrap。后者专门配合notifyItemRangeChanged和payload使用,用于在局部数据变更时保留旧ViewHolder的引用和状态,方便做增量更新。这也是为什么很多高性能列表能做到“只更新变化的文字,不整行重绘”的原因之一。
2.2 mCachedViews:滚动时命中率最高的“热缓存”
第二层是广大开发者最熟悉的mCachedViews。当一个item完全滚出屏幕,RecyclerView不会立刻把它扔进大池子,而是先放进mCachedViews这个“热缓存”里,默认容量是2。
很多新人会问:只有2个?是不是太小了?实际上这是经过考量的。mCachedViews的设计目标是服务于“马上要滑回来”的场景,比如用户上滑之后立刻下滑,这两个最新的item能被原样取出,连bind都不需要执行。容量定在2,是因为在大多数滚动场景里,从滑出视口到重新滑回视口的间距往往不会再覆盖同一个位置,一个很小的热缓存足够覆盖绝大部分回扫场景,同时避免占用过多内存。
mCachedViews在匹配时非常严格,它会检查ViewHolder的position或者stableId是否与当前要显示的位置一致,同时viewType也必须完全匹配。只有完全一致,才能不清空数据直接复用。任何一项不满足,这个ViewHolder就不能从这层取走。
默认值可以通过setViewCacheSize方法调整。我个人的经验是,如果你做的是Feeds流、商品流这类大量“快速上滑再回看”的业务,把缓存容量调到5到10往往能明显减少bind次数;但如果你做的是一屏一页的长列表,保持默认反而更稳妥,因为缓存多了也大概率命不中,纯粹浪费内存。
这里还有个关键认知:mCachedViews是会被数据刷新“打散”的。一旦调用了notifyDataSetChanged,所有ViewHolder都会被标记为无效,这层缓存基本就废了。原因很简单,你告诉Adapter“所有数据都变了”,RecyclerView自然不再信任任何旧position映射。
2.3 mViewCacheExtension:官方留出的自定义扩展口
第三层mViewCacheExtension几乎是被开发者遗忘的一层,但它其实是官方给自定义缓存策略留的扩展点。这个扩展层的位置在源码调用链里处于mCachedViews之后、mRecyclerPool之前,前面两层都没命中时,RecyclerView会先来问你的扩展层有没有现成的View可以复用。
整个类只有一个核心方法,大概逻辑就是:外部实现一个ViewCacheExtension,重写获取View的方法,传入position和viewType,返回一个View或者null。返回null代表“我这层没有命中”,RecyclerView继续向下查RecyclerPool;返回非空则直接把这个View包装成ViewHolder使用。
什么场景会用到它?说实话,绝大多数业务项目一辈子都用不上。真正值得考虑这个扩展层的,是那些item构建成本极高、又需要跨页面预热的场景,比如大型图片墙、播放器封面墙、复杂富文本卡片。你可以提前在一个后台线程里把常见项构建好,放进自定义缓存里,滚动时直接从内存里取现成的View。
但在使用前请务必想清楚,这层扩展一旦实现,你得自己管理View的生命周期、内存上限、线程切换和数据绑定状态。RecyclerView不会帮你兜底。对于普通列表,我个人建议是慎用甚至不用,默认链路已经足够高效,盲目自定义只会引入更多不可控的边界问题。
2.4 mRecyclerPool:真正跨场景的通用复用池
最后一层,也是大家最熟悉的RecycledViewPool。它和mCachedViews最大的区别在于:池子里的ViewHolder不再和position绑定,只按viewType分类存放。也就是说,从池子里取出来的ViewHolder,没有“它还应该属于哪个位置”这个概念,必须重新执行onBindViewHolder才能使用。
RecycledViewPool内部的结构是“viewType -> 栈”的映射,每个viewType默认最多缓存5个ViewHolder,可以通过setMaxRecycledViews方法修改。为什么默认是5?因为正常滚动过程中,一屏里同时滚出去的相同类型item数量不会特别多,5个足够给快速滚动提供缓冲。如果缓存的个数超过上限,最旧的ViewHolder会被真正丢弃,最终等待GC回收。
这里要特别强调一个坑:池子里缓存的ViewHolder在取出后,旧数据是完全保留的。如果你的item里有图片、选中态、开关状态等,而bind方法里没有对这些字段做重置,复用后就会出现内容错位。这几乎是列表开发里最高发的bug,后面我会专门讲排查方法。
RecycledViewPool还有一个高级用法:它可以在多个RecyclerView之间共享。你只需要在多个列表里设置同一个pool实例,比如同一个页面里一个横向列表嵌套一个纵向列表,或者两个Tab各自用独立的RecyclerView,都可以共用同一个池子。这样不同类型列表里相同viewType的item就能互相复用,减少整体View创建次数。但前提是这两个列表的数据结构、viewType定义必须完全一致,否则轻则复用错乱,重则ClassCastException。
3. 一次滚动背后,缓存机制是如何运转的
3.1 回收旧Item:从视口滑出去后去哪
理解了四层缓存,接下来把时间线拉长,看看一次普通滚动中,缓存机制到底是怎么串联起来的。
当一个item滑出视口时,LayoutManager会调用removeAndRecycleView等回收逻辑。RecyclerView回收这个ViewHolder时,会先尝试塞进mCachedViews。塞入前要检查两个条件:容器的当前大小有没有达标,以及这个ViewHolder的信息是否与缓存策略兼容。如果mCachedViews已经满了,系统会把这个新收到的ViewHolder先放进RecyclerPool,然后让mCachedViews里最老的那个缓存也进入Pin块、最终转移到RecyclerPool。也就是说,mCachedViews永远保存着最近滑出的几个item,而早期滑出的item则一路下沉到大池子。
有些人会问,为什么不直接全部进pool?原因很简单:pool里取出来的item需要重新bind,而mCachedViews里取出来的不需要。如果一个item滑出屏幕后几百毫秒又滑回来了,从mCachedViews直接拿出来可以省掉一整轮数据绑定,体感上就是滚动跟手、不闪、不掉帧。这种“热替换”正是缓存分层最重要的价值。
3.2 取用新Item:从触发布局到拿到ViewHolder的完整调用链
当滚动需要显示新item时,LayoutManager会向Recycler请求一个ViewHolder,核心入口是getViewForPosition。之后Recycler会按固定顺序一轮轮找:
第一步查mAttachedScrap,因为layout过程中可能还有未被重新使用的临时item;第二步查mCachedViews,用position或stableId精确匹配;第三步查mViewCacheExtension,看你有没有自定义缓存;第四步从mRecyclerPool按viewType获取;最后一步才是真正调用onCreateViewHolder创建新View。
这个顺序本质上是“按复用成本从低到高排列”。成本最低的是scrap和cachedViews里的ViewHolder,直接拿来就能用;成本稍高的是pool里的ViewHolder,需要重现bind;成本最高的就是创建新View,不仅要做inflate,还要走一次bind,一般发生在列表第一次加载或者滚出屏幕太久的item重新进入视口时。
一个很容易被忽略的细节是,在正式走到onCreateViewHolder之前,哪怕最终要新建,RecyclerView也会先计算viewType,再按viewType去pool里匹配一次。所以如果你的adapter里getItemViewType写得有问题,比如同一个位置返回了不同type,那么不仅无法复用,还会导致列表item显示错乱甚至崩溃。这个方法的返回值必须稳定,最好是纯粹由数据结构决定,不要依赖外部可变状态。
3.3 命中缓存和没命中缓存,性能差在哪儿
“命中缓存”也分三六九等。为了说清楚性能差异,我直接列一张代价对比表:
| 场景 | 是否创建View | 是否执行bind | 说明 |
|---|---|---|---|
| 命中mAttachedScrap | 否 | 否 | 局部刷新核心方案,零成本恢复 |
| 命中mCachedViews | 否 | 否 | 滚出屏幕又立刻滚回来时的高性价比路径 |
| 命中mRecyclerPool | 否 | 是 | 数据要重新绑一遍,但比创建View便宜得多 |
| 命中mViewCacheExtension | 取决于实现 | 视实现而定 | 高风险高收益,谨慎使用 |
| 全部未命中 | 是 | 是 | 最贵路径,一次完整创建加绑定 |
从这个表格能看明白一个结论:列表性能优化的核心目标,就是把尽可能多的item访问路径维持在“不需要bind”的前两层。如果一段时间内onBindViewHolder被反复调用,先别急着怀疑缓存机制有问题,大概率是命中层次太深,或者复用条件已经失效。
判断方式也不复杂:统计onCreateViewHolder调用次数。如果这个数字远大于当前屏幕内可见的item数量,说明你的列表有大量无谓创建,命中的几乎都是最下层甚至未命中;如果onCreateViewHolder次数很少,但onBindViewHolder次数很多,说明滚动场景中从pool里复用的占比太高,或者在反复触发数据刷新。
4. 源码关键节点里的几个“反直觉”细节
4.1 tryGetViewHolderForPositionByDeadline:预取机制藏在这里
真正读过RecyclerView源码的人,应该对tryGetViewHolderForPositionByDeadline这个方法不陌生。几乎所有取ViewHolder的逻辑最终都会收敛到这个方法里,方法名的后半段“ByDeadline”藏着RecyclerView一个重要的优化能力:预取。
RecyclerView内部会通过Choreographer监听每一帧的剩余时间,如果这一帧在主线程上很快就干完了,还有空闲时间,它就会利用这段时间提前去准备下一个可能要显示的item。这个“提前准备”的动作就叫prefetch,而deadline就是给这次准备划定的最后时限。如果在这个时限内完成了ViewHolder的获取和绑定,那这个item就被提前放进了缓存;当真正需要显示时,直接取出来就能用,滚动自然就顺滑了。
这个设计意味着一个反直觉的事实:bind操作未必都发生在item上屏的那一刻,有些bind会被提前执行。我见过不少开发者在统计性能时,把onBindViewHolder调用次数当作卡顿指标,结果数字很高,但实际滚动很流畅。原因就是预取机制在提前绑定。所以统计时最好把bind耗时的口径和主线程卡顿点结合起来看,不要单独拿bind次数判定问题。
4.2 绑定流程中的“白绑定”,以及它对统计的影响
prefetch还会带来另一个现象,我管它叫“白绑定”。在tryGetViewHolderForPositionByDeadline的执行链路上,如果预取时时间已经不充裕了,RecyclerView会拿到一个ViewHolder但选择不立刻绑定,而是先把它放进缓存里,等到真正要被滑入视口时再补上绑定。
这么做的好处是,避开了在滚动繁忙期挤占主线程资源。副作用就是,如果你在业务代码里通过日志或打点统计bind次数,你会看到有些ViewHolder被bind了不止一次,并且其中一次绑定的结果可能根本没被用户看到。这不是bug,而是预取机制和时间片博弈的自然结果。
理解了这一点之后,在排查“为什么onBindViewHolder执行次数比预想多”的问题时,你就有了一个额外的排查方向:先区分是预取提前执行,还是真的发生了无谓的反复绑定。很多线上性能优化团队会把bind日志打上“来自预取”的标记,就是为了区分这两类情况。
4.3 回收时别忽略的几个状态细节
说完取用,再补一个回收方向的细节。ViewHolder在回收时会经历状态的检查,比如它当前有没有被动画持有,有没有被其他逻辑临时占用。如果存在非空闲状态,RecyclerView不会把它放到任何缓存层,而是标记为等待下次回收或者直接销毁。这就是为什么有时候你滑动很快、item闪退没有来得及进入缓存,之后滚动反而要重新创建View。
在自定义LayoutManager或者做复杂列表时,我建议主动监听回收状态。Recycler提供了一个setRecyclerListener,可以拿到ViewHolder被回收时的回调。这个回调可以用来做资源清理,比如取消图片加载、关闭播放器、释放大对象引用。但这里有个重点:回调发生时,ViewHolder正处于被回收的边缘,之后可能进入缓存也可能被销毁。如果在这个回调里直接重置了item的所有UI状态,而它下一秒又被mCachedViews命中取出,那一场bind还是免不了。所以这层清理逻辑要精细控制,该清的数据清理,不该动的UI状态别动。
5. 常见问题与排查技巧实录
5.1 为什么notifyDataSetChanged之后列表又卡又闪
这是列表开发里最高频的问题。每当你调用了notifyDataSetChanged,所有ViewHolder都会被打上“数据已失效”的标记,mCachedViews里那2个热缓存基本白存了,滚动时只能用RecyclerPool甚至新建View。如果数据量比较大,还会触发全量重新布局,加上item动画的参与,整个列表很容易出现闪烁、跳动、快速滑动时白屏。
常规解法有两个方向。第一个是用DiffUtil或ListAdapter做增量更新,它会精确计算出哪些item新增、删除、移动、修改,然后只通知对应位置的局部变化。第二个是在局部更新里合理使用payload,比如一个点赞按钮状态变化,完全可以通过payload只更新按钮区域,而不刷新整个item。第一种方案解决“无谓失效”的问题,第二种方案解决“无效重绘”的问题,结合使用体验最好。
我特别想说明的是,DiffUtil也不是万能药。它的diff计算本身有成本,列表超大、数据项又频繁变化时,diff本身也可能成为主线程负担。这种情况下可以考虑把diff移到后台线程,或者缩小diff范围,也可以考虑在adapter层做更细粒度的定点更新。
5.2 复用的Item出现内容错乱、串数据怎么办
这个问题几乎每个做过列表的人都会遇到。现象是:快速滚动时,某些item的图片、文字和其他item串了;再滚一下又恢复正常;偶尔还伴随闪图。根因基本都在“ViewHolder被复用后,旧数据没有清干净”。
最常见的场景是异步图片加载。比如一个item滚出屏幕后被回收进pool,但它的图片加载任务还在异步进行,等加载完成后回调拿到的是旧URL,结果直接设置到了被复用的ImageView上,把新item的图片覆盖成了旧item的。这类问题不解决,缓存复用越多,错乱越严重。
标准解法是三层防线:第一,bind方法里对所有可能残留状态的控件做重置,比如先设置默认占位图、清空文本、重置选中态;第二,为每个item的加载请求加上位置或URL匹配判断,回调时对照当前ViewHolder绑定的是不是同一条数据,不是就放弃显示;第三,图片加载库尽量使用按View生命周期管理的版本,让请求跟着ViewHolder走,销毁或回收时自动取消。实际上,做信息流比较久的人都会有一个共识:bind方法里必须无脑清状态,不要赌上一个ViewHolder是干净的。
5.3 缓存命中率怎么统计,命中率低了怎么查
如果你不确定自己的列表复用是否健康,最好的办法就是先打点。在adapter里给onCreateViewHolder和onBindViewHolder分别加一个计数器,然后在列表快速滑动5到10秒后打出来。正常情况下,onCreateViewHolder的次数应该接近“首次填充一屏”需要的数量加上少量越界滑动需要的补充,如果它持续快速增长,基本可以断定缓存没有发挥作用。
命中率低通常逃不出这几个原因。第一个是getItemViewType不稳定,同一种数据在不同时候返回了不同type,导致viewType错配,复用池永远撞不上。第二个是position匹配太苛刻,比如你在item里显示绝对position相关的信息,同时又频繁使用notifyDataSetChanged,导致mCachedViews的命中条件被破坏。第三个是viewType种类过多,每种类型的viewType在pool里只有5个名额,列表如果有10种itemType,池子很容易被稀释,导致每一种都缓存不够。解决办法很简单,优先减少不必要的viewType种类,相同布局的item统一type,而不是按数据来源去细分。
下面整理一个速查表,方便你在线上排查时对着找:
| 现象 | 可能原因 | 建议解法 |
|---|---|---|
| 快速滚动大量新建View | notifyDataSetChanged频繁 | 改DiffUtil/局部刷新 |
| 仅回滚时卡 | mCachedViews命中低 | 调大setViewCacheSize |
| item内容串数据 | bind未清状态/异步回填 | bind内重置,加载回调校验 |
| ClassCastException | 共享pool时viewType定义不一致 | 统一viewType设计 |
| 首屏渲染慢 | item本身构建太重 | 检查布局层级,做异步预取 |
| 偶尔白屏闪烁 | item动画未正确收敛 | 检查adapter动画配置 |
6. 场景化调优:缓存参数该调就调
6.1 什么时候调大mCachedViews容量
如果你在做一个可上下滑动的Feeds流,用户经常快速上滑之后又回头往下看,这时候默认2个的mCachedViews明显不够用。调大它,回扫时部分item可以跳过bind,体感会更跟手。我通常的做法是先用打点数据说话:如果发现快速滑动时onCreateViewHolder和onBindViewHolder的调用次数明显偏高,再把setViewCacheSize从默认值逐步调到5、8、10,通过A/B观察卡顿率。
但这里也要泼一盆冷水:mCachedViews里的缓存ViewHolder是“热数据”,比pool里的更占内存,因为它保留了完整的View树和用户看得到的状态。如果你列表item本身就很重,比如带视频surface、大图、复杂布局,盲目调大这个容量,内存压力会明显上升。平衡点一般是看PSS内存增量和帧率改善是否划算,不要无脑追求“越大越好”。
6.2 共享RecycledViewPool的正确打开方式
共享RecycledViewPool是我在实际项目里用得比较多的优化手段。比如首页有一个商品双列瀑布流,同时还有一个商品详情页里的“相关推荐”横向滑动列表,两者要是都能复用同一套商品卡片,整体View创建量能少一大截。
操作上很简单:创建一个RecycledViewPool实例传给多个RecyclerView,同时保证所有RecyclerView里相同viewType对应的是完全相同的item布局和数据语义。这里最容易出问题的点在于,不同页面里viewType的编号必须一致,否则同一个数字在不同页面代表不同类型,复用池里拿出来的ViewHolder自然对不上。
使用共享pool还有一个副作用你要有心理准备:一个列表里如果ViewHolder被复用到了另一个列表,之前列表里残留的滚动位置、动画状态、选中效果都可能被带过来。所以共享pool的列表,bind里的状态重置要求更高,建议在复用前统一走一遍清晰的初始化流程,不要依赖默认行为。
6.3 自定义缓存扩展:绝大多数项目用不上,但要知道
关于mViewCacheExtension,前面已经讲过它的定位和用法,这里补充一下我的判断标准。真正需要自定义缓存的场景,几乎只有一种:item构建成本极高,同时列表滚动对首帧和跟手性要求极其苛刻,比如大型直播礼物墙、一屏多图的内容卡片。
如果只是普通列表,别碰这层。原因有两个:一是RecyclerView自带的预取和四级缓存已经足够优化,绝大多数卡顿问题出在业务数据绑定和布局本身,不在缓存深度;二是这层扩展一旦写错,问题往往非常隐蔽,轻则缓存命中失效,重则把无法复用的View错配给错误位置,线上排查成本极高。
如果你真决定做,我建议先想清楚想要缓存什么状态:是缓存原本的View树,还是缓存一个“已构建完成但未绑定数据”的空白容器,还是缓存绑定完数据后的完整结果。不同设计对应完全不同的实现复杂度,千万不要一上来就打算把整个item的最终状态塞进缓存,那会把内存和状态同步问题一起引到脑门上。
最后再分享一点我个人的实操体会。很多人以为RecyclerView缓存机制是“开启即获收益”,实际上,它的默认配置是为通用场景设计的,真正吃透它之后你会意识到,性能调优不是调某个参数,而是顺着“创建View、绑定数据、缓存复用”这条链路一层层找瓶颈。把打点工具做起来,把每层缓存的命中情况量化出来,比背多少源码都管用。如果让我只留一条经验,那就是:列表卡顿的时候,永远先回答三个问题——onCreateViewHolder被调了几次?onBindViewHolder被调了几次?这两类调用分别出现在什么场景?回答完这三个问题,至少一半的性能问题已经浮出水面了。