把 Flutter 理解成「一个 UI 线程包打天下」,会把三种完全不同的病当成同一种:Dart 超时、GPU 超时、平台通道堵在主线程。运行时是 四条 Task Runner,靠 Vsync 对齐;跨队列通信全是投递任务,不是直接调函数。搞混「谁可以等谁」,优化就会改到错误的代码。
读完你应该能独立回答三个问题:卡顿发生在哪一条 Runner、为什么 Dart 已经很快仍可能掉帧、插件回调里 setState 为什么有时要先切回 UI。
1. 先画边界:四条队列,职责必须互斥
| Runner | 典型线程 | 允许做什么 | 绝对不要做什么 |
|---|---|---|---|
| Platform | 系统主线程 | 生命周期、插件、部分输入、Platform View | 重计算、等网络(Android 上 ANR) |
| UI | Engine 创建的 UI 线程 | Dart、手势、build/layout/paint 记账 | 同步读磁盘、等 GPU、大 FFI |
| Raster | Engine 创建的 GPU 线程 | Skia/Impeller、纹理上传、提交 swapchain | 跑 Dart、阻塞等锁 |
| IO | 后台线程 | 图片解码、部分文件 | 碰 dart:ui 里和 GPU 绑定的对象 |
不变量:UI 不碰 GPU 上下文;Raster 不跑 Dart;Dart 对象不得在 Raster 上解引用。 Layer 树从 UI 交到 Raster,走的是一次线程安全的移交,不是共享可变树。
Isolate 是内存隔离单位,不是这四条 Runner 之一。根 Isolate 默认绑在 UI Runner 上;Isolate.spawn / compute 另起 OS 线程和堆。worker 再快,也帮 Raster 画不动一个 saveLayer。
2. Vsync 如何变成一帧
Embedder 向系统注册 Vsync(Android Choreographer,iOS CADisplayLink,桌面则是合成器的呈现时钟)。信号到来:
- Engine 的 Animator 被唤醒,向 UI Runner 投递「开始帧」。
PlatformDispatcher.onBeginFrame:Animate(Ticker)。onDrawFrame:Build / Layout / Paint /compositeFrame。Scene投递 Raster Runner。- GPU 完成,buffer 交给系统合成器。
没有 Vsync,Framework 不会空转刷屏。scheduleWarmUpFrame 是启动例外:不等下一拍,尽快出第一帧,可能和系统节拍不对齐,所以第一帧时间不能拿来当稳态指标。
帧预算是显示周期:60Hz 约 16.6ms,120Hz 约 8.3ms。这个数字是 UI 和 Raster 流水线共享的节拍,不是「Dart 独享 16ms」。
3. 流水线:N 在 GPU 上,N+1 在 Dart 里
UI 处理完 N 就可以开始准备 N+1,不必等 N 上屏。这是双线程的全部意义。
// 伪时序
// t0 Vsync UI: drawFrame(N)
// t1 UI: Scene(N) 移交;UI 空闲或处理输入
// t1 Raster: 执行 Scene(N)
// t2 Vsync UI: drawFrame(N+1) Raster: 仍可能在 N
// t3 UI: 移交 Scene(N+1) Raster: 提交 N,开始 N+1
若 Raster 持续慢于节拍,Scene 排队。表现是 buildDuration 正常、rasterDuration 高、连续掉帧而不是单尖刺。这时在 Dart 里微优化 JSON 毫无意义。
反过来,UI 超时,Raster 会饿着:GPU 再快也没有新 Scene。Performance overlay 两条条形图,就是这两条 Runner 的占用,不是「CPU / GPU」的模糊标签。
流水线深度有限。为了不撕裂、为了和系统合成器对齐,提交的往往是「已经完成的 buffer」。这会引入大约一帧的显示延迟。触控跟手差,不一定是你的 build 超了 2ms,可能是流水线 + 系统合成叠了两拍。
4. SchedulerPhase:同一条 UI 线程上的相位
即使在 UI Runner 内部,一帧也被切成相位。SchedulerPhase.idle 时你可以随意 scheduleFrame;transientCallbacks 里跑 Ticker;persistentCallbacks 里跑 drawFrame;postFrameCallbacks 里这一帧已经交出。
WidgetsBinding.instance.addPostFrameCallback((_) {
// 安全读 RenderBox.size,几何已稳定
});
在 build 里读 context.size 往往还是上一帧或 null;那是相位错了,不是 RenderObject 没 layout。在 persistentCallbacks 里再同步等一份 Platform Channel 回包,等于在帧中途把 UI Runner 借给 Platform Runner,预算直接打穿。
微任务插在相位之间。Future.then、await 的后续,如果每帧产生几百个,看起来「我没在 build 里干活」,Timeline 上却是 UI 线程在帧前被微任务吃掉。这仍是 UI 病。
5. Platform Channel 为什么是「异步」
Channel 的异步不是 Dart 语法糖,是线程模型的推论:Dart 把字节丢进队列,Platform Runner 取出、调 Java/ObjC、再把字节扔回 UI 队列。你 await 到的延迟 = 排队 + 拷贝 + 原生执行。
没有共享内存。大图走 Channel 会双倍拷贝(进 Java heap,再进 Dart heap)。每帧数据不要走 Channel;零拷贝大缓冲走 FFI / TransferableTypedData / 纹理。
插件回调默认可能在 Platform 线程。在那里碰 setState 或任何 dart:ui 对象,轻则断言,重则静默损坏。必须投递回 UI:
void onNativeEvent(dynamic data) {
SchedulerBinding.instance.scheduleTask(() {
if (!mounted) return;
setState(() => _value = data);
}, Priority.touch);
}
scheduleTask 仍受帧节拍约束。一股脑把一万个原生事件打进 UI 队列,和在 build 里算一万次没区别。
Engine 侧的 Animator 还负责「这一拍要不要跳过」。UI 或 Raster 连续超时,Vsync 到来时上一帧还没提交,Animator 会跳帧:不是把积压的 Scene 一张张补画(那会越来越滞后),而是丢掉中间态,追最新一帧。所以严重卡顿时动画会「瞬移」,不是 Ticker 算错了 elapsed。FrameTiming.frameNumber 不连续、buildDuration 偶发远超预算,就是跳帧在发生。
输入路径和帧路径是两条投递。指针事件从 Platform Runner 进 UI,不必等下一个 Vsync 才能 hitTest;但由输入触发的 setState 仍要等到下一拍 drawFrame 才变成像素。这就是「跟手」的下限:事件处理可以在帧间发生,上屏不能。把重逻辑放在手势回调里同步做,等于在两次 Vsync 之间抢 UI Runner,下一帧 buildDuration 直接被吃掉。手势回调里只改值、标脏,把重活推到 worker 或下一帧的 idle 相位。
桌面和 120Hz 手机把预算砍到 8.3ms,流水线更脆:UI 用掉 6ms,Raster 只剩缝。这时「还能塞点微任务」的 Debug 直觉完全失效。SchedulerBinding.instance.schedulingStrategy 可以在负载高时推迟非用户可见的任务;业务代码更实际的做法是:触摸优先级的工作用 Priority.touch,日志上报用 Priority.idle。闲时任务跑进 transientCallbacks 之前,会表现为「一抬手下一帧就卡」,其实是 idle 工作抢了节拍。
Platform View 把四条 Runner 的互斥打开一个洞:Android 的 Hybrid Composition 会在 Platform 线程和系统合成器上再同步一次。Vsync 仍在走,但 Flutter 的 Raster 不再独占 swapchain。滚动一个内嵌 WebView,掉帧可能发生在系统合成,overlay 的 GPU 条和 UI 条都看不懂。这类问题的第一动作是把 Platform View 移出动画热路径,而不是减 Dart 分配。
多窗口 / 多 Engine 时,每份 Engine 有自己的 Animator 和 Vsync waiter。两个 Flutter 表面对同一块屏幕,可能对不齐节拍,表现为一边顺一边撕。FlutterEngineGroup 共享 VM 和 GPU 上下文,但不合并两条帧循环。混合栈里一个原生滚动 + 一个 Flutter 滚动要对齐,是 Embedder 问题,不是你在 Dart 里 vsync 对得不够准。
FrameTiming 的字段要拆开读:vsyncOverhead 是信号来了但 UI Runner 没立刻开工(被微任务或上一帧拖住);buildDuration 是 Dart 管线;rasterDuration 是 GPU 线程;layerCacheCount / 内存相关字段反映缓存。只看「掉没掉帧」会把 vsyncOverhead 误当成 build 慢,于是去拆 Widget,其实是 Channel 回包堵在 UI 队列头上。把 FrameTiming 打到日志里按字段分桶,比看平均 FPS 更接近这四条 Runner 的实情。
后台与前台切换时,Embedder 会停 Vsync waiter,Ticker 被 mute。回到前台第一拍往往伴随着 GPU 上下文恢复、纹理重新上传。这不是「Dart 启动慢」,是 Raster 在重建它不该在后台保持的资源。把重活放在 AppLifecycleState.resumed 同步做,会和恢复 Vsync 撞车,第一帧必卡。恢复路径上只标脏,让随后几拍的空闲去补纹理。
6. IO Runner:解码不是上传
图片:压缩文件在 IO Runner 解码成 RGBA,再上传成 GPU 纹理(上传常在 Raster)。把「解码」和「上传」当成一件事,会误判卡在哪。
- 解码慢:IO 队列堆积,图片迟迟不出现,UI 未必掉帧。
- 上传慢:Raster 超时,滚动掉帧,解码其实已经完成。
- 解码尺寸错误:4000×3000 × 4 ≈ 48MB,列表里几张就能把内存打穿。
cacheWidth/cacheHeight在解码时缩小,省的是原生堆和显存,不是 JPEG 文件大小。
dart:ui 的 ImmutableBuffer / ImageDescriptor 可以在 worker isolate 解码后把句柄送回。像素不要经 Channel 绕一圈。
7. 内存与生命周期:谁分配、谁释放、谁跨线程
- Dart 堆:只在其 Isolate 里可见。UI Isolate 的卡顿不会被 worker 的 GC 直接分担;worker 回传的大对象会在 UI Isolate 再分配一份。
- 原生堆:Layer、DisplayList、Codec 中间缓冲。Codec 若被
ImageCache钉住,原生像素不随 Dart 短命 Widget 死掉。 - GPU 显存:纹理、MSAA、离屏。Raster 独占。
跨线程移交必须拷贝或转移所有权。这就是 Channel 拷贝、FFI 要自己 free、Texture 要用共享 GPU 对象的原因。Engine 销毁(FlutterEngine.destroy)必须停四条 Runner、丢 GPU 上下文。Activity 销毁后仍有 Dart 定时器在 setState,是生命周期漏了,不是 Vsync 太勤。
混合 App 多 Engine:每份 Engine 一套 Runner + 一份 GPU 上下文。RSS 随页面线性涨,先查有没有 FlutterEngineGroup,再查有没有把重活放到了 Platform 线程。
8. 设计取舍(为什么是现在这样)
- 四条 Runner 而不是一条:Dart 和 GPU 流水线并行,插件不堵栅格化。代价是问题要先分流,工具也要两套(Dart Timeline + GPU 捕获)。
- 帧对齐 Vsync:省电、不撕裂。代价是输入到上屏至少隔着合成器的一拍;想「立即画」只能 warm-up 或接受撕裂。
- 单 UI Isolate 跑 Framework:无 UI 数据竞争。代价是任何同步卡顿都是全屏卡顿,重活必须离开 UI。
- Channel 拷贝语义:安全、语言无关。代价是不适合热路径大数据。
这些取舍决定了什么优化有意义。在 Platform 线程做 JSON 解析,Android 上是 ANR 候选项;在 UI 线程 dart:io 同步读文件,是自己砸节拍;在 Raster 上看不到的「Dart 优化」改变不了 rasterDuration。
9. 怎么把原理用到排障上
遇到卡顿,先钉 Runner,再改代码:
- overlay 里 UI 条高还是 GPU 条高? 高的那条就是病所在。
- 是否伴随 ANR / 主线程 watchdog? 去 Platform Runner,查插件和 Platform View,不查
setState。 - 图片是迟迟出现还是滚动掉帧? 前者偏 IO/解码,后者偏上传/Raster。
- 掉帧是否和 Channel 大包、FFI 同步调用同时出现? 看调用发生在哪条线程。
- 是否多 Engine? 线程数和 RSS 一起涨,不是单帧算法问题。
能回答这五问,线程模型就是一张「谁可以等谁」的表,Vsync 是表上的节拍器。
10. 小结
Flutter 用四条任务队列把操作系统、Dart、GPU、解码拆开,用 Vsync 对齐成帧。流水线让 UI 和 Raster 可以错开一个节拍;也让「我 Dart 很快仍掉帧」成为常态而不是悖论。下一篇进入 UI Isolate 内部,看分代 GC 怎样把「每帧 new」变成掉帧源。