
做Android开发这些年RecyclerView的复用与缓存机制是我见过被误解最多、也最值得彻底搞明白的一套系统。面试必问线上列表一旦卡顿又必背锅。网上讲这套机制的文章不少但大多数要么只画了几张抽象流程图要么只贴了源码注释真正能把三级缓存、四级缓存甚至五级缓存的来龙去脉讲清楚、并且能指导实际调优的内容其实很少。这篇文章就按“速查手册”的定位来写不打算逐行分析源码而是把Scrap、CachedViews、RecycledViewPool这几个核心层级的工作方式、触发时机、复用条件一次讲透顺带讲清楚预取机制和常见线上问题排查思路。无论你是刚接触RecyclerView不久、被嵌套卡顿折磨过的新手还是准备面试需要系统性梳理的进阶开发者这篇都值得花二十分钟完整读一遍。读完你至少能回答这几个问题为什么有时候onBindViewHolder不被调用为什么remove掉一个item后再次添加item的内容是旧的为什么嵌套RecyclerView滑动起来掉帧以及setItemViewCacheSize到底该设多少。1. 先把缓存体系在脑子里建个三维模型很多人对RecyclerView缓存的认知停留在“不就是一个Pool嘛”这是最大的误区。RecyclerView的复用体系其实由五层结构共同协作每层都有各自的职责、容量限制和适用场景。只有先搞清楚每一层各自负责什么后续排查卡顿和错乱问题时才有清晰的方向。1.1 五级缓存的名称与各自职责速查我先把这五层列出来方便对照后面每一节的详细展开缓存层级所属对象默认容量命中后是否需要重新bind触发场景AttachedScrapRecyclerView内部无固定上限不需要布局过程中被临时detach的可见itemChangedScrapRecyclerView内部无固定上限视payload情况而定notifyItemChanged / 部分增删操作CachedViewsRecyclerView内部2不需要position与type匹配时滑动移出屏幕、被remove但未真正回收ViewCacheExtension开发自定义无由开发者自行决定几乎没人用属于扩展点RecycledViewPool可跨RecyclerView共享每个ViewType默认5个需要重新bindCachedViews溢出、removeAndRecycleView等简单记忆Scrap是给“正在布局”用的临时隔离区CachedViews是给“刚移出屏幕”的view留的快速通道Pool是最终兜底的公共仓库。1.2 缓存的本质避免inflate降低bind开销先说透一个核心概念RecyclerView缓存机制的终极目标是尽量减少onCreateViewHolder的调用次数。为什么因为onCreateViewHolder里通常要做LayoutInflater.inflate()而XML布局的inflate是一个涉及IO读取、反射创建View树、解析布局属性的过程非常昂贵。相比之下onBindViewHolder只是给已有的View填充数据成本要低得多。所以整个复用体系的性能逻辑是能复用View就绝不重新创建数据变了才重新绑定数据没变连绑定都省了。搞懂这个逻辑你就能理解为什么CachedViews命中时onBindViewHolder不被调用——因为那个View压根没被回收数据还挂在上面直接拿回来用就行。1.3 字符串类比把缓存理解成“小件寄存柜”用一个生活场景类比你在健身房有个会员柜每次运动完把东西放进去下次来了直接取用这就是CachedViews速度快但柜子少只有两个。柜子满了之后多出来的东西统一存到前台大仓库这就是RecycledViewPool容量大但每次存取都要走登记流程重新bind。AttachedScrap你可以理解为你正在换衣服时手里暂时拎着的东西——不是真存起来只是腾一下手马上还得穿。这样记忆起来就很直观Scrap临时腾手Cache快速存取Pool最终仓库。2. Scrap层级布局过程中的“临时隔离区”Scrap这两层很容易被忽略因为日常开发中你几乎不会直接接触它们但理解它们是理解notifyDataSetChanged为什么慢、以及为什么部分刷新性能更好的前提。2.1 AttachedScrap布局期的“暂存栈”AttachedScrap存放的是那些“在layout过程中被detach掉的ViewHolder”。触发场景很典型调用了notifyDataSetChanged()后RecyclerView对可见区域内的所有item执行重新布局LayoutManager需要先把当前所有的item detach下来重新计算位置再逐个放回去。注意这里的detach和真正的remove不一样。detach只是把View从RecyclerView的层级中临时摘除但没有进Pool也没有销毁。这些被摘下来的ViewHolder会先放到AttachedScrap里等LayoutManager重新布局时通过position精确匹配取回来。这也是为什么notifyDataSetChanged()之后屏幕上可见item并不会重新创建View——它们只是走了“detach - scrap - 重新attach”这段路。从开发者的反馈来看很多人以为notifyDataSetChanged()会导致所有item重新onCreateViewHolder其实不是。真正发生的是可见item全部走一遍detach和reattach不可见item直接回收进Pool。绑定数据的操作依然会执行但创建View的操作很大程度上被避免了。2.2 ChangedScrap局部刷新的专用通道ChangedScrap是专门为局部更新准备的。当你调用notifyItemChanged(position)时变化的那一个item会被标记并且从正常流程中单独隔离到ChangedScrap里而不是和其他可见item一起走AttachedScrap。这里有个关键点ChangedScrap里的ViewHolder在重新绑定数据时可以把payload一起带过去。**payload是什么**简单说就是你想做局部刷新时携带的额外信息。配合onBindViewHolder(holder, position, payloads)重载方法你可以实现“只更新文字不改图片”、“只改背景不重建布局”之类的精细操作从而避免整个item被重新绑定。这也是DiffUtil在底层所依赖的机制之一。2.3 实操心得怎么验证Scrap的存在你可以自己写个Demo验证在Adapter的onCreateViewHolder和onBindViewHolder里各打一条日志然后调用notifyDataSetChanged()观察日志输出。实测下来你会发现调用notifyDataSetChanged()后屏幕上的item几乎不会再次触发onCreateViewHolder但onBindViewHolder会被全部触发一遍。如果你把notifyDataSetChanged()换成notifyItemChanged(position, payload)并且正确实现了带payload的绑定方法那么只有指定item会被重新绑定其他item的bind日志不会出现。这就是Scrap体系配合局部刷新的实际表现。3. CachedViews与RecycledViewPool快速通道与公共仓库这两层才是日常调优的主战场。两者的核心差异在于命中后要不要重新bind这个差异直接影响列表滑动时的帧率表现。3.1 CachedViews滑出屏幕后的“后悔药”当item滑动移出屏幕时RecyclerView并不会立刻把它扔进Pool而是先把它放进CachedViews。CachedViews默认容量是2也就是说最多能暂存两个移出屏幕的ViewHolder。CachedViews的匹配规则非常严格必须是同一个position严格来说是ViewHolder的mPosition并且ViewType相同。当一个item滑出去又立刻滑回来时如果CachedViews里正好存着这个position的holderRecyclerView会直接把它拿出来用不会调用onBindViewHolder。这带来两个直接收益避免了再次inflate速度快避免了重新绑定连数据赋值都省了。所以那些“快速上下滑动列表”的场景CachedViews的命中率决定了你是否会看到空白item闪烁、图片重新加载的卡顿感。把setItemViewCacheSize调大一些比如4~8对快速回滑场景的流畅度提升非常明显。坑点提醒CachedViews里的ViewHolder并没有被回收所以如果它持有比较大的资源比如高分辨率图片、VideoView等调得太大容易内存上涨。这个容量不是越大越好后面调优章节会给出具体建议。3.2 RecycledViewPool按类型分类的最终仓库CachedViews容量有限一旦超过2个或你设置的上限新滑出屏幕的ViewHolder就会被转移到RecycledViewPool。Pool的存储方式是按ViewType分类的每种ViewType默认最多存5个ViewHolder。从Pool里取出的ViewHolder因为已经经历了回收必须重新调用onBindViewHolder才能使用。这是Pool和CachedViews最大的差异。另外Pool里的ViewHolder被取出来时它的内部状态可能还是旧的——如果onBindViewHolder里没有把所有可能变化的字段都重新赋值就会出现经典的“item内容串了”问题。Pool还有一个重要特性它可以被多个RecyclerView共享。如果应用里有多个列表使用同一个Adapter类型你可以手动把它们的Pool设为同一个实例这样A列表回收的holder可以直接给B列表复用对嵌套列表、分页列表的内存优化效果立竿见影。这也是面试中经常会问到的点recyclerView.setRecycledViewPool(sharedPool)。3.3 ViewCacheExtension一个基本没人用的扩展点ViewCacheExtension是RecyclerView提供的自定义缓存扩展点理论上你可以实现自己的缓存逻辑。但实际项目中我几乎没见过有人正经用它主要原因有两个它发生在CachedViews和Pool的查询之间且绕过了一些内部状态检查很容易把自己绕晕正常场景下CachedViews Pool 预取机制已经能覆盖绝大多数性能需求没必要自造轮子。如果你只是想让“某个特殊类型”的View单独走自定义缓存可以考虑。否则我的建议是知道有这个东西就行千万别在生产环境里随便用它确实能提高命中率但也确实能把简单问题搞复杂。4. 从回收池到屏幕的完整旅程getViewForPosition背后的执行顺序前面介绍了各缓存层这一节把它们串成一条完整的链路。理解链路顺序比死记各层容量重要得多。4.1 查询ViewHolder的六步优先级当LayoutManager需要获取某个position的View时RecyclerView会按以下优先级逐级查找查ChangedScrap如果该position有被局部更新标记的holder优先取用查AttachedScrap正在布局过程中的可见item从这里找回查CachedViews已经滑出屏幕但还没进Pool的holder按position精确匹配查ViewCacheExtension自定义缓存扩展基本为空查RecycledViewPool按ViewType从池里取出holder如果池里没有就到第6步调用createViewHolder创建新holder再调用bindViewHolder绑定数据。前5步命中都不需要创建新的View只有第6步才真正发生inflate。每一步的耗时差异大概是前几步命中耗时在微秒级第6步在毫秒级。所以这个查询顺序直接影响滑动帧率。4.2 回收ViewHolder的路径选择ViewHolder被回收时不是无条件进Pool的它同样遵循一套路径逻辑滑出屏幕先进CachedViewsCachedViews满了再进PoolsetAdapter或notifyDataSetChanged所有holder都会被回收进PoolCachedViews也清空removeAndRecycleView直接进Pool不走CachedViews整体移除如removeViewAt先尝试进CachedViews满了进Pool。这条路径解释了很多人遇到的一个经典问题为什么我把某个item从列表移除后再次把这个position加回去显示的是旧数据答案很简单移除item后它的holder先进了CachedViews。因为CachedViews里有这个position的缓存再次添加时RecyclerView直接命中CachedViews不调用onBindViewHolder于是显示的还是旧数据。解决方式是在notifyItemRemoved之前手动把position对应的holder标记一下或者调用recyclerView.getRecycledViewPool().clear()再就是通过DiffUtil走payload进行强制刷新。4.3 亲手校验打日志确认每个holder的状态流转在onCreateViewHolder、onBindViewHolder、onViewRecycled三个回调里加日志然后正常滑动列表。一个典型的日志输出大概会是这样item0的onCreateViewHolder触发一次然后多次onBindViewHolder当item0滑出屏幕又滑回来时如果没有命中CachedViews因为缓存里是item0出来的滑回来时它还在可见区不会触发onCreateViewHolder。但当你快速来回滑动超过2个item距离时item0的holder可能会进Pool再进行来回滑动时你会看到onCreateViewHolder不再触发但onBindViewHolder会触发。**为什么**因为从Pool里取出的holderRecyclerView认为它的数据可能是旧的所以必然重新绑定。这就是Pool和CachedViews的本质差异在日志里的直观体现。4.4 预取机制为什么你还没滑到item就已经创建好了当RecyclerView处于空闲状态时它会启动一个预取机制Prefetch提前把即将滑入屏幕的item创建好。这个机制在LinearLayoutManager和GridLayoutManager上是默认开启的v1.0之后。预取的原理是当RecyclerView完成一帧绘制后如果主线程有空闲它会在下一帧之前预测用户下一步滑动的方向并提前从Pool取holder或直接createViewHolder把即将出现的item准备好。好处是滑动时不会因为临时创建View而掉帧坏处是如果你在onCreateViewHolder里做了重活预取本身也会占用主线程时间。嵌套RecyclerView场景下预取特别重要。内层列表的预取默认是关闭的需要手动调用LinearLayoutManager.setInitialPrefetchItemCount()来开启否则内层列表滚动时第一次显示的item会全部现场创建卡顿明显。5. 线上常见问题与排查思路缓存机制引发的事故现场前面是机制原理这一节是实战问题。以下问题都是我实际在项目里遇到过的也是社区里高频反馈的。5.1 item内容串了、图片闪烁、文字残留这是缓存复用最常见的翻身事故。原因很单纯View被复用了但onBindViewHolder里没有重置所有字段。比如一个item里有头像、标题、描述、点赞数、点赞状态。如果你的绑定方法里只更新了部分字段尤其是非空判断做得太“智能”复用时上一position的残留数据就会显示出来。排查逻辑很简单在onBindViewHolder里把所有跟数据相关的字段都做默认值重置。图片要设置占位图文本要设置空串不要用“if (text ! null) setText(text)”这种写法直接用holder.textView.setText(text ! null ? text : )。很多人觉得这样多写了代码实际上这是避免复用错乱的必备姿势。5.2 快速滑动时列表闪烁或白屏多数情况下这是CachedViews太小 没有开启预取导致的。每个新滑入屏幕的item都在实时createViewHolder主线程瞬间被inflate占满自然掉帧、闪烁。处理建议先调高setItemViewCacheSize比如改成4或6如果是GridLayoutManager还可以考虑给每个item设置固定宽高比避免layout时反复测量。更推荐的方案是把item布局中的复杂层级降下来减少inflate本身的耗时。一个item的inflate如果能控制在2ms以内即使频繁创建也不至于剧烈掉帧。5.3 嵌套RecyclerView滑动卡顿最常见的组合是外层大列表内层横向列表或者外层垂直、内层横向。很多人的第一反应是“内层列表关掉滑动”但这治标不治本。核心问题在于内层RecyclerView的holder复用不到位。推荐几种方案联合使用内外层共享RecycledViewPool回收的holder能在不同列表间复用给内层RecyclerView设置setNestedScrollingEnabled(false)配合外层一起滚动减少嵌套测量次数设置setInitialPrefetchItemCount()开启内层预取外层的onBindViewHolder里通过RecyclerView提供的setRecyclerView方式提前准备内层内容。如果外层item数量多且结构稳定还可以直接用一个全局的RecycledViewPool在多个列表间共享实测对这种场景的内存回收效果非常明显。5.4 notifyDataSetChanged后列表整体闪动根本原因是可见item全部执行了detach - scrap - rebind。闪动的本质是数据绑定重置过程里短时间内页面看起来“整体刷新”了。建议优先用DiffUtil进行差异更新它会精确到item级别发送通知极大减少无谓的bind对于大量数据更新也可以尝试submitList DiffUtil的组合。如果实在无法避免全量刷新至少要确保new DataSet不触发大范围的对象重建尽量复用同一个数据集引用。5.5 获取item高度时得到0这个问题的根源跟缓存机制也有关系ViewHolder还处于scrap状态但并没有完成真正的attach即布局阶段此时取高度当然为0。特别是从Pool复用的holder它的宽高信息可能还是旧状态需要等layout pass完成后再取。我实际排查时发现多数场景是开发者在onBindViewHolder里就直接去取item的高度但此时RecyclerView还没完成布局。正确做法是在onLayoutComplete回调里或者通过View.post()延后获取。如果你确实需要在bind阶段知道item尺寸可以在item根布局上写死宽高或者在xml里指定item的宽高比例这样即使复用时也能得到准确值。5.6 各种缓存容量怎么配一张速查表场景推荐配置说明普通线性列表setItemViewCacheSize(4)兼顾回滑流畅度和内存图片大图列表setItemViewCacheSize(2) Pool按类型容量设3避免缓存过多大图持有嵌套列表共享Pool setInitialPrefetchItemCount(4)关键在预取和跨列表复用视频列表CacheSize设为0视频View持有重资源能少存就少存高频刷新列表DiffUtil payload减少notify全量刷新的触发5.7 排查工具使用hierarchy viewer与Layout Inspector真正常见的性能问题不一定都是缓存导致。定位底部帧率掉帧时我习惯先开着Layout Inspector看item的层级深度。item根布局如果嵌套了超过四层FrameLayout嵌套LinearLayout再嵌套ConstraintLayout之类inflate开销可能已经很大了这时候再去调Pool容量都是杯水车薪。经验之谈一个item的xml布局层级尽量控制在2-3层以内能用ConstraintLayout扁平化就扁平化。有的item复杂到需要十几个子View也建议全部用merge或者自定义ViewGroup来减少inflate时的类加载开销。6. 调优思路从知道“怎么配”到知道“怎么选”严格来说RecyclerView的缓存调优没有一套“万能参数”。最有效的调优思路永远是先测量再调参最后验证。6.1 用数据说话先摸清列表的真实耗时分布如果你想系统性地优化一个列表第一步不是改参数而是搞清楚耗时集中在哪在onCreateViewHolder里记录耗时如果持续超过5ms优先优化item布局在onBindViewHolder里记录耗时如果频繁超过1ms优先检查数据绑定逻辑图片加载、JSON解析等用Systrace或CPU Profiler看RecyclerView的每帧耗时确认卡顿是否真的是创建View引起。只有数据告诉你“创建View”是瓶颈时调大缓存才有意义。如果瓶颈在bind逻辑你再怎么调Pool容量也救不了。6.2 不同业务场景的取舍策略总结信息流场景数据变化频繁优先保证DiffUtil和payload能正确工作减少全量刷新Pool容量适中不设太大因为频繁刷新会导致Pool不断被清空重建。设置页等静态列表一次性setAdapter后很少更新此时缓存几乎不重要只要item本身能正确复用就行。图片类列表商品、相册核心矛盾是图片解码与内存占用。建议设置Pool时尽量保守避免大量Holder携带ImageView和缓存图片在内存中堆积。更重要的是在bind时不要每次都重新请求网络图片而要用图片库的内存缓存。嵌套列表共享Pool 预取是主阵地细节参数反而不需要过度调。6.3 一个完整的最小调优案例翻车到流畅的复盘我曾接手过一个某资讯客户端的频道页外层是一个垂直方向的列表每个item内嵌一个横向RecyclerView展示推荐内容。先看现象横向列表滑到第三四个item时开始掉帧而且上下滑动整页时横向内容刷新的频率很高。数据摸底后的情况是横向列表首次展示时onCreateViewHolder耗时约8ms因为内嵌item的布局层级太深外层的预取没有开启所以每个横向item都是滑动到位才现场创建。随后做了三件事第一把内嵌列表的item布局从四层嵌套改成一层ConstraintLayoutcreateViewHolder耗时从8ms降到了约3ms第二对外层列表开启预取并给内嵌列表设置setInitialPrefetchItemCount(4)第三把各个同类内嵌列表共享同一个Pool。最终效果原先肉眼可见的横向列表顿挫感消失CPU Profiler里的inflate调用峰值大幅下降。这个案例里调大Pool参数的贡献其实不到三分之一更关键的是减少inflate耗时和开启预取。6.4 怎么判断你的缓存是否真的被命中了很多人实际改进后不确定缓存有没有生效。最直接的验证办法是在Adapter里写一个计数器记录onCreateViewHolder的调用次数然后反复快速滑动列表观察计数是否在稳定后就几乎不再快速增长。如果计数持续线性增长说明你的item类型很杂、每类总量很少、或者频繁触发全量刷新这些都会让Pool始终存不到可复用的holder。另一种情况是item在onViewRecycled里做了耗时操作间接拖累了回收流程导致进Pool的效率降低。6.5 关于“把CacheSize调到20”这件事别过度有些资料建议把setItemViewCacheSize调得很大比如20用来减少重新bind。大部分场景下我不建议这么做原因有二一是CachedViews里的holder持有View实例和相关资源数量变大内存压力增加二是很多更新UI的操作依赖bind回调CacheSize太大会导致该重新绑定的item没有触发bind反而出现显示不及时。我一般建议除非你明确知道自己在做什么保持默认值2或者上调到4-6即可。真正的性能提升来自减少inflate本身而不是把所有东西都往CachedViews里塞。写在最后回过头来把整个缓存体系压缩成一句话RecyclerView的复用优化本质上是在“减少创建View”和“减少绑定数据”之间做平衡。CachedViews帮我们减少重复绑定Pool帮我们减少重复创建Scrap保证布局过程的稳定预取机制则提前把后面要用的Holder准备好。在实践中比调参更重要的是建立数据意识当你觉得列表卡顿先定位耗时在哪当你觉得内存上涨先看缓存放了多少Holder。这套思路在各版本RecyclerView里都通用即使以后架构升级了理解缓存如何运作、如何排查复用问题依然是一项核心基本功。这篇速查手册算是把我平时排查问题时的经验做了个系统沉淀希望对你也有用。