把 Flutter 理解成「一套跨端 Widget」会在生产里立刻失效。setState 不会把像素推上屏幕;build() 也不会调用 Metal。真正完成「Dart 世界 ↔ 操作系统世界」翻译的,是用 C/C++ 写的 Flutter Engine。Framework(package:flutter)只是 Engine 的客户:它产出一棵 Layer 树,然后把控制权交出去。

这篇文章只讲 Engine 这一层:它怎么分层、线程怎么切、一帧像素怎么走完、内存和消息在谁手里。读完你应该能独立回答三个问题:卡顿发生在哪条 Runner、启动慢消耗的是哪块资源、多 Flutter 页面为什么会把 RSS 打爆。

1. 先画边界:Framework 不在 Engine 里

日常写的 StatelessWidget / State.setState 属于 Framework,跑在 Dart 里,源码在 flutter/lib。Engine 的源码在 flutter/engine,主体是 C++,外加少量各平台的 Java/ObjC/Swift 嵌入代码。

两者通过一张很窄的契约对话:

  • Framework → Engine:提交 Scene(Layer 树的最终形态)、注册帧回调、发 Platform Message、要纹理、要语义更新。
  • Engine → Framework:Vsync 到达、指针事件、生命周期、Platform Message 回包、栅格化完成。

dart:ui 就是这张契约在 Dart 侧的投影。window / PlatformDispatcher / SceneBuilder / PictureRecorder 看起来像 Dart API,实现几乎都在 Engine。所以:调 dart:ui 已经是在调 Engine,只是没有自己写 JNI。

这个边界一旦画清,很多误解会消失。例如「Flutter 是解释执行所以慢」——Release 里 Dart 是 AOT 机器码,慢通常不在解释器,而在你是否堵了 UI Runner,或是否让 Raster Runner 画了过多离屏层。

2. Engine 的四层,职责必须互斥

可以把一份 FlutterEngine 实例拆成四块。生产问题几乎都能归到其中一块「做了不该它做的事」。

2.1 Dart Runtime(不管像素)

Runtime 负责:

  • 加载 AOT isolate 快照(isolate_snapshot_data/instr)和 isolate 组快照
  • 跑根 Isolate(以及你 compute / Isolate.spawn 出来的子 Isolate)
  • 分代 GC、分配器、JIT(仅 Debug/Profile 的部分场景)
  • 把 Dart 的微任务、事件循环和 Engine 的 Task Runner 接上

它 不 调用 GPU,不 持有窗口。build/layout/paint 这些 Framework 工作,只是跑在 Runtime 所在的 UI Task Runner 上。Runtime 卡顿的典型原因是:同步计算、超大对象分配引发 GC、在 UI Isolate 里做本该给 worker 的活。

Isolate 是内存隔离单位,不是线程。根 Isolate 默认绑在 UI Runner 上;你新 spawn 的 Isolate 会在新的 OS 线程上跑。跨 Isolate 只能传、不能共享可变对象——这是 Dart 的模型,Engine 只是实现了它。

2.2 图形后端:Skia 或 Impeller(不管 Dart 对象)

这一层吃的是 DisplayList / 渲染指令,吐的是 Metal、Vulkan 或 OpenGL 命令。

  • Skia:通用 2D 引擎,能力面宽。代价是遇到没见过的绘制组合,可能在运行时编译着色器,移动驱动上一次编译可以是几十毫秒。
  • Impeller:Flutter 自研,图元集合封闭,着色器在构建期用 impellerc 编进包,运行时原则上不再现场编译。

两者都活在 Raster Task Runner 上。你在 Dart 里 Canvas.drawPath,并没有立刻画:那是在 UI 线程往 Picture/DisplayList 记账。真正的 fill rate、MSAA、纹理采样发生在 Raster 线程。

所以 GPU 掉帧和 Dart 掉帧是两种病。FrameTiming.buildDuration 对前者不敏感,rasterDuration 对后者不敏感。

2.3 文本、图片、字体(最容易被忽略的账单)

文本:LibTxt 做段落布局,HarfBuzz 做 shaping,ICU 做 bidi 和换行。测量发生在 UI 线程。 在 build 里对同一段超长字符串反复 TextPainter.layout,吃的就是 16.6ms 预算。

图片:压缩文件在 IO Runner 解码成 RGBA,再上传成 GPU 纹理(常在 Raster 侧完成上传)。解码尺寸是 宽 × 高 × 4,和 JPEG 文件 KB 数无关。4000×3000 的图是 48MB 量级,列表里几张就能把内存打穿。

字体:字形缓存、fallback 链、emoji 彩色字体,往往在首帧或第一次用到某语言时初始化。启动慢不一定是 Dart 代码多,可能是第一次 shaping CJK。

2.4 Shell / Embedder(唯一碰操作系统的一层)

Shell 回答这些题:

  • 窗口 Surface 从哪来、多大、像素比多少
  • Vsync 谁提供(Choreographer / CADisplayLink / 桌面合成器)
  • 触摸、键盘、IME 从哪进
  • App 前后台、Activity/ViewController 销毁时 Engine 怎么停
  • Platform View(WebView、地图)如何嵌进 Flutter 的合成

换端(Android → iOS → 桌面)理论上只换 Shell,不换 Runtime 和图形语义。这是 Flutter「一份 Dart 代码」能成立的真正原因。代价是:每个平台的 Surface、线程、后台限制都不一样,Embedder 的 bug 会表现为「只有某机型掉帧」。

3. 线程模型:四条 Task Runner

Engine 不用「一个 UI 线程包打天下」。它有四条队列,跨队列通信全是 投递任务,不是直接调函数。

Runner典型线程允许做什么绝对不要做什么
Platform系统主线程生命周期、插件、部分输入、Platform View重计算、等网络(Android 上会 ANR)
UIEngine 创建的 UI 线程Dart、手势、build/layout/paint 记账同步读磁盘、等 GPU、大 FFI
RasterEngine 创建的 GPU 线程Skia/Impeller、纹理上传、提交 swapchain跑 Dart、阻塞等锁
IO后台线程图片解码、部分文件碰 dart:ui 对象

不变量: UI 不碰 GPU 上下文;Raster 不跑 Dart;Dart 对象不得在 Raster 上解引用。Layer 树从 UI 交到 Raster 时,走的是一次线程安全的移交,不是共享可变树。

Platform Channel 的「异步」就是这个模型的直接推论:Dart 把字节丢进队列,Platform Runner 取出、调 Java/ObjC、再把字节扔回去。你 await 到的延迟 = 排队 + 拷贝 + 原生执行。没有共享内存,所以大图走 Channel 会双倍拷贝。

4. 一帧到底怎么走完

用一次用户点按钮、触发 setState 来把整条链路串起来。

  1. 输入
    系统把 pointer 交给 Shell → 转成 Engine 的指针包 → 投递到 UI Runner → Framework 做 HitTest 和手势竞技场。

  2. 标脏
    setState 只给 Element 打标。没有立刻 build,更没有立刻 draw。

  3. 等 Vsync
    Shell 从系统拿到下一拍 Vsync,唤醒 UI Runner。这就是帧的节拍器。没有 Vsync,Framework 不会自己空转刷屏(调试里的自定义 scheduler 除外)。

  4. UI Runner:Animate → Build → Layout → Paint
    产出的是 Layer 树 / DisplayList,不是 framebuffer。这一段时间计入 buildDuration。

  5. 移交
    Scene 被投递到 Raster Runner。UI Runner 可以 开始准备下一帧(流水线)。如果 Raster 还在画上一帧,可能产生排队,表现为持续掉帧而不是单帧尖刺。

  6. Raster Runner
    Impeller/Skia 执行指令、采样纹理、处理 clip/saveLayer、提交到 swapchain。时间计入 rasterDuration。

  7. 系统合成
    最终 buffer 交给 SurfaceFlinger / Core Animation。这里还有一次合成延迟,和 Flutter 内部流水线叠加,所以「触控跟手」不只取决于你的 Dart 是否小于 8ms。

关键推论:你在 Framework 里做的优化,只能缩短步骤 4。 步骤 6 的模糊、大图、过多离屏,必须减层、减像素。步骤 1/3 的问题在 Embedder。把所有卡顿都怪 Redux,是在错的层找原因。

5. 启动路径:为什么冷启动像「引擎点火」

创建一个 FlutterEngine 大致要:

  1. 加载并映射 AOT 快照(mmap 机器码,不是逐字节解析 Dart)
  2. 创建四条 Runner / 线程
  3. 初始化 Dart isolate,跑 main() 直到第一帧回调挂上
  4. 建 GPU 上下文、可能编译/绑定首批管线
  5. 等第一块 Surface 和第一次 Vsync
  6. 第一帧 layout + 字体 fallback + 首图解码

所以冷启动慢,常见账单是:快照太大、首帧字体/图片、GPU 管线第一次绑定、在 main() 里同步做插件初始化。Debug 模式还有 JIT 和检查器,不能拿来和 Release 比启动。

FlutterEngineGroup 的意义就在这里:第一份 Engine 付完 VM/GPU/字体的固定成本,后续 spawn 只再挂一个 Isolate 和一块 Surface,增量内存可以到百 KB 级。混合 App 里每页 new 一个孤立 Engine,等于每次重新点火。

6. 内存:谁分配、谁释放、谁跨线程

几块互不混淆的堆:

  • Dart 堆:Isolate 私有。GC 管。图片的 Codec 解码结果若被 ImageCache 引用,会钉在堆上。
  • 原生堆(C++):Layer、DisplayList、字体缓存、Codec 中间缓冲。
  • GPU 显存:纹理、MSAA、离屏 FBO/Texture。RepaintBoundary 缓存也在这儿。

跨线程移交必须拷贝或转移所有权,不能两边同时写。这就是 Channel 拷贝、FFI 要自己管 free、Texture 要用共享 GPU 对象的原因。

泄漏排查要先问在哪块堆:DevTools Memory 看 Dart;原生要用 instruments / Android Studio Profiler;显存看纹理数量和 ImageCache 的字节上限。三套工具,三套结论。

7. 和操作系统的另外两条管道

Platform Message(Channel)
消息、编解码、跨 Platform ↔ UI 队列。适合配置、权限、一次性调用。不适合每帧数据和零拷贝大缓冲。

FFI
同一进程、C ABI、直接函数调用。没有队列语义,默认也没有线程切换。适合计算库。指针生命周期完全由你证明。

Platform View / Texture
当必须嵌 WebView、相机、地图时,Engine 不再独占 GPU。像素通过虚拟 Display、多 Surface 合成或共享纹理进来。这是 Engine 架构上最贵的例外:破坏了「一条 Raster 线程画完所有像素」的假设。

选管道就是选一致性模型。不要用 Channel 模拟 FFI,也不要用 FFI 调 UIKit。

8. 设计取舍(为什么是现在这样)

  • 自绘而不是系统控件:像素跨端一致,动画不受 OEM 控件限制。代价是无障碍、IME、平台 View 都要 Engine 自己接一层语义树和嵌入协议。
  • 单 UI Isolate 跑 Framework:没有 UI 数据竞争,推理简单。代价是任何同步卡顿都是全屏卡顿,必须把重活赶到 worker Isolate 或原生。
  • 渲染下沉 C++:换 Shell 就能换端。代价是性能问题经常要同时看 Dart 火焰图和 GPU 捕获。
  • 帧对齐 Vsync:省电、不撕裂。代价是输入到上屏至少隔着合成器的一拍。

这些取舍决定了「什么优化有意义」。例如在 Dart 里微优化 JSON 解析,远不如让 JSON 离开 UI Isolate;在 Widget 层换状态库,远不如减少 saveLayer。

9. 怎么把原理用到排障上

遇到问题,按层提问,不要一上来改业务代码:

  1. 是 UI 超时还是 Raster 超时? FrameTiming 一拆就知道。方向完全相反。
  2. 是启动还是稳态? 启动看快照、字体、首管线;稳态看每帧分配、图层、图片尺寸。
  3. 是单 Engine 还是多 Engine? RSS 随页面线性涨,先查有没有 EngineGroup。
  4. 是 Dart 堆、原生堆还是显存? 工具不同。
  5. 有没有平台 View / Channel 大包? 这些走的是例外路径。

能回答这五问,Engine 对你就不再是黑盒,而是一张有边界的地图。

10. 小结

Flutter Engine 是运行时、渲染器、资源系统和操作系统嵌入层的合体。Framework 产出场景描述,Engine 负责在正确的线程上、用正确的内存模型,把场景变成屏幕上的 buffer。

跨端能力来自 Shell 可替换;流畅来自 UI/Raster 流水线;可预测的帧时间来自「Dart 不碰 GPU、GPU 不跑 Dart」。把问题归到这一层,后面讲管线、Impeller、Isolate、Channel 才有落点。下一篇会沿着第 4 节的步骤 4,把 Animate / Build / Layout / Paint 拆开。