RecyclerView 的预加载机制,Prefetch 到底有用吗

RecyclerView 的预加载机制,Prefetch 到底有用吗

RecyclerView 的预加载机制,Prefetch 到底有用吗


RecyclerView 的预加载机制,Prefetch 到底有用吗


从一次列表卡顿说起


去年维护一个信息流应用时,我遇到个挺典型的性能问题。列表滑动到第 15 条左右开始掉帧,Systrace 上能看到明显的红色帧, Choreographer 的 doFrame 耗时飙到 30ms 以上。当时的第一反应是 item 布局太复杂,开始砍 ConstraintLayout 嵌套、用 merge 标签、把图片加载延后。折腾两周后帧率好了些,但滑动到视频卡片时还是偶尔卡顿。


真正定位到根因是在一台 Pixel 4 上抓的 Perfetto trace。放大看发现,卡顿帧的 doFrame 里,RecyclerView 的 onBindViewHolder 占了大头,而且不是某一个 holder 绑定慢,是连续几个都在同一帧里执行。这时候我才意识到,问题可能不在单个 item 的绑定耗时,而在绑定的时机——RecyclerView 默认是在即将把 item 滑入屏幕时才创建和绑定,如果一屏内同时出现多个新 item,它们会挤在同一帧里做初始化。


这就是 Prefetch 要解决的问题。但翻了一圈官方文档和源码后,我发现这个机制的实际行为和文档描述有不少出入,有些场景开了反而更慢。


Prefetch 的两种形态:GapWorker 与 NestedPrefetch


RecyclerView 的预加载其实分两条线。一条是 Android 6.0(API 23)引入的 GapWorker,在 androidx.recyclerview:recyclerview:1.2.0 之前是唯一的预加载机制;另一条是 1.2.0 版本新增的 NestedScrollingChild3 接口配合的嵌套预加载,主要解决 ViewPager2 这种嵌套场景。


GapWorker 的核心逻辑在 GapWorker.java 里。RecyclerView 滑动时, onScrollStateChanged 触发 postFromTraversal,往主线程的 Choreographer 里 post 一个 runnable,在下一帧的 input 阶段之后、animation 阶段之前执行预加载。这个时机选得很讲究——利用的是帧与帧之间的空隙,理论上不影响当前帧的绘制。


但实际抓 trace 会发现,GapWorker 的 prefetchPositionWithDeadline 方法虽然用了 deadline 参数来限制执行时间,默认给每个 item 的配额只有 3ms(mFrameIntervalNs / 2 再除以预估的预加载数量)。如果单个 createViewHolderbindViewHolder 超过这个阈值,预加载任务会拆成多帧执行,而 deadline 的计算在复杂列表里经常不准。


我做过一个对比测试,用同样的 50 条混合布局列表(图文 + 视频 + 广告),在 Pixel 4(Android 13)和一台小米 10(Android 12)上分别开关 Prefetch:


// 关闭 GapWorker 的方式
RecyclerView.setItemViewCacheSize(20); // 调大缓存只是权宜之计
// 或者反射把 mGapWorker 置 null(生产环境别这么干)

开 Prefetch 时,Pixel 4 的 90th percentile 帧时间从 18.2ms 降到 12.5ms,但小米 10 反而从 15.8ms 升到 17.1ms。差异出在小米 10 的 GPU 合成路径不同,GapWorker 抢占的 CPU 时间影响了 RenderThread 的同步。


更隐蔽的是 NestedPrefetch。ViewPager2 内部用的 RecyclerViewImpl 在 1.2.0 之后默认开启了 setInitialPrefetchItemCount,这个值默认是 2,意思是当 ViewPager2 的页面滑入时,会预取该页面内 RecyclerView 的 2 个 item。但问题出在嵌套层数上——如果外层是 ViewPager2,内层是 RecyclerView,再内层是某个自定义的横向滚动组件,三层 NestedPrefetch 同时触发时,Choreographer 的 callback 队列会堆积,反而造成 input 延迟。


我在一个实际项目里踩过这个坑。信息流里嵌套了横向的短视图片段,用 ViewPager2 实现,每个页面内部是纵向 RecyclerView。测试发现快速滑动外层 ViewPager2 时,内层 RecyclerView 的前两帧经常空白。最后定位到是 InitialPrefetchItemCount 的默认值 2 在内层 RecyclerView 上生效,但外层 ViewPager2 的滑动速度太快,预取的两个 item 还没完成绑定,页面已经需要显示了。解决办法是显式设成 0:


// 在内层 RecyclerView 的 Adapter 里
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
    if (recyclerView.getLayoutManager() instanceof LinearLayoutManager) {
        ((LinearLayoutManager) recyclerView.getLayoutManager())
            .setInitialPrefetchItemCount(0);
    }
}

自定义 LayoutManager 的陷阱


Prefetch 的生效前提是 LayoutManager 正确实现了 collectAdjacentPrefetchPositions 方法。LinearLayoutManager 和 GridLayoutManager 的实现都在 LinearLayoutManager.java 里,逻辑是向滑动方向预取 1 个 item。但如果是自定义 LayoutManager,很容易漏掉或者写错。


我之前写过一个瀑布流 LayoutManager,参考了 Google 的 StaggeredGridLayoutManager。第一次实现时直接 copy 了预取逻辑,但没注意 collectAdjacentPrefetchPositionslayoutState 方向判断。结果是向上滑动时预取的是下方的 item,向下滑动时预取的也是下方的 item——预取了个寂寞,还白白消耗 CPU。


正确的做法需要结合 mLayoutState.mLayoutDirection


@Override
public void collectAdjacentPrefetchPositions(int dx, int dy, 
        RecyclerView.State state, LayoutPrefetchRegistry layoutPrefetchRegistry) {
    int delta = (mOrientation == HORIZONTAL) ? dx : dy;
    if (getChildCount() == 0     
    int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
    // ... 根据 direction 计算预取位置
}

更深层的问题是,Prefetch 假设了 createViewHolderbindViewHolder 的耗时是可预测的。GapWorker 用了一个滑动窗口平均来估算,代码在 GapWorker.javaprefetchPositionWithDeadline 里:


long averageCreate = mCreateAverageNs;
long averageBind = mBindAverageNs;
long required = averageCreate + averageBind;
if (required > deadlineNs) {
    // 跳过,下次再来
}

但这个平均值对抖动很不敏感。如果列表里混入了复杂 item(比如带 WebView 的广告),第一次创建可能耗时 50ms,会把平均值拉高,导致后续简单 item 的预取也被跳过;反之如果先都是简单 item,平均值很低,遇到复杂 item 时会超时,预取失败,滑入时卡顿。


我试过在广告 item 的场景下,把 RecyclerView.RecycledViewPoolsetMaxRecycledViews 针对广告 type 单独设高,配合 onViewRecycled 里不销毁 WebView 只是 detach,这样 createViewHolder 的耗时趋近于 0,Prefetch 的预测才准一些。但这也带来了内存压力,需要权衡。


AsyncListDiffer 与 Prefetch 的微妙关系


现在很多列表用 ListAdapter + AsyncListDiffer 来做差分更新。这个组合和 Prefetch 有个不太明显的交互问题。


AsyncListDiffersubmitList 是异步计算 Diff,结果通过 ListUpdateCallback 分派到 RecyclerView。如果用户在 Diff 计算期间开始滑动,新列表的 item 可能还没进入 RecyclerView 的 mAttachedScrapmCachedViews,这时候 GapWorker 的预取位置计算基于的是旧列表的 mState.getItemCount(),预取的可能是不存在的 position,或者预取到了但类型不匹配。


我在 ListAdapter 的源码里看到,AsyncListDiffermMainThreadExecutor 默认是 ArchTaskExecutor.getMainThreadExecutor(),而 RecyclerView 的 Prefetch 也是跑在主线程。如果 Diff 结果刚好在某一帧的 Choreographer callback 之后到达,下一帧既要执行 dispatchUpdatesTo,又要执行 GapWorker 的预取,两个任务抢主线程时间。


一个缓解方案是控制 submitList 的时机,避免在滑动过程中频繁提交。但更好的做法可能是自己实现一个带 backpressure 的 diff 队列,或者干脆在滑动状态不是 SCROLL_STATE_IDLE 时延迟提交。不过这也只是 workaround,根本问题是 RecyclerView 的预取没有考虑数据变更的并发场景。


RecyclerView 1.3.0 的变更与回归


androidx.recyclerview:recyclerview:1.3.0 有个值得注意的改动。Google 在这个版本里重构了 RecyclerView 的部分内部结构,把 GapWorker 的调度从固定的 Choreographer.getInstance() 改成了允许通过 RecyclerView.getChoreographer() 覆盖。这原本是为了支持测试场景,但引入了一个 regression:在某些 ROM 上,自定义 Choreographer 的帧回调注册时机和系统不一致,导致 Prefetch 的 runnable 偶尔被执行两次。


这个 bug 的跟踪在 Google Issue Tracker 上,编号 #243119783。现象是快速滑动列表时,Systrace 里能看到两个连续的 GapWorker.prefetch slice,间隔不到 1ms,第二个是冗余的,浪费 CPU。1.3.1 版本修复了,但 1.3.0 在部分设备上能复现。


我在升级依赖时踩过这个坑。当时项目从 1.2.1 升到 1.3.0,测试反馈某款 OPPO 机型上列表滑动发热。抓 trace 发现 CPU 小核持续高负载,放大看是 GapWorker 在空转。回退到 1.2.1 或者升到 1.3.1 都正常。这个 case 让我对 RecyclerView 的版本升级更谨慎了——哪怕是 patch 版本,内部调度逻辑的改动也可能在特定设备上出问题。


另一个 1.3.0 的变更是 RecyclerView.setItemPrefetchEnabled(boolean) 这个方法的可见性。之前是 package-private,只能通过 LayoutManager.setItemPrefetchEnabled 间接控制。1.3.0 把它改成了 public,但文档没同步更新,很多开发者不知道可以直接在 RecyclerView 层面关闭 Prefetch:


// 1.3.0 之后可以直接这么写
recyclerView.setItemPrefetchEnabled(false);

我倾向于在调试性能问题时先关闭 Prefetch,确认卡顿是否和预取本身有关。有些场景下,Prefecth 的预创建会触发过早的图片加载(如果 ImageView 的绑定逻辑里直接调了 Glide.load),导致内存带宽被占,反而影响当前帧的渲染。


真实数据:Prefetch 的收益边界


为了回答标题的问题,我在一个生产环境做过 A/B 测试。样本是日活百万级的信息流应用,Android 版本分布从 8.0 到 13,设备覆盖高中低端。


实验组关闭 Prefetch(setItemPrefetchEnabled(false) + setInitialPrefetchItemCount(0)),对照组保持默认。核心指标是滑动帧率(SM,Smoothness Metric,定义为单位时间内流畅帧占比)和 ANR 率。


结果是分层的。在 Android 10 以上、内存 6GB 以上的设备上,关闭 Prefetch 的 SM 下降约 3-5%,ANR 无明显变化;在 Android 9 以下、内存 4GB 以下的设备上,关闭 Prefetch 的 SM 反而上升 2-4%,ANR 下降 0.01%。


背后的原因大概是:低端设备上,Prefetch 的 CPU 抢占导致 RenderThread 饥饿,而预取带来的绑定并行化收益抵不过渲染延迟。高端设备上,CPU 核数多,Prefetch 和渲染能并行,收益才体现。


这个结论和 Google 官方的说法不完全一致。官方文档里 Prefetch 被描述为"在所有设备上提升滑动性能",但实际数据证明它是有设备门槛的。我在后续版本里加了动态开关:


boolean shouldEnablePrefetch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q 
    && getAvailableMemory() > 4096; // 粗略判断
recyclerView.setItemPrefetchEnabled(shouldEnablePrefetch);

当然 getAvailableMemory 的实现要斟酌,用 ActivityManager.getMemoryInfoavailMem 还是 totalMem,阈值设多少,都需要根据自己产品的用户分布调。


一个被忽视的参数:mPrefetchMaxCountObserved


RecyclerView 源码里有个私有字段 mPrefetchMaxCountObserved,在 GapWorkerbuildTaskList 方法里用到。它记录的是单次预取任务里实际执行的最大 item 数,用来动态调整下一帧的预取策略。


但这个值的更新逻辑有个细节:它只在 prefetchPositionWithDeadline 成功执行后才更新,如果预取因为 deadline 不足被跳过,不会计入。这导致一个恶性循环——某帧因为负载高跳过了预取,下一帧的 mPrefetchMaxCountObserved 还是旧值,预取配额计算偏乐观,继续跳过。


我试过在自定义的 RecyclerView 子类里反射修改这个值,强制给一个保底配额,效果有限。更实际的方案是重写 GapWorker 的调度逻辑,但 GapWorker 是 package-private 的,反射成本和维护成本都高。


Google 在 1.3.2 版本里对这个逻辑做了微调,把 mPrefetchMaxCountObserved 的衰减系数从 0.5 改成了 0.33,让历史值的影响更快消退。这个改动没有写进 release note,我是 diff 源码时发现的。实际测试下来,对抖动场景的恢复确实快了一些,但根本问题还在。


与 Compose LazyColumn 的横向对比


虽然题目是 RecyclerView,但提到 Prefetch 很难不想到 Compose 的 LazyColumn。Compose 1.3.0 之前,LazyList 没有预加载机制,滑动到新 item 时如果组合(composition)耗时,会直接掉帧。1.3.0 引入了 LazyListPrefetchStrategy,1.4.0 又加了 beyondBoundsItemCount,功能上才接近 RecyclerView 的 Prefetch。


但实现方式完全不同。Compose 的预取是在组合阶段之外,用 CoroutineDispatcher 异步执行的,不抢主线程的 Choreographer 时间片。这避免了 RecyclerView GapWorker 的帧内抢占问题,但也引入了新的复杂度——异步预取的结果需要同步到组合树,如果用户滑动太快,预取还没完成 item 就需要显示,会 fallback 到同步路径,卡顿感比 RecyclerView 更明显。


我在一个 Compose 迁移项目里对比过同一场景。RecyclerView + Prefetch 的 90th 帧时间是 14ms,Compose 1.4.0 的 LazyColumn 是 19ms。Compose 的首次滑动更慢,但反复滑动同一区域时因为组合结果可复用,后续更流畅。这个差异说明两种框架的优化思路不同:RecyclerView 是 View 系统的增量优化,Compose 是声明式 UI 的全量重算加缓存。


回到 RecyclerView,它的 Prefetch 机制在 View 系统的约束下已经做得相当精巧,但受限于主线程架构,天花板很明显。如果项目还在用 RecyclerView,我的建议是:不要无条件信任默认配置,根据设备分层、根据列表内容复杂度、根据滑动场景(快速 fling vs 慢速浏览)来动态调整。


最后一点:Systrace 看不全的真相


很多性能分析止步于 Systrace 或 Perfetto 的切片(slice),但 Prefetch 的问题有时候藏在 trace 之外。GapWorker 的 postFromTraversal 用的是 Choreographer.postCallback,这个 callback 的执行时机在 doFrameCALLBACK_INPUTCALLBACK_ANIMATION 之间,但具体在哪个线程?


答案是主线程,但和 UI 绘制的消息队列共享 Looper。如果业务代码在 RecyclerView.OnScrollListener.onScrolled 里做了重逻辑,会延迟 GapWorker callback 的执行,导致预取实际发生在更晚的帧,失去"预"的意义。


我抓过一个 trace,GapWorker 的 prefetch slice 和 onScrolled 的自定义逻辑在同一个 message 里,间隔不到 0.1ms,说明是被同一个 Looper 消息触发的。但 onScrolled 执行了 8ms,把整帧占满,GapWorker 的预取被挤到下一帧,而下一帧又需要绑定新 item,预取和绑定撞车。


这种时序问题用静态代码分析很难发现,必须在真实用户场景下抓 trace。我们后来在 release 版本里嵌入了轻量的帧时间监控,超过阈值时自动 dump 当前主线程的调用栈,才定位到是某个埋点 SDK 在 onScrolled 里同步写数据库。


Prefetch 有没有用?我的答案是:在理解它的前提条件下有用。把它当成黑盒打开,预期它能魔法般地消除所有列表卡顿,多半会失望。拆开看它的 deadline 计算、设备差异、和数据变更的并发、和业务代码的抢占,才能决定在你的场景里怎么配置,甚至要不要用。

SonarQube 代码质量门禁,团队落地经验 2026-07-10
GnssStatus.Callback 的卫星定位状态,原始数据怎么用 2026-07-13

评论区