把 Flutter 理解成「一个 UI 线程包打天下」,会把三种完全不同的病当成同一种:Dart 超时、GPU 超时、平台通道堵在主线程。运行时是 四条 Task Runner,靠 Vsync 对齐;跨队列通信全是投递任务,不是直接调函数。搞混「谁可以等谁」,优化就会改到错误的代码。

读完你应该能独立回答三个问题:卡顿发生在哪一条 Runner、为什么 Dart 已经很快仍可能掉帧、插件回调里 setState 为什么有时要先切回 UI。

1. 先画边界:四条队列,职责必须互斥

Runner典型线程允许做什么绝对不要做什么
Platform系统主线程生命周期、插件、部分输入、Platform View重计算、等网络(Android 上 ANR)
UIEngine 创建的 UI 线程Dart、手势、build/layout/paint 记账同步读磁盘、等 GPU、大 FFI
RasterEngine 创建的 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,桌面则是合成器的呈现时钟)。信号到来:

  1. Engine 的 Animator 被唤醒,向 UI Runner 投递「开始帧」。
  2. PlatformDispatcher.onBeginFrame:Animate(Ticker)。
  3. onDrawFrame:Build / Layout / Paint / compositeFrame。
  4. Scene 投递 Raster Runner。
  5. 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,再改代码:

  1. overlay 里 UI 条高还是 GPU 条高? 高的那条就是病所在。
  2. 是否伴随 ANR / 主线程 watchdog? 去 Platform Runner,查插件和 Platform View,不查 setState。
  3. 图片是迟迟出现还是滚动掉帧? 前者偏 IO/解码,后者偏上传/Raster。
  4. 掉帧是否和 Channel 大包、FFI 同步调用同时出现? 看调用发生在哪条线程。
  5. 是否多 Engine? 线程数和 RSS 一起涨,不是单帧算法问题。

能回答这五问,线程模型就是一张「谁可以等谁」的表,Vsync 是表上的节拍器。

10. 小结

Flutter 用四条任务队列把操作系统、Dart、GPU、解码拆开,用 Vsync 对齐成帧。流水线让 UI 和 Raster 可以错开一个节拍;也让「我 Dart 很快仍掉帧」成为常态而不是悖论。下一篇进入 UI Isolate 内部,看分代 GC 怎样把「每帧 new」变成掉帧源。