记一次 Flutter 预热帧引起的初始化问题
这是我真实遇到的问题,解决后让AI根据过程总结润色的出来的(自己写太麻烦了)已经去除了业务相关内容
一次 Android detached 生命周期与首帧调度问题排查。
先说一个前提:这不是一个纯普通 Flutter App,也不是用户点了图标之后才创建 Flutter 引擎的那种启动方式。
普通 Flutter App 通常是用户点击桌面图标,Android Activity 创建,Flutter 引擎跟着 attach 到这个 Activity 上,然后 Dart 侧开始跑起来。这个过程里,窗口、生命周期、帧调度都比较顺。
但我这个 App 不太一样。它是 Android 原生 + Flutter 的混合架构,原生侧有无障碍服务、悬浮窗、保活等能力。更关键的是,我把 Flutter 引擎从 Activity 里拆了出来:在 Application.onCreate() 里提前创建并缓存 FlutterEngine,注册插件和 MethodChannel,然后直接执行 Dart 入口。
所以只要系统因为无障碍服务、悬浮窗或保活链路把我这个 App 的主进程拉起来,Application.onCreate() 就会执行,Flutter 引擎也会跟着被预热,Dart 侧初始化逻辑就有机会开始跑。
这里不是说"安装 App 这个动作会自动执行 Flutter"。更准确地说,是重启、重装后的系统恢复、无障碍服务重连等场景,可能会让 Android 先拉起我这个 App 的主进程;而我又在 Application.onCreate() 里启动了缓存引擎,所以 Dart 入口才会在没有打开界面的情况下被执行。
也就是说,Flutter 引擎有可能已经醒了,但承载 Flutter 页面显示的 Activity 还没接上。
这篇文章说的,就是这个不太常见、但混合 App 很容易踩到的场景。
现象:进程活着,但首页没起来
问题最开始看起来有点拧巴。
Android 上,我这个 App 以前在重启手机、重装后的系统恢复,或者无障碍服务被系统重新拉起后,即使不点桌面图标,Flutter 引擎也会在 Application.onCreate() 里被预热,后续 Dart 侧初始化逻辑会自动继续跑。
后来某个版本开始不行了。
进程确实起来了,原生侧的悬浮窗也出现了,甚至还能和 Flutter 侧做部分交互。但 Dart 侧自己的日志没有继续写入,首页相关初始化也像是没发生。
只有手动点击 App 图标,让 Activity 真正打开之后,那些初始化才突然继续执行。
这个现象最容易误判的地方在于:它不像崩溃,也不像完全没启动。它处在一个很尴尬的中间状态:
- Android 进程在。
- 原生 Service 和悬浮窗也在。
- Flutter 不是完全不可达。
- 但 Flutter 目标页面没有真正创建出来。
所以这次不能只问"进程有没有启动",还要继续往下问:Flutter 的页面树有没有真的落地?
第一个误判:是不是 main.dart 没执行?
一开始只看 Dart 日志,很容易得出一个直觉结论:是不是 main.dart 根本没执行?
但这个结论不够准确。
因为悬浮窗已经能和 Flutter 侧做部分交互,说明 Flutter 世界并不是完全死的。真正异常的是:Splash 初始化完成后,路由跳到目标页面的那一帧没有真正提交。
在这个项目里,Home 页面创建后,GetX 才会创建对应的 HomeBinding 和 HomeController。很多后续初始化逻辑都挂在 HomeController 里,所以它没创建出来,就表现成"Flutter 后续逻辑没继续跑"。
这就是排查时的第一个关键转向:
问题不是"Flutter 有没有启动",而是"Flutter 路由后的那一帧有没有被调度出来"。
先找对照:旧版本是正常的
真正让我把方向收窄的,是切回上一个稳定版本后,问题消失了。
同一台设备、同一套构建环境、同样的后台拉起方式下,旧版本不需要点击桌面图标,Flutter 后续初始化也能继续跑。
这个对照很重要。
它说明问题不是"Flutter App 天生不能这样启动",也不是系统、设备、Flutter SDK 或原生启动链路突然整体坏了。后台拉起这条链路以前确实跑通过。
所以更合理的方向是:最近某个启动阶段改动改变了 Dart 启动时序。
继续对比后,范围落到一次启动阶段新增的数据库迁移上。不过具体版本号和提交号对这篇文章没什么帮助,这里就不展开了。它们适合留在内部排查报告里,不适合放进面向路人的博客正文。
最小实验:不是资源复制,而是数据库迁移
当时 Splash 进入首页前,比较可疑的异步任务主要有两个:
- 一个资源复制任务,主要是文件 IO。
- 一个数据库迁移任务,主要是读写本地数据库。
它们共同点是都发生在进入首页之前,也都可能通过 await 改变启动时序。所以我把它们拆开做了几组组合验证:
| 资源复制 | 数据库迁移 | 结果 |
|---|---|---|
| 等待完成 | 等待完成 | 异常 |
| 不等待 | 不等待 | 正常 |
| 等待完成 | 不等待 | 正常 |
| 不等待 | 等待完成 | 异常 |
这张表基本把方向打清楚了:资源复制不是关键触发点。只要数据库迁移任务在进入 Home 路由前被等待,问题就会复现。
这里要特别强调一句:数据库迁移逻辑本身不是错的。真正的问题是,它改变了启动时序。
关键代码大概是这样:
dart
Future<void> migrateLocalDataIfNeeded() async {
final existingCount = ((await dao.count()) ?? 0);
if (existingCount != 0) {
return;
}
// 后续读取旧数据,生成并写入新数据
}
第一行 await dao.count() 看起来很普通:查一下数据库里是不是已经有数据,有就不迁移。
但在这次场景里,它足够改变整个启动时序。
根因:路由发生得太晚了
Splash 初始化流程大致是这样:
dart
init().then((_) {
if (appConfig.firstStartup && Platform.isAndroid) {
Get.offNamed(Routes.WELCOME);
} else {
Get.offNamed(Routes.HOME);
}
});
为了方便描述,下面主要用 Home 代指目标页面。首次启动时它也可能是 Welcome,关键点不是具体路由名,而是路由后的页面帧有没有真正提交。
后来 init() 里面加入了数据库迁移:
dart
await migrateLocalDataIfNeeded();
普通点击图标启动时,这没什么问题。Activity 已经存在,Flutter 生命周期正常,路由跳转之后会有帧调度,Home 页面会创建,Binding 和 Controller 也会跟着起来。
但后台链路拉起主进程时,情况不一样。
这时 Flutter 可能处在 AppLifecycleState.detached。可以粗略理解成:Flutter 引擎已经启动了,但还没有挂到一个真正显示页面的 Activity 上。
在这个状态下,普通的 scheduleFrame() 不一定会像 Activity 已经 attach 时那样自然提交页面帧。
于是问题链路变成了:
- 系统通过无障碍服务、悬浮窗或保活链路拉起我这个 App 的主进程。
Application.onCreate()创建并缓存 Flutter 引擎,同时执行 Dart 入口。- Flutter 引擎启动了,但 Activity 还没接上,生命周期处于
detached。 - Splash 开始执行初始化。
await dao.count()进入数据库异步边界。- Flutter 初始预热帧结束。
- 数据库查询回来后,代码执行
Get.offNamed(Routes.HOME)。 - 路由状态变了,但
detached状态下普通帧没有真正提交。 HomeBinding、HomeController没有按预期创建。- 用户点击桌面图标,Activity attach,帧调度恢复。
- 之前悬着的路由页面终于被提交,初始化看起来才"突然开始执行"。
说得再直白一点:数据库查询这一等,把 Home 路由推到了一个很尴尬的时间点。
如果 Activity 已经在,这个时间点没问题;但如果 Flutter 引擎是被后台服务先拉起来的,Activity 还没接上,这次路由就可能只改了状态,没有真正画出目标页面。
这也解释了为什么旧版本没有强制帧代码也正常。
旧版本不是更神奇,也不是主动处理了 detached 生命周期。它只是当时还没有在 Splash 路由前引入这段数据库异步迁移,路由没有被推迟到这个危险窗口之后。
修复:在 Android detached 状态下补一帧
最终修复没有绕开数据库迁移,也没有把这段迁移改成不等待。
那样虽然可能让启动看起来恢复,但会引入另一个隐患:Home 或相关 Controller 可能在数据库迁移完成前读取数据,造成迁移和页面初始化竞争。
更稳的做法是保留迁移顺序,只在这个特殊生命周期下补一次帧调度:
dart
void _scheduleDetachedAndroidRouteFrame() {
final lifecycleState = WidgetsBinding.instance.lifecycleState;
if (!Platform.isAndroid || lifecycleState != AppLifecycleState.detached) {
return;
}
WidgetsBinding.instance.scheduleForcedFrame();
}
调用点放在 Splash 路由之后:
dart
if (appConfig.firstStartup && Platform.isAndroid) {
Get.offNamed(Routes.WELCOME);
} else {
Get.offNamed(Routes.HOME);
}
_scheduleDetachedAndroidRouteFrame();
这段逻辑只针对 Android 且生命周期为 detached 的后台拉起场景。普通 Activity 启动不需要额外处理,因为它本来就有正常的帧调度。
修复后的结果是:即使用户没有点击桌面图标,只要系统通过无障碍、悬浮窗或保活链路拉起主进程,Splash 完成初始化并路由到目标页面后,也能强制提交这一帧,让页面、Binding、Controller 正常创建。
这次真正学到的东西
这类问题最麻烦的地方,是它看起来像"启动失败",但实际是"启动了一半"。
如果只盯着进程、Service 或原生日志,很容易觉得原生侧没问题;如果只盯着 Dart 日志,又容易误判成 main.dart 没跑。
后来真正破局的点有三个。
第一,区分"进程启动"和"Flutter 页面树创建"。悬浮窗出现只能证明一部分链路可用,不能证明 Home 已经初始化。
第二,用旧版本做对照。旧版本正常这一点非常关键,它把问题从环境和 Flutter 版本里拉了出来,重新指向最近的启动阶段改动。
第三,最小化验证 await 点。资源复制和数据库迁移的组合实验说明,问题不是"异步越多越坏",而是特定异步边界让 Home 路由发生在 detached 状态下无法自然提交帧的时间点。
最后总结成一句话:
这次根因不是数据库迁移逻辑错了,也不是 Flutter 没启动,而是迁移里的数据库 await 改变了 Splash 到 Home 的路由时机;在 Android 无 Activity 拉起导致的 detached 生命周期下,普通帧调度没有把这次路由提交出去。