Flutter 混合页面的启动优化很容易陷入一个直觉:Engine 越早创建、首帧越早预渲染,页面就一定越快。但用户看到的不是 Engine 的状态,而是一段由原生导航、Flutter 启动、业务初始化和 GPU 渲染共同组成的时间线。

预热可能提前了 Dart 代码执行,却也可能在点击后的关键路径上增加一次 offscreen layout;等待预渲染可以消除黑屏,却把渲染和页面打开强制串行;不等待首帧则速度更快,但动画结束和首帧之间可能露出容器底色。

所以启动优化的第一步不是决定“要不要预热”,而是把用户动作到可交互画面的每个阶段放到同一时钟下,再区分三个指标:

  1. 启动耗时:从用户触发到首帧实际呈现;
  2. 视觉连续性:导航动画结束后是否出现黑屏、白屏或明显跳变;
  3. 资源成本:为了预热提前占用多少 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-up100 ms 以上
present 到 willAppear约 40 ms

Flutter 线程上还包括:

阶段单次观测耗时
viewDidLoad 到 view ready50 ms 以上
Dart 服务初始化约 10 ms
首页 initState约 15 ms
build/layout/paint/raster200 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 的多次页面启动不能串样本。

九、实验应该同时看速度、黑屏和业务结果

比较三种预渲染模式时,至少同时观察:

  1. 点击到首帧 P50/P95;
  2. 黑屏比例与 black_gap P95;
  3. 点击到可交互时间;
  4. 启动失败、卡死和崩溃;
  5. 内存峰值和 Engine 常驻时长;
  6. 页面进入后的退出率、播放/阅读等核心行为。

等待预渲染可能让黑屏率下降,却使用户更晚看到页面;无预渲染可能让首帧更早,但视觉跳变导致退出率上升。最终策略应由多指标共同决定。

样本不足时不要过早下结论。尤其黑屏属于尾部问题,需要足够放量才能看出不同设备上的变化。

十、Engine 生命周期与预热预算

预热不是免费的。一个常驻 Engine 会占用 VM、Dart heap、纹理、插件对象和 native 资源。需要定义:

  • 何时创建:启动后空闲、入口曝光、用户意图预测?
  • 何时放弃:长时间未进入页面、内存警告、进入后台?
  • 是否允许多个预热 Engine 并存?
  • 页面退出后保留多久?
  • 插件和跨语言对象如何随 Engine reset?
  • 低端设备是否禁用或缩短保留?

预热策略可以做成有预算的缓存,而不是一个永不销毁的单例:命中时加速,未命中时按内存压力回收,并记录命中率。命中率很低的预热只是在用常驻内存换偶发收益。

十一、落地检查表

  • 启动结束定义的是 framework 首帧,还是用户真正看到的首帧?
  • native、Dart、raster 和导航动画是否处在同一时间线?
  • 能否直接计算动画结束到首帧之间的黑屏窗口?
  • warm-up 是否发生在点击后的关键路径?
  • 预热帧是否能被最终页面复用?
  • 是否有与首帧无关的重初始化阻塞主线程?
  • Dart 是否在等待一个其实不需要的 native 事件?
  • 实验是否同时比较耗时、黑屏、可交互和资源成本?
  • Engine 复用是否有明确的会话清理与回收预算?

Flutter 启动优化的核心,不是尽可能早地调用 run 或强行等一张预渲染帧,而是缩短用户可见关键路径,并让不可避免的等待保持视觉连续。只要点击、Engine、动画和首帧没有被放到同一张时间图上,任何“优化”都可能只是把耗时从一个阶段搬到另一个阶段。