优化没有测量就是玄学。把「感觉卡」直接改成换状态库、加 RepaintBoundary、开 Impeller,三种药可能都开错。FrameTiming 把一帧拆成 UI 用时 和 Raster 用时(以及 vsync 相关的时间戳)。DevTools 的时间线再告诉你时间花在哪类调用。先分类,再下药。两者缺一:只有线上数字不知道函数名,只有本地火焰图不知道用户设备分布。
读完你应该能独立回答三个问题:buildDuration 超了该减什么、rasterDuration 超了该减什么、为什么两者之和不是用户可见延迟。
1. 先画边界:一帧的时间戳
Engine 在 UI Runner 和 Raster Runner 上打点,经 SchedulerBinding 汇总成 FrameTiming。关键字段:
| 字段 | 含义 | 超了说明什么 |
|---|---|---|
vsyncStart | 这一拍 Vsync 到来 | 节拍器 |
buildStart / buildFinish | UI 线程做 Animate/Build/Layout/Paint 记账 | Dart / 布局 / 记录绘制太重 |
rasterStart / rasterFinish | Raster 执行 DisplayList、提交 GPU | 层、模糊、大图、着色器、填充 |
buildDuration | buildFinish - buildStart | 同上 |
rasterDuration | rasterFinish - rasterStart | 同上 |
totalSpan | 从 vsync 到栅格结束的跨度 | 含排队,不等于两段相加 |
vsyncOverhead | 唤醒晚于 vsync 多少 | 上次工作没吃完、线程忙 |
流水线:Raster 画第 N 帧时,UI 可以已经在建第 N+1。所以 两段可以在时间上重叠。用户看见的掉帧,是「到了该上屏的那一拍,buffer 还没好」。任一端持续超过帧间隔(16.6ms / 8.3ms),队列堆积,掉帧从尖刺变成持续。
不变量:
buildDuration对 GPU 填充不敏感。rasterDuration对 Dart 布局不敏感。- 一次 GC STW 会打进
buildDuration(发生在 UI Isolate 时)。 - shader compile 打进
rasterDuration,且「只第一次」。 - Platform View 的系统合成延迟 不一定 完整出现在这两个字段里。触控跟手还要加上 SurfaceFlinger / Core Animation。
WidgetsBinding.instance.addTimingsCallback((timings) {
for (final t in timings) {
final uiMs = t.buildDuration.inMicroseconds / 1000;
final gpuMs = t.rasterDuration.inMicroseconds / 1000;
final budget = 1000 / (t.frameNumber == 0 ? 60 : 60); // 用显示刷新率更准
if (uiMs > 16 || gpuMs > 16) {
// 上报:路由、哪一侧、是否连续
}
}
});
不要每帧 print。回调本身在 UI Isolate,日志 IO 会制造你正在查的卡顿。
2. 预算从哪来:60Hz、120Hz、可变刷新
16.6ms 不是物理常数。120Hz 是 8.3ms。Android 可变刷新可能在 30~120 之间跳。预算变了,同样的 10ms build 从「安全」变成「危险」。线上应按 FrameTiming 关联的刷新率或 display.refreshRate 分桶,不要用 16.6 一刀切。
连续三帧超预算和单帧尖刺是两种病。单帧:第一次 shader、一次老年代 GC、一次同步 IO。连续:每帧都重的 build 或每帧都离屏。优化策略完全不同。后者才能用「减 Widget / 减层」治;前者要找冷启动路径。
vsyncOverhead 大:UI 线程没在 vsync 时醒来,常见原因是上一帧 build 拖太久、或主 Isolate 被同步调用堵住(Channel 编码、FFI、文件)。它不是独立的第三种病,是「上一帧的债」。
3. DevTools:时间线怎么读
CPU Profiler / Timeline 的轨道:
- UI:
vsync、animate、build、layout、paint、submit。柱子宽就是buildDuration的来源。 - Raster:
GPU/ Impeller pass、upload、tessellate。柱子宽对应rasterDuration。 - IO: 解码。它不直接进 FrameTiming 两段,但解码完成会在后续帧的 Raster 上传里爆。
- Platform: 插件、Platform View。ANR 在这里,不在 FrameTiming。
读火焰图的纪律:
- 先看超的是哪条轨道,再点开函数。不要对着一张「很忙」的图猜。
build里全是你的build()和setState链路;performLayout深说明约束传递或固有尺寸测量(TextPainter、Intrinsic)。paint记录太重:过多saveLayer、过大 path、每帧new Picture。- 打开 Track Widget Builds 才能看见是哪个 Widget rebuild。没开就猜状态库,是在浪费时间。
- Enhance Tracing(layout / clip / rebuild)有开销,只在复现时打开。
- Profile 模式才接近 Release。Debug 的 JIT、asserts、逐层 checkerboard 会撒谎。
Performance overlay:UI 条和 GPU 条。UI 红减 Dart;GPU 红减层和像素。棋盘格闪烁 = RepaintBoundary 缓存每帧失效。
4. 分类后的动作,不要交叉开药
UI 超、Raster 健康
- 减 rebuild 范围:更细的
State、ListenableBuilder、const、拆InheritedWidget通知面。 - 减 layout:能
relayout boundary就钉尺寸;不要在滚动回调里TextPainter.layout整篇文章。 - 重计算离开 Isolate:JSON、加密、排序。
compute的回包不要大到解码本身卡 UI。 - 查 GC:DevTools Memory 看 scavenge 是否和帧尖刺重合。热路径复用
Paint/Path/List。
Raster 超、UI 健康
- 减
saveLayer:整页Opacity、不必要的Clip.antiAliasWithSaveLayer、全屏 blur。 - 减像素:图片
cacheWidth、不要离屏再放大。 - 减层:能合成进同一 Picture 的不要强行
RepaintBoundary。 - 第一次才超:shader / 字形 / 路径,走 Impeller 或 warmup,见前篇。
两边都超
通常是「一棵巨大的树既 layout 又 paint」,例如没有 sliver 的超长列、自定义 RenderObject 每帧 layout。先让 layout 成为 boundary,再谈 paint。
两边都不超仍觉得跟手差
输入延迟、Platform View、系统合成、120Hz 没打开、vsync 对齐导致的一帧排队。这不是 Dart 优化能抹平的。测 pointer 到上屏要用系统工具,不是只看 buildDuration。
5. 线上采样:怎么才有统计意义
每帧都上报会打爆自己的 Channel 和磁盘。正确形状:
- 只报超预算帧,或 1% 采样。
- 带:路由、前后台、刷新率、连续超帧计数、设备档、是否 Impeller。
- 分 P50/P95,不要只看平均值——compile jank 会被平均藏掉。
- 把「首次进入某路由的前 30 帧」和「稳态滚动」分成两个指标。
addTimingsCallback 在 Release 可用。它拿不到函数名。函数名靠:本地用 Timeline 复现同类路由,或极少量的 DartDeveloper 自定义 Timeline 事件打在可疑函数上(有开销,用开关)。
不要用 Timer 每秒 debugDumpRenderTree 当监控。
6. 生命周期与测试陷阱
addTimingsCallback 要在 dispose 里卸。热重载会叠加回调,Debug 里越跑越慢。
测试里 tester.pump 的时间不是真 Vsync。pumpAndSettle 更不是用户设备。性能测试用 integration_test + Timeline 摘要,或真机 Profile。CI 模拟器的 GPU 和真机是两个物种,Raster 数据只能当回归,不能当绝对预算。
Engine 后台(paused)仍可能有残留回调。后台报的「超帧」不要和前台用户体验混在一个报警里。
多 Engine:每份 Engine 自己的 timings。只连着 DevTools 那一份。线上要按 engine id 分。
7. 设计取舍
- 流水线并行: 吞吐高。代价是至少一拍的显示延迟,
build+raster不能理解成串行用户等待。 - 两段计时: 分类便宜。代价是系统合成、输入队列不在里面,跟手问题会误导你继续减 Widget。
- Profile 接近 Release: 可测。代价是断言关闭后某些 Debug-only 的 layout 异常消失,要以 Release 为准做最终判断。
- overlay 和 checkerboard: 本地立刻看见 GPU 病。代价是它们改变填充和带宽,测完必须关。
把 FrameTiming 当「性能分数」做 KPI 会逼人优化平均值。正确 KPI 是超帧率和连续掉帧,按路由和刷新率分层。
8. 排障流程(按层,不按感觉)
- 超的是 UI 还是 Raster?连续还是单次?
- 单次:GC、shader、首次解码、首次字体。Timeline 对一下时间点。
- 连续 UI:Track Widget Builds,找 rebuild 最宽的节点。问它是否必须每帧变。
- 连续 Raster:checkerboard、layer dump、减 blur/clip/opacity。问像素数。
- 两边干净:输入路径、Platform View、刷新率、是否在模拟器。
- 只某机型:驱动、Impeller 回退、热节流(持续 raster 高导致降频,看起来像「越用越卡」)。
void _install() {
SchedulerBinding.instance.addTimingsCallback(_onTimings);
}
void _onTimings(List<FrameTiming> list) {
var uiOver = 0, rasterOver = 0;
for (final t in list) {
if (t.buildDuration > const Duration(milliseconds: 14)) uiOver++;
if (t.rasterDuration > const Duration(milliseconds: 14)) rasterOver++;
}
if (uiOver + rasterOver == 0) return;
// 聚合后再上报
}
阈值用 14 而不是 16.6,是为了在 60Hz 上留余量;120Hz 要再降。不要把阈值写死成魔法数而不带刷新率。
9. 自定义 Timeline 事件该打在哪
打在你怀疑的边界,不要打在每个 build:
- Isolate 回包解码前后
- 一次自定义
performLayout的重计算 - FFI 调用
- 「我认为很快」的图片处理
事件会出现在 UI 轨道,宽度即墙钟时间。若它和超帧重合,争论结束。若从不重合,把这段优化从清单里删掉。性能工作最大的浪费是优化不在关键路径上的代码。
10. SchedulerPhase、帧策略、以及你在错误的相位做事
SchedulerBinding.schedulerPhase 告诉你现在在帧的哪一段:idle、transientCallbacks(Ticker)、midFrameMicrotasks、persistentCallbacks(build/layout/paint)、postFrameCallbacks。在 build 里 setState 会断言或排到下一帧。在 layout 里读还没 layout 的子节点会脏环。性能问题常是相位用错:在 ScrollNotification(可能落在 layout 之后或中途)里同步算一遍固有尺寸,等于额外 layout。
WidgetsBinding.instance.addPostFrameCallback 把活推到本帧 paint 之后、下一 vsync 之前。适合「读 size 再决定」,不适合「每帧重计算」。回调里再 setState 必打下一帧。动画里 post-frame setState 是双倍 build。
SchedulerBinding.scheduleWarmUpFrame 在没有 vsync 的启动阶段强行跑一帧,让第一屏早点出来。warmup 帧的 timings 往往难看,统计时要排除启动后前 N 帧,否则 P95 永远是启动。
帧策略:LiveTextInput、TickerMode 关闭时动画不跑,帧可以少。后台 paused 应停止 Ticker。自己写的 Timer.periodic(16ms) 不会停,会在后台继续 setState 失败或积压。这是「上架后耗电」和「回来第一帧巨卡」的来源——积压的帧或积压的微任务。
handleBeginFrame / handleDrawFrame 是 Engine 打进 Dart 的入口。中间若你用 await 卡在 persistentCallbacks 里,帧无法结束,Raster 拿不到 Scene,屏幕冻结。build() 必须同步。异步数据用「已有快照 + 以后再 setState」,不要 FutureBuilder 在每帧新建 Future(那会每帧重启异步,看起来像性能问题,其实是生命周期问题)。
11. 火焰图以外:raster cache、shader 时间、系统工具
FrameTiming 在较新版本还带 layerCount 一类 hint(以你用的 SDK 为准)。图层数高且 raster 高,比「我感觉 Opacity 多」硬。
Skia 时代 Timeline 上的 RasterCache 命中/失败:缓存命中省 CPU 记录,费显存。失败还费。Impeller 下这套缓存语义变了,不要用旧文的「加大 raster cache 上限」当通用药。
shader compilation:Timeline 事件名随后端变(GrGLProgramBuilder、impeller、Precompile)。线上没有函数名,只能用「该路由第一次连续 raster 尖刺」当代理指标。代理指标要按版本和是否 Impeller 分维,否则 Impeller 覆盖率一升,这个指标自己降下来,你会误以为某次业务优化成功了。
系统工具补 FrameTiming 看不见的:
- Android
systrace/ Perfetto:SurfaceFlinger、Binder、GPU fence。Platform View、主线程 ANR。 - iOS Instruments:Core Animation、Metal System Trace。跟手延迟、离屏。
adb shell dumpsys gfxinfo:框架帧、GPU 帧。和 Flutter 的两段不是同一套数,用来交叉验证「系统也认为掉帧」。
把 Flutter timings 和 gfxinfo 对上:Flutter raster 短但 gfxinfo 长 → 病在系统合成。Flutter raster 长 gfxinfo 也长 → 病在 Flutter 提交的内容。只看一边会错怪 OEM 或错怪自己。
debugProfilePaintsEnabled / debugProfileLayoutsEnabled 给 Timeline 加色块,有开销。复现完毕关掉。CI 里跑 integration_test 录 timeline JSON,用 summarize.dart 看 build_avg / raster_worst。把阈值当回归门禁时,必须锁设备型号和刷新率,否则 120Hz 机器先红。
自定义指标:连续掉帧比单帧最大值重要
单帧 40ms 用户可能无感(一次 hitch)。连续 10 帧 20ms,滚动变「粘」。上报应包括:
overrunStreak:连续超预算帧数maxStreak按会话- 该 streak 期间 UI/Raster 各自占比
- 路由、是否在滚动(自己用
ScrollActivity或滚动通知打标)
滚动中的超帧权重应更高。静止时一次 GC 尖刺可以降权。把所有超帧当同等事件,优化方向会被启动动画带跑。
frameNumber 可用来算丢失:若 vsync 间隔是 16.6ms,两次回调的时间戳差 33ms,中间丢了一拍。totalSpan 大而 build+raster 都小,是排队或唤醒晚,查 vsyncOverhead 和主 Isolate 同步阻塞。
与 GC、日志、网络的耦合
print / debugPrint 在真机 USB 上可能阻塞。线上 log 打到磁盘同理。性能采样回调里做 IO,会制造 raster/ui 双假阳性。采样要:内存聚合、超阈值才入环形缓冲、每 N 秒或每路由 pop 上报一次。
网络回包在 UI Isolate jsonDecode 大 JSON,打的是 buildDuration 之外的 idle 时间,但下一帧 vsyncOverhead 会涨。看起来像「这一帧莫名其妙」,原因在上一截同步 decode。Timeline 上自定义事件包住 decode,就能对上。这是 FrameTiming 的盲区:它只覆盖帧内两段,不覆盖帧间的同步工作。所以「帧间」的重活同样要测。
实验室协议
固定:设备、刷新率锁死、充电、关自动亮度、冷启动等 3 秒再操作、同一套手势脚本(integration_test 或宏)。变:Impeller 开关、图片尺寸、是否打开读屏。一次只变一个。读屏开会让 Platform 线程变忙,Raster 也可能间接受语义几何更新影响。对比实验时读屏必须同开同关。
流水线气泡:为什么偶发一帧特别长
UI 和 Raster 并行时,若 Raster 超时,下一帧 UI 可能已经做完,Scene 排队。再下一拍,UI 可能被背压挡住(实现随版本:有的会跳帧、有的会等)。于是你看到:一帧 raster 40ms,后面一帧 vsyncOverhead 高、buildDuration 却低。这是气泡,不是 build 变快了。优化应打在那次 40ms raster,不要去「优化」后面那次空的 build。
反过来,build 40ms 时 Raster 在吃老 Scene,GPU 空一拍,然后一次提交。用户感觉是一次卡顿。Timeline 上两轨错开看,不要只看单轨最大值。
forceFramesOnWatch 一类嵌入式场景会改策略。普通 App 不要拿手表或嵌入式的帧策略套手机。
采样代码的线程安全
addTimingsCallback 在 UI Isolate 调用。把数据放进非同步的 List 再在 Isolate 里上报,注意回调和上报 Timer 不要并发改 List——同一 Isolate 无数据竞争,但 await 上报时回调可能插入。用「交换缓冲」:上报时 final batch = _buf; _buf = [];。不要在回调里 await。
上报 Channel 自己也耗时。超帧时再 invoke 一个 Channel,可能让下一帧更超。环形内存缓冲 + 闲时上报。闲时用 SchedulerBinding.scheduleTask(..., Priority.idle) 或路由 pop。
和用户体验指标对齐
FrameTiming 是实验室指标。产品指标是滚动 FPS、点击到内容、启动到可交互。三者相关但不等价。启动到可交互包含 Channel、网络、第一帧字体,FrameTiming 只覆盖其中「帧」那一段。不要用平均 buildDuration 给启动项目结项。启动用 TimeToFirstFrame / TimeToRasterEnd(Engine 有报告回调)和自己的业务 ready 点。
刷新率切换
游戏进页请求 120Hz,退出恢复。切换瞬间几帧 timings 乱,要排除。display.refreshRate 变化打点。在 60 和 120 之间来回的设备上,用固定 16ms 阈值会在 120 时误报。阈值 = 1000/refresh * 0.9。
与手势、语义同时打开时的测量
打开 Performance overlay 会改填充。打开 Semantics debugger 更改。测帧时这些全关。TalkBack 开是真实用户场景,单独出一条「辅助功能开」的 P95,不要和默认混。手势竞技场本身很少成为 buildDuration 主体,除非 HitTest 极深或回调里 setState 整页。火焰图看 hitTest 宽度,深树 + 复杂 path contains 会出现。那是手势篇的病,用 FrameTiming 分类出来再去改 HitTest。
回归门禁数字
CI 真机:指定路由滚动脚本,raster_worst < 20ms(60Hz 设备)、build_worst < 20ms、超帧率 < 5%。锁 Impeller 开关。数字随设备改,仓库里按 device id 存基线。不要用模拟器 GPU 当门禁。
性能工作的节奏应是 分类 → 复现 → 单变量改动 → 同一脚本再测。分类用 FrameTiming;复现用 Profile + 真机 + 锁刷新率;单变量改动禁止同时换状态库、加 Boundary、开 Impeller。再测必须同一脚本。打破节奏的「优化周」会留下一堆无法归因的 diff,下一次卡顿不知道哪些还能留。
把 FrameTiming 接进已有 APM 时,注意采样偏差:只在 Debug 用户开、只在 Wi-Fi 上报、只在高端机,都会让线上看起来很美。采样要包含低端机和 Android 读屏用户。偏差比没有数据更危险,因为它支撑错误的自信。
最后,FrameTiming 不能替代产品体感。有人能感觉 1 帧延迟的跟手,有人对 5 帧无感。跟手问题加测 touch-to-photon:从 PointerDown 时间戳到该指针效果上屏。那可能要求引擎或系统 trace。若业务是绘画/游戏,这笔测量值得;若是表单,P95 超帧率够了。选指标要匹配交互密度。不要给设置页上触控延迟仪表盘,也不要给画布只用平均 FPS。
APM 看板最少四张图:按路由的 UI 超帧率、Raster 超帧率、首次进入前 30 帧的 raster P95、稳态滚动超帧率。一张「平均 FPS」可以不要。平均会把问题洗掉。
告警:某路由超帧率周环比上升,且分维显示不是刷新率变化、不是 Impeller 覆盖率变化。再去复现。无分维的告警会变成狼来了。
本地复现包:同一路由、同一数据夹具(固定图片尺寸)、Profile、锁 60Hz。数据夹具很重要——线上 4K 图、本地 200px 占位,你永远复现不了 raster 问题。夹具进仓库。
开发者选项:内部包打开帧图例、超帧时震动。测试同学用震动定位「哪一下卡」,再交给研发看 Timeline。没有体感开关,测试只能写「有点卡」,研发无法分类。FrameTiming 是给机器看的;震动是给人看的。两者一起,分类才快。
把「性能预算」写进页面模板:该页静止 build < 8ms,滚动 raster < 8ms(120Hz 则更严),允许的离屏效果列表。超预算的设计不进开发。预算是和设计师共用的语言。FrameTiming 是预算的测量仪。没有预算,测量仪只产生焦虑。
与电池:高刷新率 + 高 raster 会降频,然后更卡,然后你加更多 overlay 调试,更卡。测卡顿要充电、关调试叠加层。热节流单独一维,夏季真机外场测。实验室空调房会低估。
版本对比:发版后看同一路由超帧率,而不是看「我们优化了多少处」。处数是过程,超帧率是结果。结果坏了就回滚那一处效果,不要用十处微优化覆盖一处全屏 blur。FrameTiming 分维能指出是哪条路由坏了,git blame 那条路由的近期 diff,比看优化周报快。
FrameTiming 接入后,禁止再用「感觉流畅了」作为优化结项标准。结项必须是某路由某分维的超帧率下降,且无回归。感觉可以当假说,不能当结论。假说用 Timeline 验证病灶,结论用线上分维验证用户。三步缺一,优化周就是表演。
对管理者:不要下达「FPS 提到 60」这种指标。下达「主 Feed 滚动 Raster 超帧率 < 3%(60Hz 分维)」这种。前者会催生锁 60、关动画、骗平均;后者逼人减离屏。指标形状决定优化形状。本文把 FrameTiming 讲这么细,就是为了让指标能写细。写不细,原理等于没落地。
把 FrameTiming 的分类结果反写进 bug 模板:必填「UI 还是 Raster、连续还是单次、是否首次、路由、刷新率、是否 Impeller、是否读屏」。测试同学填不全就让研发退回。模板强迫分类。不分类的卡顿单是噪声。噪声多了,性能通道会和普通 bug 混在一起被忽略。通道要干净,模板要硬。硬模板是这篇原理文在流程上的落点。原理若不改流程,读完还是玄学。
测量体系一旦按 UI/Raster、首次/持续、路由、刷新率、后端分维,优化就变成可关闭的票:每张票一个分维上的数字,一个 diff,一次复测。关不掉的票说明病灶没找对,回去看 Timeline,不要再堆微优化。堆微优化是玄学的回潮。本文从 FrameTiming 字段讲到流程模板,就是为了把回潮堵住。堵住之后,图形的事归图形,Dart 的事归 Dart,合成器的事归 OEM 或 Platform View。各回各层,和专栏第一篇 Engine 分层是同一张地图。地图已经有了,测量是图上的比例尺。没有比例尺,地图只是装饰。
FrameTiming 是比例尺,DevTools 是显微镜,线上分维是地图图例。三件一起用才能在专栏这张 Engine 地图上定位。只拿显微镜看本地,会优化到没人走的路径。只看线上平均,会看不见 GLES 设备的 compile。只看比例尺不分类,会在 GPU 病上减 setState。分类句是:UI 还是 Raster,首次还是持续。两问之后才允许打开火焰图。火焰图之后才允许改代码。改代码之后必须同一脚本再测。再测之后才允许改线上指标预期。这条链比任何工具配置重要。配置会变,链不应变。链就是这篇的不变量。不变量一旦进 bug 模板,卡顿单才从噪声变成工程。工程能关票。能关票,性能工作才不是无限优化周。无限优化周是没有比例尺的症状。现在比例尺有了。用它。
比例尺还要校准:不同 Flutter 版本 FrameTiming 字段可能增减,刷新率 API 可能变。校准写进 APM 的版本适配层,不要写进每个页面。页面只报「超了」。适配层把超了翻译成分维。翻译错了,全公司看板错。看板错比没有看板危险。所以适配层要有单测:造一份 FrameTiming,断言分维键。键稳定,历史曲线才可比。不可比的曲线会在升级 Flutter 后跳变,引发假胜利或假事故。假事故消耗信任。信任一旦没了,性能通道再干净也没人填模板。模板是信任的下游。上游是校准。校准是这篇读完后基建组该做的第一件事。做了,分类才能跨年版。跨年版,原理才算进了仓库,而不只进了专栏。
基建组做完适配层单测,还要把「超了」的定义写成配置:刷新率函数,而不是字面 16。配置进远程,必要时能放宽某路由以免误报淹掉真问题。放宽要打日志。日志让放宽可逆。可逆,比例尺才不会被一次性的产品活动(例如全屏直播页)永久扭曲。扭曲的比例尺会让主 Feed 的回归被忽略。主 Feed 才是大多数用户的大多数时间。比例尺为大多数校准。活动页单独分维,单独阈值。分维是为了不让特例统治平均数。平均数不统治,模板才值得填。填了,链才转。转了,卡顿单才能关。能关,是测量的终点。终点不是永远优化。永远优化是没有终点的别名。别名不进这篇文章。
活动页单独阈值之后,主 Feed 的门禁才能长期保持严。严,是为了大多数时间。大多数时间的滚动,才是用户对「这个 App 顺不顺」的记忆。记忆不是启动动画,不是一次 hitch。记忆是持续。持续用超帧率抓。抓到了,用链处理。处理完,关票。关票之后不要再为感觉开新的优化周。感觉去对照分维。分维说没有,就相信分维,去查别的(跟手、合成器、读屏)。别的在别的篇。本篇负责帧内两段。两段清了,职责尽了。尽了,才把显微镜还给下一个人。
两段清了,才去查跟手和合成器。别在已经干净的帧内两段上继续堆微优化。
微优化堆在干净的两段上,只会制造不可归因的 diff。diff 要能关票。关不掉就不要堆。
关不掉的票说明分类错了。回到 UI 还是 Raster、首次还是持续那两问,不要在错误的层上继续改。
两问答错,改得越多离病灶越远。先答对,再打开显微镜。
显微镜很贵,问句很便宜。便宜的先问。问完再决定值不值得打开 Timeline。
先问清楚,再测。
12. 小结
FrameTiming 给病种:UI 还是 Raster,尖刺还是持续。DevTools 给病灶:哪个函数、哪一层、哪张图。流水线让两段并行,所以不要把它们加起来当用户等待,也不要在 GPU 病上减 setState。线上只采样超帧并按路由和刷新率分层,本地用 Profile 时间线复现。能稳定回答「哪一侧、是否第一次、哪个节点」,优化才是工程而不是玄学。