记一次 Flutter 预热帧引起的初始化问题

记一次 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 才会创建对应的 HomeBindingHomeController。很多后续初始化逻辑都挂在 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 时那样自然提交页面帧。

于是问题链路变成了:

  1. 系统通过无障碍服务、悬浮窗或保活链路拉起我这个 App 的主进程。
  2. Application.onCreate() 创建并缓存 Flutter 引擎,同时执行 Dart 入口。
  3. Flutter 引擎启动了,但 Activity 还没接上,生命周期处于 detached
  4. Splash 开始执行初始化。
  5. await dao.count() 进入数据库异步边界。
  6. Flutter 初始预热帧结束。
  7. 数据库查询回来后,代码执行 Get.offNamed(Routes.HOME)
  8. 路由状态变了,但 detached 状态下普通帧没有真正提交。
  9. HomeBindingHomeController 没有按预期创建。
  10. 用户点击桌面图标,Activity attach,帧调度恢复。
  11. 之前悬着的路由页面终于被提交,初始化看起来才"突然开始执行"。

说得再直白一点:数据库查询这一等,把 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 生命周期下,普通帧调度没有把这次路由提交出去。

相关推荐
阿pin1 小时前
Android随笔-要怎样与AI相处
android·人工智能·ai
Kapaseker2 小时前
Android CLI 完全指南
android·kotlin
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:MoodStorage 的 CRUD 集成验证
android·服务器·flutter·华为·harmonyos
solo_992 小时前
Android window属性全解析
android
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:错误处理与优雅降级策略
android·javascript·flutter·华为·harmonyos
-SOLO-13 小时前
停止gradle命令
android
_祝你今天愉快13 小时前
Android Binder 驱动 - 启动 ServiceManager 进程
android
农村小镇哥13 小时前
ThinkPHP-MVC什么是框架
android
换元不配限14 小时前
Jetpack Compose 状态管理指南
android·状态管理·compose·状态·单向数据流·jetpack compose·状态容器