把 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) |
| UI | Engine 创建的 UI 线程 | Dart、手势、build/layout/paint 记账 | 同步读磁盘、等 GPU、大 FFI |
| Raster | Engine 创建的 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 来把整条链路串起来。
-
输入
系统把 pointer 交给 Shell → 转成 Engine 的指针包 → 投递到 UI Runner → Framework 做 HitTest 和手势竞技场。 -
标脏
setState只给 Element 打标。没有立刻 build,更没有立刻 draw。 -
等 Vsync
Shell 从系统拿到下一拍 Vsync,唤醒 UI Runner。这就是帧的节拍器。没有 Vsync,Framework 不会自己空转刷屏(调试里的自定义 scheduler 除外)。 -
UI Runner:Animate → Build → Layout → Paint
产出的是 Layer 树 / DisplayList,不是 framebuffer。这一段时间计入buildDuration。 -
移交
Scene被投递到 Raster Runner。UI Runner 可以 开始准备下一帧(流水线)。如果 Raster 还在画上一帧,可能产生排队,表现为持续掉帧而不是单帧尖刺。 -
Raster Runner
Impeller/Skia 执行指令、采样纹理、处理 clip/saveLayer、提交到 swapchain。时间计入rasterDuration。 -
系统合成
最终 buffer 交给 SurfaceFlinger / Core Animation。这里还有一次合成延迟,和 Flutter 内部流水线叠加,所以「触控跟手」不只取决于你的 Dart 是否小于 8ms。
关键推论:你在 Framework 里做的优化,只能缩短步骤 4。 步骤 6 的模糊、大图、过多离屏,必须减层、减像素。步骤 1/3 的问题在 Embedder。把所有卡顿都怪 Redux,是在错的层找原因。
5. 启动路径:为什么冷启动像「引擎点火」
创建一个 FlutterEngine 大致要:
- 加载并映射 AOT 快照(mmap 机器码,不是逐字节解析 Dart)
- 创建四条 Runner / 线程
- 初始化 Dart isolate,跑
main()直到第一帧回调挂上 - 建 GPU 上下文、可能编译/绑定首批管线
- 等第一块 Surface 和第一次 Vsync
- 第一帧 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. 怎么把原理用到排障上
遇到问题,按层提问,不要一上来改业务代码:
- 是 UI 超时还是 Raster 超时?
FrameTiming一拆就知道。方向完全相反。 - 是启动还是稳态? 启动看快照、字体、首管线;稳态看每帧分配、图层、图片尺寸。
- 是单 Engine 还是多 Engine? RSS 随页面线性涨,先查有没有 EngineGroup。
- 是 Dart 堆、原生堆还是显存? 工具不同。
- 有没有平台 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 拆开。