把丢帧时长占比降下来:双列信息流的流畅度优化

初稿于 2024 年发表于公司内网,2026 年对外重写

背景

2023 年我主导了今日头条双列信息流的性能专项。「双列」指类似小红书样式的双列卡片流:一屏两列、卡片高度参差,当时是头条站内仅次于推荐 feed 的高流量场景。高流量意味着体验问题会被成倍放大,也意味着优化的回报足够高,值得专项投入。

这篇主要写方法:怎么建评估、怎么找问题、优化了什么,以及怎么让优化成果不随时间退化。

指标:为什么是 HTR

专项的第一件事是选指标。指标一旦定下,就是后面所有优化的验收口径,值得先花时间想清楚。我们的流畅度核心指标是 HTR(Hitch Time Ratio):单位时间内丢帧时长的占比。

先解释 hitch。屏幕以 60Hz 刷新时,一帧的预算约 16.7ms;一帧没能按时提交,延后的这段时间就是这一帧的 hitch time。把一段时间内所有 hitch time 累加,除以这段时间的总时长,就得到 HTR。

比起「掉帧次数」这类计数型指标,HTR 更贴近用户体感,因为它同时反映丢帧的频率和严重程度:轻微晚到一点和整段卡住,计数上可能都算一次掉帧,体感却完全不同;HTR 按时长累计,严重卡顿在指标上的权重自然更大。平均帧率也有类似的盲区——平均值会把局部的严重卡顿摊平,滑动大部分时间很顺、偶尔明显卡一下的场景,平均帧率可能依然好看。Apple 在讲渲染性能的官方材料里,用的也是同类口径。

先建评估,再动手优化

单点优化谁都能做几个,难的是持续回答两个问题:现在卡在哪,改完有没有变好。所以专项先花力气建评估,线下线上各一套。

线下用 Instruments 配合 os_signpost 打点:把滑动过程中的耗时任务全部标出来,导出后画成甘特图,主线程、子线程、关键任务、白屏区间用不同颜色区分,关键路径一眼可见。打点本身没有多少技术含量,关键是覆盖要全:主线程之外,子线程的任务也要标出来,不少卡顿的根源在子线程把资源占满,只盯主线程找不到原因。这张图对协作的价值超出预期——「感觉卡」没法讨论,「看见哪一段在卡」才能分工。

甘特图示意:主线程与子线程的任务沿时间轴排布,超出帧预算的一段被标记为 hitch

线上建了五个核心指标的大盘,覆盖流畅度、首刷耗时(进入场景到第一屏内容展示完成的耗时)等,再配两个机制:实验劣化报警,任何一个实验(A/B 测试)导致指标劣化都会触发报警;劣化归因,指标退化时能定位到引入退化的版本或实验。报警的意义在于把发现劣化的时机从「版本发布之后」提前到「实验开启之后」,问题还没放量就能被看到。

评估建好之后,找优化空间就有了两个稳定的方向:

  • 和自己比:版本之间、实验组之间做劣化归因,找出每一个退化点。已经退化过的地方,往往就是最容易拿回来的空间。
  • 和别人比:跨场景对比指标,双列和站内的推荐 feed 比,也和竞品的同类场景比。差距摆在那里,差多少,优化空间就至少有多少;竞品能做到的水平,也证明了在同类设备和系统上技术可达,这类对比最能说服团队投入。

优化了什么

具体优化项沿「请求发出到画面渲染」的链路排下来,每项说一句原理。

  • feed 请求时机提前:把首刷请求提前到启动更早的阶段发出,让网络耗时和启动阶段的其他初始化并行,等界面准备渲染时,数据大概率已经回来了。
  • 减少端内请求开销:请求真正发到网络之前,要在端内经过序列化、拦截器等环节,这部分开销做了裁剪。
  • Cell 高度计算优化:双列卡片高度参差,高度在布局和滚动过程中会被反复询问;加高度缓存,避免同一张卡片重复计算。
  • RunLoop 分周期刷新:把一次刷新要做的事拆到多个 RunLoop 周期里执行,避免单帧内堆积过多任务导致该帧超时。总的工作量没有变少,但摊薄到多帧之后,每一帧都能按时提交。
  • 多代理事件分发优化:滚动时 scrollViewDidScroll 这类回调要分发给多个代理,滚动是高频事件,分发本身的开销积少成多,值得优化。
  • 首刷杂项:段压缩预载、配置系统初始化提前等。思路一致:把首刷路径上的等待,挪到更早、更空闲的阶段去做。

结果用两个口径衡量:首刷耗时累计优化约 150ms;HTR 优化约 50%。

问题左移:让劣化在合码前被拦下

优化做完只是一半。信息流场景需求密度高,新代码每天都在合入,没有防线,指标会在之后的版本里悄悄退回去。所以专项的另一半力气花在「问题左移」上:把发现问题的时机往开发流程的更早阶段移,能在合码前拦住的,就不拖到线上再查。具体做了三件事。

  • 录制回放替代人工回归:把双列的回归用例用录制回放全量覆盖,同样的回归范围,测试提效 4-6 倍,人从重复操作中解放出来。
  • 合码前性能拦截:在 MR(merge request)阶段自动跑性能对比,严重的流畅度劣化直接拦截,不允许合入。问题在合码前修,成本远低于上线报警之后再回头查。
  • 自研基于 signpost 的自动化测试工具:耗时变化可以精确到毫秒级,直接定位到具体任务。性能拦截回答「有没有劣化」,这个工具回答「劣化在哪」。

回头看

单点优化技巧,在各种技术分享里都能找到类似的;这个专项里真正难、也真正值钱的,是先建立「能持续发现问题」的评估。没有大盘和归因,优化做完的那一刻就是指标的顶点,随后慢慢退化回去,过一段时间一切重来。专项总有结束的一天,投入的人力会撤走,能留下来的是大盘、报警、归因和合码前拦截这些机制——指标最终能不能守住,靠的是它们。

另一个印象深的点是可视化的杠杆作用。甘特图把「感觉卡」变成「看见哪一段在卡」,问题从主观体感变成客观事实,和团队对齐问题的效率完全不同。方法本身没有任何新意——打点、画图而已——但它决定了后面所有优化工作的沟通成本。