资讯详情

资讯详情

Android RecyclerView缓存机制详解:四级缓存与列表性能优化实践

做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、notifyItemRemovedLayoutManager需要重新摆放所有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而不是按数据来源去细分。下面整理一个速查表方便你在线上排查时对着找现象可能原因建议解法快速滚动大量新建ViewnotifyDataSetChanged频繁改DiffUtil/局部刷新仅回滚时卡mCachedViews命中低调大setViewCacheSizeitem内容串数据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被调了几次这两类调用分别出现在什么场景回答完这三个问题至少一半的性能问题已经浮出水面了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →