每个 FlutterEngine 都带一份 Runtime、线程、字体和 GPU 资源。在原生壳里每开一个 Flutter 页面就 new FlutterEngine(),内存和启动时间都会按页线性涨。FlutterEngineGroup 要解决的不是「引擎启动优化开关」,而是 哪些资源在进程里只能有一份,哪些状态必须按页面隔离。共享错了会串数据,隔离错了会把 RSS 打爆。
读完你应该能独立回答三个问题:Group 到底共享了什么、为什么插件 static 会在第二页出错、销毁 Engine 和销毁 Group 为什么不能当成一回事。
1. 先画边界:一份 Engine 有多贵
冷拉起一份孤立 Engine 大致要付:
- 映射 AOT 快照(
isolate_snapshot_data/instr、vm snapshot)——可以 mmap,但仍占虚拟地址和启动期缺页。 - 创建 Platform / UI / Raster / IO 四条 Runner,至少两条专用 OS 线程。
- 启动根 Isolate,跑
main(),注册插件,挂帧回调。 - 建 GPU 上下文、字体库、shader/PSO 缓存。
- 等第一块 Surface 和第一次 Vsync,画出第一帧。
企业混合 App 里「设置、钱包、客服、搜索」四个入口各 new 一次,等于四次点火。RSS 上常见的现象是:每多一个 Flutter 容器,多十几到几十 MB,而业务 Dart 代码根本没那么大。多出来的是 重复的运行时。
FlutterEngineGroup(Android io.flutter.embedding.engine.FlutterEngineGroup,iOS FlutterEngineGroup)把「进程级」和「页面级」切开。
2. 共享什么、隔离什么
Group 内第一个 Engine 完整初始化。后续 spawn / makeEngine:
| 类别 | 共享(进程 / Group) | 隔离(每个 Engine) |
|---|---|---|
| VM / AOT | 快照映射、VM 内部结构 | 根 Isolate、Dart 堆 |
| 线程 | 部分线程池、IO 能力 | 自己的 UI/Raster 调度与 Surface 绑定 |
| 字体 | 字体管理器、文件映射 | 当前用到的段落缓存可能仍按引擎 |
| GPU | 设备、部分管线/上下文 | 自己的窗口 Surface、swapchain |
| 插件 | 类加载、so | Channel 注册表、插件实例状态 |
| 语义 / 恢复 | 无 | 自己的 Semantics、Restoration |
官方数字随版本变,数量级是:第一份 Engine 仍是十几到几十 MB,后续增量可以到 百 KB 到一两 MB,而不是再来一份 30MB。启动从「再起 VM」变成「再挂一个 Isolate + 一块 Surface」。
不变量:
- Dart 堆不共享。 Isolate 内存隔离仍在。你不能在页面 A 的
static里指望看见页面 B 的对象——static是 Isolate 级,不是进程级。每个 Engine 一个根 Isolate,就有一份自己的static。 - C 全局 / Java static 是进程级。 插件用
static MethodChannel或 C 单例,会在 Group 里串。这是混合架构里第一常见的 bug。 - GPU 共享不等于可以同时往同一块 Surface 画。 每个 Engine 仍要自己的渲染目标。
- Group 活着,共享资源就活着。 最后一个子 Engine destroy 不会拆 Group。
// Dart 侧:这个 static 只在「当前 Engine 的根 Isolate」里唯一。
// 另一个 Flutter 页面是另一个 Isolate,看不到它。
class Session {
static String? token;
}
原生侧 Java static String token 则两页都能看见。同一名字,两种寿命,这就是串数据的来源。
3. 线程与入口:spawn 不是 fork
makeEngine 不会 fork 进程。它在已有 VM 里创建一个新的根 Isolate(属于同一 Isolate Group 的细节随版本演进,但对外表现是:独立的 Dart 堆、独立的事件循环、独立的 main 入口)。
入口可以和第一份 Engine 不同:FlutterEngineGroupOptions / DartEntrypoint 指定 main 或 settingsMain。这允许「设置页只跑设置库」。注意:AOT 里没用到的代码仍可能在快照里,入口不同省的是启动期执行和堆上的单例,不一定省包体。
每份 Engine 仍有自己的 UI Runner 语义。两份 Engine 同时可见(例如原生 ViewPager 里两个 FlutterView)时,会有两套 build/layout/paint 和两套 Raster 工作。Group 省的是固定成本,不是「两页同时画只付一份 GPU 时间」。产品上能离屏的页面应暂停渲染:FlutterView detach 或 lifecycleChannel.appIsPaused。
Platform Runner 仍是进程里那条系统主线程。所有 Engine 的插件默认挤在同一条主线程上。四个 Engine 同时 invokeMethod 做重活,ANR 是共享的。Group 不隔离主线程。
4. 插件:按 Engine 实例写,禁止进程单例
FlutterPlugin 的正确形状:
public class DevicePlugin implements FlutterPlugin {
private MethodChannel channel;
@Override
public void onAttachedToEngine(FlutterPluginBinding binding) {
channel = new MethodChannel(binding.getBinaryMessenger(), "app.device");
channel.setMethodCallHandler((call, result) -> { /* 用 binding.getApplicationContext() */ });
}
@Override
public void onDetachedFromEngine(FlutterPluginBinding binding) {
channel.setMethodCallHandler(null);
channel = null;
}
}
错误形状:static MethodChannel、static 保存 Activity、在 registerWith 里假设永远只有一次调用。第二份 Engine attach 时会覆盖第一份的 handler,或第一份 detach 时把第二份的 handler 清掉。
Activity 感知用 ActivityAware,并且记住:Group 里多个 Engine 可能对应不同 Activity,也可能同一个 Activity 里两个 FlutterView。把 Activity 存 static 会泄漏和发错窗口。
Dart 插件同样:static const channel = MethodChannel('x') 可以,因为 Channel 对象只是句柄,messenger 来自当前 Engine 的 window / ServicesBinding。危险的是 Dart static 缓存「原生已初始化」——那份缓存跨不出 Isolate,会导致第二页再初始化一次,或误以为已经初始化。
5. 生命周期:谁 create、谁 destroy、谁 pause
混合容器的典型寿命:
- 进程启动:创建 一个 Group(Application / AppDelegate 级单例)。
- 打开 Flutter 页面:
group.makeEngine(...),attach 到FlutterView/FlutterViewController。 - 页面不可见:通知 Engine
AppLifecycleState.paused/ 暂停渲染,不要 destroy——回来才快。 - 页面销毁:
engine.destroy()。Group 留下。 - 进程退出:Group 才拆。
把 4 写成「destroy Group」等于下一次打开又冷启动,Group 白做。把 3 写成 destroy Engine,等于放弃「百 KB 增量」里最值钱的那部分——Isolate 可以重建,但第一帧仍要走 main 和首屏 layout。
Android 配置变更(旋转)不要重建 Group。Engine 可以留,Surface 重建。iOS 内存警告时,可以 destroy 不可见的子 Engine,保留 Group 和当前可见的那一份。
多 Flutter 页面共享数据:不要靠 static。用原生侧的进程级存储(或 Channel 到原生单例),或显式 IPC。Isolate 不共享堆是特性,不是 Group 的缺陷。
6. 内存账本:怎么证明 Group 在工作
不要只看 Dart DevTools。DevTools 连的是 当前 Isolate,看不见另一份 Engine 的堆,也看不见 GPU。
该看的三张图:
- RSS / PSS(系统):打开第二页,增量应远小于第一页。若仍涨 20MB+,多半没有走 Group,或插件在 native 又建了一份缓存。
- Java / ObjC 堆: 插件泄漏的 Activity、WebView、Bitmap。
- GPU: 两块 Surface 同时存在时纹理翻倍是正常的;离屏页仍有全屏纹理就不正常。
第一份 Engine 的字体、shader 缓存会留在 Group 里。这是好事(第二页首帧少编译),也意味着 Group 本身不是零成本。进程里永远挂一个 Group + 零个子 Engine,仍可能留着 GPU 上下文。若产品「一年才开一次 Flutter」,可以考虑用完拆 Group;若一周开几次,留着。
7. 设计取舍
- 共享 VM/GPU,隔离 Isolate: 混合 App 能承受「很多 Flutter 入口」。代价是插件必须可重入,Dart
static不能当跨页总线。 - 一个 Group 单例: 实现简单。代价是 Group 的 GPU/字体生命周期等于进程,内存基线略高。
- 每页可选不同 entrypoint: 模块化。代价是仍一份 AOT,包体不一定小;还要维护多入口的初始化顺序。
- 不共享 Element 树: 不能把「半个 Flutter 页面」挪到另一个 Engine。跨页 UI 状态只能序列化,不能搬对象。
和「单 Engine + 自绘路由」比:单 Engine 更省,适合 Flutter 是主 UI 的 App。Group 适合 原生是壳、Flutter 是多模块 的企业应用。用错场景会两边不讨好:主 Flutter App 里再套 Group,多 Isolates 同时跑 UI,得不偿失。
8. 排障
- 第二页 RSS 仍很大: 确认容器调的是
FlutterEngineGroup.makeEngine,不是new FlutterEngine。老模块、FlutterFragment 默认参数、自己缓存的孤立 Engine,都会绕过 Group。 - 第二页插件失效 / 第一页突然失效: 搜插件里的
static。按实例改onAttachedToEngine。 - Dart
static状态「丢了」: 不是丢,是另一份 Isolate。跨页状态放原生或磁盘。 - 同时打开两页掉帧: 两份 Raster 在跑。离屏暂停;不要做并排两个全屏 FlutterView 还各自动画。
- 销毁后崩溃: 回调进已 destroy 的 Engine。插件 detach 必须卸 handler;原生异步回包要弱引用 Engine。
- iOS 第二次
run失败: 同一 Engine 不能run两次。spawn 新的,或设计成 Engine 复用但不重复 run。 - 启动仍慢: Group 只省第二份及以后。第一份仍要看快照大小、首帧字体、插件同步初始化。别把首启问题算到 Group 头上。
9. 和 FlutterFragment / 路由的关系
Android FlutterFragment 可以接收外部 Engine,也可以自己造。自己造时若不传入 Group,就是孤立 Engine。把 Group 放在 Application,Fragment 只 attach,是混合栈里最不容易写错的结构。
路由:每个 Engine 自己的 Navigator 栈。原生返回键要决定发给哪一份 Engine。不要假设「只有一个 WidgetsApp」。Restoration id、Channel 名、通知权限回调,全部带 engine / page id。
10. 和 FlutterEngineCache、Activity 预热的关系
Group 不是 Android 上唯一的「复用引擎」API。FlutterEngineCache 是一份手动 Map:你自己 put("main", engine),FlutterActivity 用 cachedEngineId 取。它 不 自动共享 VM 初始化——如果你 cache 的是孤立 Engine,第二份仍要你自己从 Group spawn 再放进 cache。
正确组合:
Application.onCreate(或按需)创建 一个FlutterEngineGroup。- 冷启动预热:
group.createAndRunEngine(...)得到第一份,放进FlutterEngineCache,跑完main但不 attach 视图。用户点入口时 attach,首帧只剩 Surface 和 build。 - 第二入口:
group.createAndRunEngine再 spawn,不要new FlutterEngine。 - 页面销毁:视产品决定 destroy 还是放回 cache。Cache 里挂着一份已 run 的 Engine,占的是一份 Isolate 堆 + Surface 未 attach 时较少的 GPU。常驻一个预热 Engine 适合「一天打开几十次」的入口;不适合十个入口各挂一份。
FlutterActivity.withCachedEngine 不会帮你 spawn。传错 id 会崩溃。预热 Engine 的 DartEntrypoint 必须和将要展示的页面匹配,否则你要在 Dart 里再走一套原生通知来切换根路由——这套要自己做,且必须可重入。
iOS 上 FlutterEngineGroup.makeEngineWithEntrypoint 类似。FlutterViewController 可以接收外部 engine。预热时注意:没 attach 的 Engine 默认可能不跑帧,这是省电;第一次 attach 仍有首帧 layout。把预热做到「跑过第一帧并 FlutterEngine.sendOnPlatformMessage 把首屏数据拉好」,用户感知才是秒开。只 run 而不 build 首屏,省掉的只是 VM,不是第一帧。
Debug / JIT:Group 的增量优势仍在,但绝对数字难看。不要用 Debug 的 RSS 差去向老板证明 Group 没用。Profile/Release + 真机。
dart-entrypoint-args 和 dartDefines 是进程级的。Group 里两份 Engine 想要不同 flavor 配置,靠 args 传进 main(List<String> args),不要靠改 defines——defines 编译期就冻了。
11. Isolate Group、AOT 快照和「看起来像共享的 static」
Engine 文档里的 isolate group 指 VM 可以共享 只读 的程序结构(AOT 代码、部分元数据)。Heap 仍按 isolate 隔离。所以:
- 同一份机器码在内存里一份(mmap)。这是 Group 省 RSS 的大头之一。
- 类的静态字段:每个 isolate 一份。
static计数器两页互不影响。 native扩展里用 C 全局变量:一份,两页都看见。File/mmap只读资源:操作系统页缓存天然共享,这和 Group 无关,孤立 Engine 也共享。
因此「共享」有三层,写进注释才不会被下一任拆错:
- OS:文件页、GPU 设备。
- VM/Group:AOT、字体管理、线程池。
- 你的代码:必须显式。Channel 到原生单例,或磁盘。
错误地以为「Group 了所以 Dart static 共享」,会把会话做在 static String? token 上,第二页登出第一页还显示已登录——或者反过来。Java static 才会这样串。Dart 不会。两边的修法相反:Dart 要 加 跨页总线;Java 要 拆 静态。
后台 isolate(Isolate.spawn / compute)挂在 当前 Engine 的 VM 下,但不是一个新 Engine。它没有自己的 UI、没有 Channel(除非你 ensure binary messenger)。不要为每个 Flutter 页面 spawn 一套常驻 worker 还不关掉:Group 省下的内存会被 worker 堆吃回去。worker 的寿命跟任务走,不跟页面走,除非它持有该页的指针。
预热第一帧:Group 省的和省不了的
Group 把 VM 点火从第二页账单里划掉。第一页仍然要:
- 跑
main直到runApp - 注册插件(Dart 和原生)
- 首屏
build/layout/paint - 字体 fallback、首图解码、PSO bind
预热若只 runEngine 而不 attach、不让首屏 build,第二次 attach 仍要付 layout。真正的秒开预热是:在 Application 空闲时 attach 到一块 1×1 或 offscreen 的 FlutterView,等到 addPostFrameCallback 第一帧,再 detach,Engine 留在 cache。注意:offscreen 仍可能触发 GPU 资源分配。测 RSS。部分系统对不可见 Surface 节流,第一帧其实没跑完——要用 WidgetsBinding 的帧回调确认,不要 sleep(500)。
多入口预热两份 Engine,内存回到「两份堆」。只预热最高频入口。其它入口走 Group spawn 的「百 KB + 跑 main」,通常可接受。
插件里的 Activity 与 Context
onAttachedToEngine 给的是 ApplicationContext。ActivityAware 的 onAttachedToActivity 才有窗口。Group 下:
- Engine 预热阶段可能 没有 Activity。插件在 attachToEngine 时碰
Activity会 NPE。 - Engine 可以从 Activity A detach 再 attach 到 B(少见但合法)。存 Activity 必须在
onDetachedFromActivity清空。 - 两个 Engine 两个 Activity(分屏),Java static Activity 必错。
FlutterPluginBinding.getTextureRegistry() 也是 每 Engine 一份。相机纹理 id 不能跨 Engine 使用。把 id 当 int 塞 SharedPreferences 再给另一页,是野指针的 Flutter 版。
销毁崩溃:弱引用和消息队列
engine.destroy() 之后,UI Isolate 死了,原生还可能有:
- 网络回调
result.success - 传感器 EventChannel sink
- 延迟
Handler.post
这些必须是弱引用 Engine / Channel,失败就 return。Crashlytics 里 FlutterJNI 在 destroy 后 invoke 是混合栈第一崩溃。Group 让 Engine 生灭更频繁,这类 bug 从「偶发」变成「必现」。代码审查清单就一条:所有异步回包看 Engine 是否 still attached。
DartExecutor、JNI、以及「run 只能一次」
Android 上 FlutterEngine.getDartExecutor() 在 run 之后才 isExecutingDart。Channel 在 run 之前注册是合法且推荐的——这样 main 里第一句 invoke 不会 MissingPlugin。run 第二次会失败:一份 Engine 一个根 Isolate,不能重新 main。要换入口就 spawn 新 Engine。热重载是 Debug 服务协议,不是 run 两次。
FlutterJNI 是 Java 和 C++ Engine 的边界。destroy 后 JNI 指针置空。任何仍持有 FlutterEngine 引用的异步回调都要先问 engine.getDartExecutor().isExecutingDart()。Group spawn 的 Engine 各有各的 JNI。发错 Engine 的 JNI 会把消息送进另一份 Isolate,表现为「偶发状态错乱」而不是立刻崩溃。
iOS 的 FlutterEngine runWithEntrypoint 同样一次性。FlutterEngineGroup 的 makeEngine 返回的是已经或即将 run 的实例,视 API 而定——以当前版本头文件为准,不要假设和 Android 对称。读头文件比读两年旧博客安全。
内存基线怎么写进预算
给技术经理的数字应分三行,而不是一个「Flutter 占 40MB」:
- 进程里 第一个 Flutter Engine(含 GPU/字体/AOT):例如 20~40MB,随包体和首屏变。
- Group 内 每个额外 Engine:目标 < 2MB + 该页 Dart 堆 + 该页纹理。
- 每个可见
FlutterView的全屏纹理:宽×高×4×缓冲数。
第 3 行经常比第 2 行大。两个并排可见的 Flutter 页,贵的是两块 swapchain,不是两份 VM。优化「同时可见」比优化「Group 开关」更值钱。预算表里写「最多同时可见 1 个 Flutter 全屏」,比写「必须用 Group」更能保住 RSS。
入口函数的初始化幂等
void main() => runApp(MyApp()) 和 void walletMain() => runApp(WalletApp()) 都可能 WidgetsFlutterBinding.ensureInitialized()。绑定是 per-isolate 的,两份 Engine 各做一次没问题。危险的是原生侧在 main 里通过 Channel 做「只该发生一次」的事(初始化崩溃上报、改 WebView 数据目录)。改成原生 Application.onCreate 做一次,Dart 只登记。幂等写在有进程寿命的那一层。
路由谁来当:原生栈还是 Dart 栈
Group 架构下有两种合法模型,混用会丢返回栈。
模型 A:原生导航为主。每个 Flutter 页面一个 Engine、一个 FlutterViewController / FlutterFragment。返回键先问原生。Dart 里尽量单页,不在内部再 push 很深。Restoration 按 Activity。
模型 B:一个常驻 Engine,Dart Navigator 很深,原生只是壳。Group 用得少。返回键始终交给 Flutter。
错误模型:每个原生页面一个 Engine,同时 Dart 里还有深层 Navigator,返回键有时 pop Dart 有时 pop 原生。用户会卡在「空白 Flutter 页」。写进导航文档:这一页的返回归属。
冷启动预加载插件
插件 onAttachedToEngine 若同步读磁盘,第一份 Engine 和每一份 spawn 都会付。改成懒加载:第一次方法调用再读。Group 的 spawn 才轻。启动期打 log 看 attach 耗时,超过几毫秒的插件列名单治理。
如何证明「我们在用 Group」
代码搜 new FlutterEngine( / FlutterEngine( 构造。测试里开第二页打 RSS。文档里画「Application 持有 Group」的图。这三件缺一,半年后就会有人复制旧模板 new FlutterEngine 进来。CI 搜禁止的构造函数,比 code review 记忆可靠。
EngineGroup 落地失败,通常不是 API 不会用,是 组织问题:各业务线复制了一份「启动 Flutter」的原生模板,模板里 new FlutterEngine。治理是提供 唯一 的 FlutterContainer SDK:内部 Group、cache、生命周期、返回键、restoration 前缀、插件注册。业务只 open("wallet")。SDK 的测试包括:连续打开关闭 50 次 RSS 不线性涨、第二页插件可用、destroy 后无 JNI 崩溃。没有这层 SDK,Group 只存在于你的博客,不存在于仓库。
Dart 侧配套:禁止把跨页状态放 static;提供 NativeSession Channel 读进程级会话。文档用「Isolate 级 / 进程级」词汇,不要用「全局」。全局在两种语言里寿命不同,这是本文反复强调的那条不变量。新人入职第一课就讲,能省掉半年的串数据 bug。
性能上,Group 让「Flutter 模块化」在内存上成立,但不让「同时跑三个重动画模块」成立。模块化是代码和启动的切分,不是 GPU 的切分。同时可见的模块数仍要配额。和 Platform View 配额同一思想:共享固定成本之后,可变成本是 Surface 和 Dart 堆。可变成本用产品约束,不是用更多引擎技巧。技巧已经用到 Group 了。再往后是少开、暂停、销毁不可见页。
EngineGroup 与热更新、动态下发 isolate 快照(若业务做了)冲突很大。Group 假设进程内 AOT 一致。动态下发第二份不兼容快照,不能塞进同一 VM。那时应回到孤立 Engine 或独立进程,不要硬塞 Group。能力边界写进架构:Group 用于「同一产物内的多入口」,不是「运行时换引擎版本」。
日志:每份 Engine 的 debugLabel / dartEntrypoint 打进崩溃上报自定义键。否则 Crashlytics 里全是 main,看不出是钱包还是客服。Native 崩溃带 engine id。Dart 错误带 Isolate.debugName。运维才能把崩溃分到模块负责人,而不是全部飞给基础组。
灰度:新模块用 Group spawn,旧模块仍孤立,RSS 曲线会怪。迁移期接受,但仪表盘按「是否 Group」分维,否则你会以为 Group 无效。迁移完成下线孤立构造函数。留着两个世界,半年后全是孤立的——因为复制粘贴更熟。
与测试:widget 测试不启动 Group。集成测试在 Android 上要用真实 Application 单例。机器人测试反复进出 Flutter 页,盯 PSS。没有这一条,泄漏只会在用户旅程里出现。把「进出 50 次 PSS 斜率」当发布门禁,比看一次打开的绝对值更接近 Group 的目标。
Engine 的 destroy 是同步还是异步随平台略有差别,但业务必须当「调用后立刻不能再发 Channel」。把 destroy 和页面动画并行(边 pop 边 destroy)会让动画最后一帧的回调打进已死 JNI。先等 Flutter 页 paused 且动画结束,再 destroy。体感上多 100ms 卸载,换稳定。反过来省这 100ms,换一周一次的崩溃,不划算。
预热过度:Application onCreate 里同步 makeEngine 会拖长进程启动,系统启动指标变差。预热应 idle 或首帧后。用 reportFullyDrawn 之后再预热第二入口。启动和预热抢 CPU,两者都难看。错峰是和 Impeller PSO、字体一样的技巧,用在 Engine 上同样成立。
Dart main 里同步 Channel 等原生配置,会把 spawn 的「快」抵消掉。配置预读进原生内存,main 里同步读一份已经准备好的 map,或用编译期 define。spawn 路径要像热路径一样审:每一毫秒都在用户点入口到看见页之间。
EngineGroup 的文档要画三张寿命图:进程、Group、Engine。招聘和交接只靠口口相传,三个月后必有人 destroy Group。图挂在仓库 README。代码里 Group 的持有者类型名叫 FlutterEngineGroupHolder 这种丑但搜得到的名字,不要叫 Helper。搜得到才能治理。
与灰度发布:原生壳先发、Flutter 模块后发时,entrypoint 可能不存在。spawn 失败要有降级(隐藏入口或跳旧原生页),不要崩溃。失败 code 打点。模块化让版本矩阵变二维,错误处理必须按二维写。Group 不负责这个,壳负责。但文章必须提到,否则落地时会怪 Group 「不稳定」。不稳定的是版本矩阵,不是 spawn API。
EngineGroup 与推送拉起:推送点开要进某 Flutter 页。此时进程可能没有 Group。冷启动路径:Application 建 Group → spawn 对应 entrypoint → 把推送 payload 当初始路由参数。不要先起默认 main 再跳,会闪首页。参数走 dartEntrypointArgs 或 Channel 一次。和 restoration、深度链接排优先级:推送通常赢。三路进同一页的代码要共用「解析参数 → 建页」,不要三套。Group 只负责 Engine 从哪来,不负责路由语义。路由语义仍是你的导航模块。两者接口是 entrypoint 名 + args 列表。接口稳定,两边才能独立演进。
回到内存数字:第一份 Engine 的几十 MB 是交税,后面每一份只交 Dart 堆和纹理。税交在 Application,不交在每一个 Fragment。谁 new 孤立 Engine,谁就在重复交税。CI 搜构造函数,就是查偷税。这比喻不好听,但好记。好记的纪律才能在业务线复制模板时活下来。Group 的 API 很简单,简单到会被忽略。忽略的代价按页线性出现在 RSS 曲线上。曲线一陡,再来看这篇文章,会发现共享和隔离那张表其实已经把答案写死了。写死的东西,用自动化守,不要用记忆守。
EngineGroup 不是缓存技巧,是混合 App 的内存模型声明:VM 级一份,页面级多份。声明要写进原生 SDK 的类型系统,写成 FlutterContainer 只能从 Holder 拿 Engine,写不成 new FlutterEngine。类型系统比 wiki 硬。硬了,RSS 才不会在业务狂奔时按页抬头。抬头时再优化插件 static,已经是第二层问题。第一层是有没有 Group。先有第一层,再清第二层。两层都清,增量内存才会落到百 KB 到一两 MB 那个数量级。数量级对了,产品才敢把更多模块写成 Flutter,而不怕壳被内存打死。敢,是架构的产出。不敢,是孤立 Engine 的遗产。遗产用 CI 扫掉,用 Holder 接住。接住之后,这篇文章的表才变成代码。
Holder 还要负责 restoration 前缀和 debugLabel。两个字符串漏了,状态会串、崩溃会上错门。字符串从页面 id 来,页面 id 从产品模块名来,不要从 class hash 来。hash 会变。模块名稳定。稳定 id 是 Group、Channel、语义、恢复共用的钥匙。一把钥匙开多篇文章里的锁。钥匙乱了,四篇文章的机制会同时表现为「偶发」。偶发是 id 不稳的别名。Holder 把 id 稳住,偶发会少一半。另一半在插件 static。static 清完,偶发才变成可复现。可复现才能修。修完,Group 才从PPT变成 RSS 曲线上的平台阶。台阶是你想要的形状。线性斜坡是孤立 Engine 的形状。看形状,就知道代码走的是哪条路。
看 RSS 形状判断路有没有走对:平台阶是 Group,斜坡是孤立。发版前画一张「打开第 n 个 Flutter 入口后的 PSS」。n=1 可以高,n=2 必须矮很多。矮很多,Holder 才算活着。活着,税才只交一次。一次税,换无数模块。这是混合架构敢继续用 Flutter 的算术。算术写进发布检查,和功能列表并列。并列了,内存才是功能的一部分,不是事后账单。事后账单总是来得太晚,晚到模块已经铺开。铺开再合并 Group,比一开始 Holder 贵十倍。十倍的差,就是这篇要挡的那笔钱。
钱在 RSS 里,不在周报里。周报可以写「已接入 Group」,曲线仍是斜坡。只认曲线。曲线是 Holder 的验收。验收不过,入口不准扩。不准扩,是为了以后还能扩。还能扩,才是这篇算术的正循环。循环从形状来。形状从代码来。代码从搜不到 new FlutterEngine 来。搜不到,才算完。
搜不到孤立构造,Holder 才算安装完成。
12. 小结
EngineGroup 把 VM、字体、GPU 设备变成进程级单例,把 Isolate、Surface、Channel 表、语义树留在页面级。内存模型是「共享不能重复的,隔离必须隔离的」。插件 static 和 Dart static 寿命不同,这是 Group 世界里的第一不变量。销毁 Engine 不等于销毁 Group;暂停渲染不等于销毁 Engine。把这三层寿命画清楚,混合 App 的 RSS 才不会随页面线性上涨。