Flutter 混合页面的启动优化很容易陷入一个直觉:Engine 越早创建、首帧越早预渲染,页面就一定越快。但用户看到的不是 Engine 的状态,而是一段由原生导航、Flutter 启动、业务初始化和 GPU 渲染共同组成的时间线。
预热可能提前了 Dart 代码执行,却也可能在点击后的关键路径上增加一次 offscreen layout;等待预渲染可以消除黑屏,却把渲染和页面打开强制串行;不等待首帧则速度更快,但动画结束和首帧之间可能露出容器底色。
所以启动优化的第一步不是决定“要不要预热”,而是把用户动作到可交互画面的每个阶段放到同一时钟下,再区分三个指标:
- 启动耗时:从用户触发到首帧实际呈现;
- 视觉连续性:导航动画结束后是否出现黑屏、白屏或明显跳变;
- 资源成本:为了预热提前占用多少 CPU、内存和 Engine 生命周期。
一、什么才算启动完成
“首帧”至少有三个容易混淆的时点:
- Flutter framework 完成一次 build;
- raster 线程完成首帧栅格化;
- 该帧真正出现在已经 present 的页面上。
如果 Engine 在不可见的 View 上完成了首帧,framework 和 raster 指标都结束了,但用户仍什么也没看到。反过来,原生页面已经 present,只显示一个稳定的占位背景,视觉上不算黑屏,却也还不是 Flutter 内容。
建议把启动结束定义为“目标 Flutter 内容的首帧提交并可见”,同时单独记录“可交互”时点。对于首帧后仍会执行大量同步初始化的页面,只看首帧可能掩盖点击无响应。
二、建立统一的启动时间线
一个混合页面可以标记这些节点:
T0 user_tap
T1 engine_group_create_start/end
T2 engine_create_start/end
T3 dart_entry_start/end
T4 native_dependencies_ready
T5 view_controller_created
T6 present_animation_start
T7 flutter_view_attached
T8 first_widget_build
T9 first_frame_rasterized
T10 present_animation_end
T11 first_frame_visible
T12 first_interaction_ready
这些点必须使用同一单调时钟。不要一部分使用墙上时间,一部分使用 Dart DateTime.now(),否则系统校时和跨语言时钟基准会让区间出现负数。
跨语言上报可以只传相对于 T0 的微秒数。每次启动生成唯一 startup_id,原生与 Dart 都使用同一个 ID,服务端再按 ID 合并。这样既避免时钟对齐问题,也能发现某一侧标点缺失。
一个轻量的标记接口
abstract final class StartupTrace {
static void start(String entrance);
static void requireMark(String name);
static void mark(String name, [Map<String, Object?> extra = const {}]);
static void end(String reason);
}
requireMark 很有用:有时 native 已经上报“首帧”,但业务首页还没完成关键初始化;必须等指定标记到齐才能生成完整样本。也要设置总超时,防止某个标记永远缺失导致整条数据不上报。
三、把用户看到的画面也放进时间线
iOS present 动画期间和结束后,页面背后的图层不同。假设 Flutter 容器背景透明:
| 阶段 | 背景层 | 前景层 |
|---|---|---|
| present 动画中 | 上一个页面 | Flutter 容器底色或首帧 |
| present 动画结束后 | 系统/容器背景 | Flutter 容器底色或首帧 |
如果 first_frame_visible > present_animation_end,动画结束后到首帧出现之间,用户会看到容器底色。底色是黑色时就是典型黑屏:
present start ───── present end ───── first Flutter frame
previous page black/background Flutter content
因此,黑屏可以先用时间关系近似判断:
black_gap = max(0, first_frame_visible - present_animation_end)
这比“启动总耗时超过 1 秒就算黑屏”准确得多。启动即使很慢,只要动画或占位内容连续,也未必产生突兀黑屏;反过来,总耗时不高但动画结束后空出 80 ms,也可能明显闪一下。
进一步可以记录:
first_frame_visible - user_tap:用户等多久看到内容;first_frame_visible - present_start:渲染是否赶上导航过程;black_gap:空白/黑屏窗口;interactive_ready - first_frame_visible:看得到但还不能用的时间。
四、三种预渲染模式的真实取舍
4.1 不预渲染
点击后创建或取得 Engine,立即打开页面,Flutter 正常渲染。
优点:页面打开动作最早开始,用户通常能更早看到最终画面;没有额外的 offscreen layout。缺点:首帧可能赶不上 present 动画结束,出现黑屏或占位背景。
4.2 预渲染但不等待
打开页面前,把 Flutter View 临时附着到可布局环境,触发一次渲染,同时继续 present,不等待首帧完成。
优点:渲染与页面打开部分并行,首帧更有机会赶上动画。缺点:预热步骤本身占用主线程,若实现包含同步 layout,反而会推迟 present;状态切换也更复杂。
4.3 预渲染并等待首帧
先触发 offscreen 渲染,确认首帧完成后再打开页面。
优点:绝大多数情况下不会露出黑屏,页面出现时 Flutter 内容已经准备好。缺点:渲染和 present 变成串行,绝对启动时间最长;若首帧依赖 view attach 后才有的事件,还可能互相等待。
| 模式 | 页面开始打开 | 黑屏风险 | 总耗时 | 实现复杂度 |
|---|---|---|---|---|
| 无预渲染 | 最早 | 较高 | 通常最短 | 低 |
| 预渲染不等待 | 中间 | 中等 | 中等 | 中 |
| 预渲染并等待 | 最晚 | 最低 | 通常最长 | 高 |
没有一种策略在所有指标上都更好。选择应基于启动样本分布和业务结果,而不是只看本地录屏。
五、一次真实链路为什么会超过 700 ms
在一组冷启动样本中,从点击到首帧超过 700 ms,主线程关键步骤大致包括:
| 阶段 | 单次观测耗时 |
|---|---|
| 激活原生依赖 | 约 20 ms |
| 创建 Engine Group | 约 20 ms |
| 创建 Engine | 约 50 ms |
| 创建业务上下文 | 约 10 ms |
| 初始化一个系统媒体组件 | 300 ms 以上 |
| Engine warm-up | 100 ms 以上 |
| present 到 willAppear | 约 40 ms |
Flutter 线程上还包括:
| 阶段 | 单次观测耗时 |
|---|---|
viewDidLoad 到 view ready | 50 ms 以上 |
| Dart 服务初始化 | 约 10 ms |
首页 initState | 约 15 ms |
| build/layout/paint/raster | 200 ms 以上 |
这组数字不是平台基准,只代表一次具体链路;它的价值在于暴露关键路径。最显眼的并非 Engine 创建,而是一个 300 ms 以上、与首帧内容未必相关的系统组件初始化,以及 100 ms 以上但对首帧收益不明确的 warm-up。
如果没有分段标记,很容易把 700 ms 全部归因于“Flutter 冷启动”,然后花大量时间优化 50 ms 的 Engine 创建,却忽略真正的大头。
六、预热为什么可能没有收益
6.1 预热发生得不够早
如果用户点击后才开始 warm-up,它仍处于启动关键路径。所谓预热只是把渲染从页面显示后挪到显示前,并没有消除工作。
真正的提前预热必须发生在系统空闲、用户有较高进入概率且资源允许的时点。太早会浪费内存,太晚则没有并行空间。
6.2 offscreen view 的布局本身很贵
iOS 上为了触发 Flutter 渲染,可能需要把 Flutter View 加入临时层级、设置 frame、调用 layoutIfNeeded。这些操作若在主线程同步执行,会直接推迟导航动画。
6.3 预热帧和最终帧不是同一帧
预热时缺少最终页面尺寸、路由参数、主题、原生数据或 view-ready 事件。页面真正 attach 后仍会重新 build 和 raster,预热只做了一次无法复用的工作。
6.4 人为等待扩大了串行链
业务常要求 Dart 等待 native viewReady 事件后才 build。如果页面其实不依赖这项信息,跨语言事件的调度延迟就只是白白加入关键路径。移除等待前要确认布局和输入状态不会读取未准备数据,再通过实验验证。
七、按关键路径优化,而不是按模块优化
有了时间线后,可以把节点关系画成 DAG,寻找最长依赖链。常见手段包括:
7.1 移出无关初始化
与首帧无关的媒体、上报、缓存和监听器注册,延迟到首帧后或第一次真正使用时。主线程上的 300 ms 系统初始化,即使完全无法优化内部实现,也可以通过改变时机从启动路径移除。
7.2 并行准备独立依赖
Engine 创建、网络预取、配置读取若互不依赖,可以更早并行。但不要把所有工作都扔到后台线程:最终需要主线程的步骤仍应单独标记,过多并发还会争夺 CPU 影响 Dart 首帧。
7.3 复用 Engine,但隔离会话状态
复用 Engine 能省去 VM 与 framework 初始化,却会保留路由栈、单例、listener 和缓存。每次页面会话需要明确 reset 协议;否则启动变快的代价是状态串页和内存常驻。
7.4 用稳定占位解决视觉问题
如果首帧无法稳定赶上动画结束,可以把容器底色设置为目标页面背景色,或使用轻量原生骨架/加载图。它不会减少真实性能耗时,却能显著降低画面突变。
占位图必须处理深色模式、动态字体和页面版本变化。过于逼真的静态截图若与最终页面不一致,反而会产生更明显跳变。
八、启动监控怎样避免脏数据
每条样本至少带上这些维度:
- cold/warm Engine;
- 是否预渲染、是否等待;
- 入口来源;
- 设备性能档、系统版本和 App 版本;
- 前后台状态;
- 页面是否命中缓存;
- 首帧是否超时或标点缺失;
- Engine 是否在本次启动前已经存在。
统计时不要只看平均值。启动体验由慢样本决定,应关注 P50、P90、P95、P99,以及 black_gap > 0 的比例和持续时间分布。
标点本身也要校验:
- 每个
startup_id只能 start/end 一次; - 时间点必须单调,异常顺序单独上报;
- 缺少必需 mark 时标为 incomplete,不能混入正常耗时;
- 超时要保留已完成阶段,帮助判断卡在哪;
- 同一 Engine 的多次页面启动不能串样本。
九、实验应该同时看速度、黑屏和业务结果
比较三种预渲染模式时,至少同时观察:
- 点击到首帧 P50/P95;
- 黑屏比例与
black_gapP95; - 点击到可交互时间;
- 启动失败、卡死和崩溃;
- 内存峰值和 Engine 常驻时长;
- 页面进入后的退出率、播放/阅读等核心行为。
等待预渲染可能让黑屏率下降,却使用户更晚看到页面;无预渲染可能让首帧更早,但视觉跳变导致退出率上升。最终策略应由多指标共同决定。
样本不足时不要过早下结论。尤其黑屏属于尾部问题,需要足够放量才能看出不同设备上的变化。
十、Engine 生命周期与预热预算
预热不是免费的。一个常驻 Engine 会占用 VM、Dart heap、纹理、插件对象和 native 资源。需要定义:
- 何时创建:启动后空闲、入口曝光、用户意图预测?
- 何时放弃:长时间未进入页面、内存警告、进入后台?
- 是否允许多个预热 Engine 并存?
- 页面退出后保留多久?
- 插件和跨语言对象如何随 Engine reset?
- 低端设备是否禁用或缩短保留?
预热策略可以做成有预算的缓存,而不是一个永不销毁的单例:命中时加速,未命中时按内存压力回收,并记录命中率。命中率很低的预热只是在用常驻内存换偶发收益。
十一、落地检查表
- 启动结束定义的是 framework 首帧,还是用户真正看到的首帧?
- native、Dart、raster 和导航动画是否处在同一时间线?
- 能否直接计算动画结束到首帧之间的黑屏窗口?
- warm-up 是否发生在点击后的关键路径?
- 预热帧是否能被最终页面复用?
- 是否有与首帧无关的重初始化阻塞主线程?
- Dart 是否在等待一个其实不需要的 native 事件?
- 实验是否同时比较耗时、黑屏、可交互和资源成本?
- Engine 复用是否有明确的会话清理与回收预算?
Flutter 启动优化的核心,不是尽可能早地调用 run 或强行等一张预渲染帧,而是缩短用户可见关键路径,并让不可避免的等待保持视觉连续。只要点击、Engine、动画和首帧没有被放到同一张时间图上,任何“优化”都可能只是把耗时从一个阶段搬到另一个阶段。