把「一帧」理解成 setState 立刻重画,会在生产里把瓶颈找错。setState 不会调用 Metal,也不会走完 Layout;它只把某个 Element 丢进 BuildOwner 的脏列表。真正把 Dart 世界推进到可提交 Scene 的,是 Vsync 到来之后 SchedulerBinding 串起来的 Animate → Build → Layout → Paint。Raster 线程上的 GPU 工作,严格来说已经不算这四个阶段。
读完你应该能独立回答三个问题:这一帧的时间被哪个阶段吃掉了、为什么 Layout 必须一次遍历而不能像 CSS 那样反复回流、UI 线程已经 compositeFrame 之后屏幕上为什么还可能是上一帧。
1. 先画边界:四个阶段都不碰 GPU
四个阶段全部跑在 UI Task Runner 上,主体是 Dart。它们的产品是 Layer 树(以及树上 Picture / DisplayList 的记账),不是 framebuffer。
- Framework → Engine:
SceneBuilder建好的Scene、帧回调、语义树。 - Engine → Framework:Vsync(
onBeginFrame/onDrawFrame)、指针、生命周期。
所以 FrameTiming.buildDuration 量的是这条 Dart 管线;rasterDuration 量的是另一条线程。把 GPU 掉帧优化成「少 setState」,方向可能完全反了。
不变量:四个阶段可以脏、可以跳过,但不能换序,也不能在 Paint 里再改 Size。 换序会破坏 Layout 的一次遍历;Paint 改 Size 会让命中测试和合成用两套几何。
Debug 里 debugDoingLayout / debugDoingPaint 就是在守这条边界。Release 关掉断言,越界不会立刻炸,会变成「偶发错位、偶发二次 layout、偶发上一帧残影」——更难查。
2. 节拍器:Vsync 怎样变成 drawFrame
没有 Vsync,Framework 不会自己空转刷屏(scheduleWarmUpFrame 是启动和测试的例外,它不等下一拍)。Embedder 从 Choreographer / CADisplayLink 拿到垂直同步,投递到 UI Runner。这就是帧的节拍器。
一次对齐的帧,Scheduler 把时间切成几段相位(SchedulerPhase):
transientCallbacks:Ticker 步进,这就是 Animate。- 微任务插在相位之间。这里如果堆积大量
Future回调,会直接吃掉后面 Build 的预算。 persistentCallbacks:WidgetsBinding.drawFrame,Build / Layout / Paint / Composite。postFrameCallbacks:这一帧已经交给栅格化,只能做「下一帧再生效」的事。在这里再setState不会 intra-frame 循环,但会立刻预约下一帧。
void drawFrame() {
buildOwner.buildScope(rootElement);
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
renderView.compositeFrame();
pipelineOwner.flushSemantics();
buildOwner.finalizeTree();
}
这不是示意,是职责顺序。flushLayout 之前 Widget 树必须已经把这一帧的配置建完;flushPaint 之前几何必须稳定;compositeFrame 是 UI 侧的终点,不是上屏。语义树可以在同一帧稍后刷,无障碍不跟像素抢同一微秒,但会占 UI Runner。
3. Animate:只允许改「值」,尽量别改「树」
Animate 的主体是 Ticker / AnimationController。它们拿到 Duration elapsed,写出一个 double,再触发 setState,或直接改某个 RenderObject 的字段后 markNeedsPaint。
原则:动画值可以每帧变,树结构尽量别每帧变。每帧 insert/remove 孩子,Build 和 Layout 都会从 O(脏节点) 退化成 O(子树)。隐式动画(AnimatedContainer)看起来省事,代价是宽高、padding、decoration 可能同时脏,四个阶段全跑。
把动画限制在已有对象的 paint 属性上(opacity、transform、对齐),才能让后面的阶段走边界提前返回。这不是风格问题,是预算问题:Animate 本身几乎不耗时,它决定后面三个阶段有多脏。
AnimationController 必须挂在 TickerProvider 上,而 Ticker 只在 SchedulerBinding 处于帧循环时走。页面不可见时 Ticker.muted,否则后台仍在申请 Vsync,既耗电又和前台抢节拍。
4. Build:脏列表,不是整棵 Widget 树
setState 只调用 element.markNeedsBuild()。BuildOwner 把脏 Element 按 depth 排序,然后只 rebuild 这些节点。祖先先于子孙,这样子在父的新配置里重建,不会用到即将被扔掉的 slot。
Widget.canUpdate 为 true 时,Element 和它下面的 RenderObject 原地更新。false 则 deactivate 旧的、inflate 新的。Build 的复杂度应接近脏节点数量,而不是 Widget 构造函数被调用的次数——构造 Widget 很便宜,inflate Element、挂 State、挂 RenderObject 才贵。
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
LayoutBuilder、Overlay、GlobalKey 重挂载会把 Build 和 Layout 重新绑在一起:builder 在父 layout 过程中才跑,等于在 Layout 阶段插入了一次 Build。这是合法的受控例外,但会让「Build 时间」和「Layout 时间」在 Timeline 上缠在一起,排障时不要按名字机械切分。
const Widget 能跳过重建,前提是配置真的没变。父仍 rebuild 时,const 子只是 canUpdate 之后什么都不做,不是子树从树上消失。
5. Layout:一次遍历靠一条协议活着
父给子 BoxConstraints(min/max 宽高),子必须在盒子里选定 size,再把孩子摆到 parentData.offset。子不能说「我先随便高,你再告诉我宽」。
child.layout(constraints, parentUsesSize: true);
size = constraints.constrain(Size(width, child.size.height));
parentUsesSize: true 表示父稍后要读 child.size。若为 false,或约束是紧的(min==max),或 sizedByParent == true,这个节点可以成为 relayout boundary:子树内部 relayout 停在这里。
PipelineOwner 把需要 layout 的节点按 depth 排序后一次 performLayout。同一帧回头给已经 layout 过的祖先新约束,debug 断言会炸;release 里则变成不可预测的二次遍历。这就是 Flutter 相对 CSS 的那条硬边界:禁止回流。
Sliver 是同一协议的另一套约束(剩余绘制范围、滚动偏移),不是例外。列表快,是因为没出现的孩子根本不进这棵树,而不是因为 Layout 算法更聪明。
6. Paint:往当前 Layer 记账
paint 拿到 PaintingContext 和 offset。context.canvas.draw* 并不调用 GPU,它往当前 PictureLayer 的 recorder 里追加指令。需要独立合成属性时,context pushOpacity / pushClipRect / pushTransform,挂新 Layer。
所以 Paint 阶段慢,常见原因是:记录了太多层、太多 saveLayer、在 paint 里做了本该在 layout 做的测量(对同一段超长字符串反复 TextPainter.layout)。真正的 fill rate 在 Raster 线程,这一阶段看不见。
Paint 的脏标记 markNeedsPaint 向上走,直到 isRepaintBoundary == true。没有边界,一个转圈指示器可以逼整页重记 DisplayList。有边界但子树每帧都变,边界变成每帧重建的离屏纹理,Paint 未必省,Raster 一定更贵。
flushCompositingBits 夹在 Layout 和 Paint 之间:它重算「这棵子树要不要独立合成」。Opacity 从 1.0 变成 0.9,bits 翻了,后面 Paint 才会 push 新 Layer。漏算会画错;每帧都翻会多挂层。
7. 和合成的分界:流水线从这里切开
Paint 之后 RenderView.compositeFrame 把 Layer 树 addToScene,生成 ui.Scene,投递 Raster Runner。UI Runner 可以 开始准备下一帧。第 N 帧在 GPU 上,第 N+1 帧在 Dart 里。
如果 Raster 还在画 N,N+1 的 Scene 会排队。表现是持续掉帧而不是单帧尖刺。buildDuration 很低、rasterDuration 很高,就是这种病。
系统合成器(SurfaceFlinger / Core Animation)还要再吃一次延迟。触控跟手不只取决于你的 Dart 是否小于 8ms。追求「输入到上屏一拍内」,要同时看 Embedder 的 Vsync 对齐和系统合成,而不是再抠 Build。
一个完整的反例能把阶段切清楚。滚动列表时在 NotificationListener<ScrollNotification> 里同步 TextPainter.layout 一段长文,再 setState 把高度写进 item:Animate 相位(滚动 Ticker)里插入了 Layout 的活,Build 被额外点脏,Layout 对可见行再量一遍文本。Timeline 上你会看到 transientCallbacks 变长、flushLayout 变长,而 GPU 条可能仍是绿的。正确拆法是:滚动只改 ScrollPosition(paint/合成),文本高度在第一次 layout 时量完缓存,滚动路径禁止 setState。
scheduleWarmUpFrame 和普通帧的差别也在这里。它跳过「等下一拍 Vsync」,用于第一帧尽快有像素。字体 fallback、首图解码、shader/PSO 绑定经常撞在这一帧,所以启动慢不能用稳态的 16.6ms 去套。稳态优化和启动优化是两条时间线。
8. 脏标记是三条河,不要汇成海
| 标记 | 谁消费 | 被什么挡住 |
|---|---|---|
markNeedsBuild | BuildOwner.buildScope | 没有 build boundary,靠少 setState、拆 State |
markNeedsLayout | PipelineOwner.flushLayout | relayout boundary |
markNeedsPaint | PipelineOwner.flushPaint | repaint boundary |
三者独立。布局钉死、只改颜色,应只走 paint。改了一句文案导致整页 relayout,是因为文本所在节点不是 boundary,或者父 parentUsesSize: true 一路传到根。
还有一条小河:markNeedsCompositingBitsUpdate。它不直接画,但决定 Paint 时挂不挂 Layer。和三条主河搞混,会把「多了一层离屏」误诊成「build 太勤」。
生命周期上,这四段都在 UI Isolate。跨 Isolate 的 compute 回传必须在下一帧之前拷回主堆;拷贝本身记在微任务里,看起来像 Build 变慢,其实是分配和 GC。
9. 设计取舍(为什么是现在这样)
- 四阶段同步、同线程:推理简单,没有「layout 到一半 build 插进来」的数据竞争。代价是任一阶段超时,整帧 UI 超时。
- Layout 一次遍历:O(N) 可预测。代价是写不了「子决定父、父再改子」的弹性布局,必须把那种需求编译成约束。
- Paint 只记账:UI 和 Raster 流水线并行。代价是两套时间线,DevTools 里看错条就会改错代码。
- 脏标记冒泡 + 边界:局部更新。代价是边界用错会适得其反——缓存层每帧失效,或脏标冲到根。
这些取舍决定了什么优化有意义。换状态库缩短不了 Raster;给每帧都在变的子树加 RepaintBoundary 缩短不了 Paint;在 build 里缓存一个错的 Size 解决不了 Layout 协议问题。
10. 怎么把原理用到排障上
遇到掉帧,按阶段提问,不要一上来拆业务 Widget:
- 是
buildDuration还是rasterDuration? 方向完全相反。 - Build 时间是否随页面深度线性涨? 是则脏范围太大,查
setState位置、拆State、const。 - Layout 是否在 Timeline 里反复出现同一节点? 是则破坏了一次遍历,查
LayoutBuilder、intrinsic 宽高、在 paint 里 layout。 - Paint 是否伴随大量
saveLayer/ 大图? 那是合成账单,不是setState次数。 - Animate 是否每帧在改树结构? 先把动画收到
Transform/Opacity上,再谈别的。
能回答这五问,四个阶段对你就不再是概念图,而是一帧的时间表。时间表对上了,再决定动 Widget、动 RenderObject,还是动图层。
11. 小结
Animate、Build、Layout、Paint 是时间片上必须按序完成的四段 Dart 工作,产品是 Scene,不是像素。卡顿要么是某一段超了预算,要么是脏标记没有停在边界上,要么是你把 GPU 的事误记在这四段里。把问题归到阶段,后面讲 Impeller、Layer、RepaintBoundary 才有落点。下一篇会离开 UI Runner,看封闭图元和离线管线怎样把 Scene 变成可预测的 GPU 时间。