一、先看没有缓存会怎样
一个聊天列表有 100 条消息,屏幕只能显示 8 条。手指向上滑,新消息不断从底部进入屏幕。
如果没有缓存,每条新消息都要走两步:
ini
// 第一步:解析 XML、反射创建 View 对象,耗时大头
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_message, parent, false);
// 第二步:一层层查找子控件
TextView tvContent = view.findViewById(R.id.tv_content);
inflate 一个稍复杂的布局可能要几毫秒,滑动时每秒刷新 60 帧,主线程根本来不及。
RecyclerView 的思路很朴素:滑出去的 View 不要扔,留给下一条消息用。这套"留用"体系就是缓存机制。
二、四级缓存
RecyclerView 需要一个可复用的 ViewHolder 时,按顺序问四层,问到就停:
Scrap(屏幕内缓存)→ CachedViews(屏幕外缓存)
→ ViewCacheExtension(自定义缓存)→ RecycledViewPool(缓存池)
下面逐层看,例子都基于同一个 MessageAdapter 聊天列表。
第一层:Scrap------布局期间的临时停车位
概念:RecyclerView 重新布局时(调用了 notifyXxx、或尺寸变化),先把屏幕上可见的 ViewHolder 全部摘下来放进 mAttachedScrap,布局过程中再按 position 逐个取回。
它们没离开过屏幕,只是布局那一瞬间"下岗待命"。
关键在取回时的判断:
scss
// 源码 getScrapOrHiddenOrCachedHolderForPosition 中的判断(简化)
if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
// 没被标记无效、position 对得上 → 原样复用,不执行 bind
return holder;
}
// 反之被标记无效 → 转存进 Pool,之后复用要重新 bind
原理:这就解释了一个常见现象------notifyDataSetChanged 会把所有 ViewHolder 标记为无效,屏幕上所有 item 全部重新 bind;而 notifyItemChanged 只标记一条,其余原样复用。后者快,是底层机制决定的。
补充:notifyItemChanged 的那一条会进另一个集合 mChangedScrap,等变化动画(比如闪一下)播完才进 Pool。
第二层:CachedViews------刚滑出去的备用库存
概念:item 滑出屏幕后先不进 Pool,而是放进 mCachedViews,默认最多存 2 个。往回滑时直接原样取出来用,连 onBindViewHolder 都不执行。
场景:聊天列表向下滑了 1 条,position 0 的消息刚滑出屏幕进入缓存。手指往回滑,position 0 马上要回来------直接取出来放回去,零加载。
scss
// 默认值 2,回滑频繁的场景可以调大
recyclerView.setItemViewCacheSize(4);
原理:mCachedViews 按 position 精确对应,取的时候逐个比对 position,对得上才复用。position 一致,意味着数据和滑出去那一刻完全一样,重新 bind 纯属浪费。这是它和 Pool 的本质区别,第四节展开。
第三层:ViewCacheExtension
概念:官方留的自定义口子,继承它自己决定存什么、取什么。
实际开发几乎不用:存取全要自己扛,细节容易出 bug,前两层已经覆盖绝大多数场景。知道它排在 CachedViews 和 Pool 之间即可。
第四层:RecycledViewPool------按类型分类的回收池
概念:mCachedViews 满了以后,新滑出去的 ViewHolder 先清空 position 等身份信息,再按 viewType 存进 Pool。每种 viewType 默认最多 5 个。
场景:聊天列表有两种消息------文字(viewType 0)、图片(viewType 1),Pool 就是两个格子:格子 0 放文字消息的 ViewHolder,格子 1 放图片消息的。谁来取只看 viewType 对不对,不看原来是第几条。
scss
// 存入 Pool 时,源码 RecycledViewPool.putRecycledView 中(简化)
// position 等身份信息被清空,只留下 viewType 和 View 本身
viewHolder.resetInternal();
// 取出后不知道会被用到哪个 position,必须重新 bind
原理:Pool 是四层里唯一"跨 position"复用的层。按类型复用意味着"换了个位置用",旧数据不能再信,所以从 Pool 拿到的 ViewHolder 一定会重新走 onBindViewHolder。
三、完整查找流程 + 日志验证
取 ViewHolder 的入口是 Recycler.tryGetViewHolderForPositionByDeadline,简化后:
markdown
1. Scrap:position 匹配且有效 → 直接用,不 bind
2. CachedViews:position 匹配 → 直接用,不 bind
3. ViewCacheExtension:一般没有
4. RecycledViewPool:viewType 匹配 → 重新 bind
5. 全部落空 → onCreateViewHolder 新建,再 onBindViewHolder
在 Adapter 里打两行日志就能验证:
less
public class MessageAdapter extends RecyclerView.Adapter<MessageAdapter.ViewHolder> {
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
// 四层缓存全部落空才会走到这里
Log.d("CacheTest", "create");
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_message, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
// 从 Pool 取出或新建的会走到这里
// 从 Scrap、CachedViews 取出的不会
Log.d("CacheTest", "bind: " + position);
holder.tvContent.setText(messages.get(position).getContent());
}
}
屏幕显示 8 条,对应 position 0~7;CachedViews 容量是默认的 2。从打开页面到持续下滑,每一步"谁滑出、谁进屏、打印什么"逐一对照:
arduino
打开页面:
create + bind 0~7 // 缓存全空,可见的 8 条只能新建
下滑第 1 条(0 滑出,8 进屏):
create + bind: 8 // 0 刚进 CachedViews,但里面只有 position 0,
// 对不上新来的 8,帮不上忙,只能新建
下滑第 2 条(1 滑出,9 进屏):
create + bind: 9 // CachedViews 里是 0、1,都对不上 9
下滑第 3 条起(2 滑出,10 进屏):
只 bind: 10 // CachedViews 满(0、1),最早的 0 被挤进 Pool,
// 10 从 Pool 命中,重新 bind、不 create
// 第 4、5、6......次滑动重复同样的循环
往回滑:
什么都不打印 // 刚滑出去的还在 CachedViews,position 对上,直接用
原理:create 只集中在下滑前 2 次。CachedViews 容量是 2,滑出去 2 条才把它装满,第 3 次滑动才开始往 Pool 溢出;Pool 接住之后,每次"滑出 1 条、进屏 1 条"刚好一进一出,create 就不会再发生了。所以列表第一次滑到底开销大,第二次滑几乎全被缓存接住。
实际跑日志时,和上面的"标准答案"常见两处出入:
- 打开页面多打印 1 次 create:布局时会多加载 1 条用来探测屏幕还有没有剩余空间,放不下就退回缓存,属于正常现象。
- 新 item 还没进屏就先打印了 create + bind:这是 GapWorker 预取------滑动时利用每帧的剩余时间,提前给下一个要进屏的 item 做 create 和 bind,做好后放进 CachedViews。所以进屏的 item 反而静默复用,日志里看到的是"预取"那一组。时机提前了,走的还是同一套缓存。
四、CachedViews 与 Pool 的核心区别
一句话:CachedViews 认 position,Pool 认 viewType。
场景对比:
- 聊天列表往回滑一小段------刚滑出去的消息还在 CachedViews,原样出现,不闪不卡;
- 滑到列表最底部再一路滑回来------早期的 ViewHolder 早已被挤进 Pool,每条消息都要重新 bind,能看到数据重新灌入的过程。
复用能力对比:
arduino
// CachedViews 里的 ViewHolder:占着坑等人回来,
// 只有原来那个 position 能用,别的 item 来了帮不上忙
// Pool 里的 ViewHolder:通用零件,类型对谁来都能装
RecyclerView 只给 CachedViews 留 2 个、给 Pool 每类留 5 个,就是在内存和流畅度之间做的平衡。
五、实战调优
1. 加大屏幕外缓存
scss
// 回滑频繁的场景(聊天、评论区)调大
// item 布局重就别调太大,缓存的都是完整 View 对象,吃内存
recyclerView.setItemViewCacheSize(4);
2. 多列表共享缓存池
场景:一个页面两个 RecyclerView(Tab 切换的两个列表),item 长得一样。
ini
// 两个列表共用一个池:A 滑出去的 ViewHolder 直接给 B 用
RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool();
recyclerOne.setRecycledViewPool(pool);
recyclerTwo.setRecycledViewPool(pool);
// 高频类型把上限调大(默认 5)
pool.setMaxRecycledViews(0, 10);
原理:切到列表 B 时,A 囤下的 ViewHolder 直接复用,B 的前几屏不用新建。前提:两个列表的 viewType 编号规则一致,否则池子对不上号。
3. 用精细 notify 替代全量刷新
scss
// 全量刷新:所有可见 item 摘进 Scrap 后被标记无效,全部重新 bind
adapter.notifyDataSetChanged();
// 局部刷新:只有 position 3 重新 bind + 播变化动画,其余原样复用
adapter.notifyItemChanged(3);
// 更进一步:DiffUtil 算出最小变更集,逐条 notify
DiffUtil.calculateDiff(callback).dispatchUpdatesTo(adapter);
原理:列表长、刷新频繁时,精细 notify 保住了大部分缓存的"可信"状态,对性能友好得多。
六、一句话总结
RecyclerView 缓存的所有设计都围绕一个问题: "这个 ViewHolder 还能不能信" 。
- Scrap、CachedViews 里的是可信的------position 没变,拿来直接用;
- Pool 里的是不可信的------身份已注销,拿来必须重新 bind;
- 全都不可信时,才付出 create + bind 的最贵代价。