如果你已经按上一篇 Flutter Android 上线后崩溃怎么排查:符号表、logcat 与 Play Console 对照清单 把 release 产物归档、日志入口和崩溃排查链路收好,下一步就不该继续停留在“出了问题再救火”,而是把首帧、冷启动和首屏卡顿的基线先立起来。很多 Flutter Android 项目在功能越来越多之后,最先恶化的往往不是崩溃率,而是启动体验:启动页停太久、首页首帧晚、主线程被初始化任务堵住、列表一进来就掉帧。很多团队觉得“好像有点慢”。

这篇文章只解决一个长期、常青而且真实可落地的问题:如何让 Flutter Android 的冷启动优化从“凭感觉改”变成“先测量、再拆分、再验证”的固定流程。前置基础默认你已经会稳定发布 AAB、区分测试环境与生产环境,并且至少读过 Flutter Android 多环境配置实战:flavor、dart-define-from-file 与 CI 构建检查清单。本文新增能力是:在真机 profile 模式下建立启动基线,拆开首帧前后的初始化任务,在 Android 原生侧用 StrictMode 查主线程阻塞,再用 DevTools、adb 命令和 gfxinfo 把验证结果沉淀成复盘材料。

前置基础与本文新增能力

先把边界说清楚。本文不是 Flutter 性能口号合集,也不是把所有卡顿问题都归结成“多用 const”或“少写 setState”。如果你现在连稳定的启动入口、固定的 applicationId、统一的 flavor 配置和版本号策略都没有,建议先补完前面的发布与环境文章,再来做启动优化。因为启动问题最怕两件事:一是拿 debug 模式下的体感当结论,二是今天测的是测试包、明天改的是生产包,数据根本对不上。

本文新增的能力主要有四项。第一,建立与版本绑定的启动基线文件,让每次优化都有配置、命令和结果可以回看。第二,把首帧前真正必须完成的初始化和首帧后可延迟的 warmup 拆开,而不是把网络、缓存、埋点、远程配置都堆进 main()。第三,在 Android 原生层补一层主线程检查,特别是文件 I/O、磁盘读写、同步网络和某些插件初始化。第四,用 DevTools 时间线、adb shell am start -S -Wadb shell dumpsys gfxinfo 和应用日志一起验证,而不是只看“感觉好像快了”。

适用场景与启动目标

这套流程适合已经进入内部测试、封闭测试或正式生产的 Flutter Android 项目,尤其适合下面几类场景:

  • 首页依赖本地会话、远程配置、图片资源或多个 Repository,首屏逻辑逐步变重。
  • 项目已经接入推送、WebView、地图、图片缓存或埋点 SDK,启动链路不再只有 Dart 代码。
  • 团队开始做发布节奏和稳定性复盘,希望把“启动慢”纳入固定检查项。
  • 同一个项目里存在多个 flavor,需要确认测试环境和生产环境的启动配置没有漂移。

启动目标不要定得太空泛。Android 官方把 Time to Initial Display 当成首帧级指标,Android vitals 当前把冷启动 5 秒以上、温启动 2 秒以上、热启动 1.5 秒以上视为 excessive。对 Flutter Android 团队来说,这些数字更像报警线,而不是优化完成线。本文更务实的目标是:你能够稳定回答“这个版本的冷启动基线是多少”“首帧前做了哪些事”“哪些任务被推迟到了首帧后”“优化后日志和命令是否能证明真的改善”。只要这四个问题能稳定回答,后面继续做 Macrobenchmark、Perfetto 或 Play Console 对照才有意义。

项目结构:先给启动测量留固定位置

启动优化最容易失败的原因之一,不是没有工具,而是测量结果四散在聊天记录和本地截图里。更稳的方式,是在项目里预留一个固定目录,专门保存启动配置、命令输出和基线记录。

your_app/
├─ lib/
│  ├─ bootstrap/app_bootstrap.dart
│  ├─ core/startup/startup_coordinator.dart
│  ├─ core/logging/app_log.dart
│  └─ features/home/home_page.dart
├─ android/app/src/main/kotlin/.../MainApplication.kt
├─ tool/perf/
│  ├─ startup-baseline.md
│  ├─ cold-start-am-start.txt
│  └─ gfxinfo-home.txt
└─ test_driver/
   └─ smoke_notes.md

tool/perf/startup-baseline.md 不需要花哨格式,哪怕只记录版本号、设备型号、构建类型、启动命令、首帧时间和备注,也比没有基线强。关键是每次验证都能回到同一位置。

步骤一:只在真机 profile 模式下建立冷启动基线

Flutter 官方文档已经把结论说得很清楚:性能分析要在 profile 模式和真实设备上做,debug 模式和模拟器都不能代表最终运行时特征。所以第一步不是改代码,而是把测量环境钉死。建议每次都使用能复现生产配置的 flavor 和入口文件:

flutter run --profile --flavor prod --target lib/main_prod.dart
adb shell am force-stop com.example.app
adb shell am start -S -W com.example.app/.MainActivity
adb shell dumpsys gfxinfo com.example.app framestats > .\\tool\\perf\\gfxinfo-home.txt

这里有三个重点。第一,--profile 比 debug 更接近 release,同时仍保留追踪能力;第二,am force-stopam start -S -W 让你每次都从更接近冷启动的状态开始,而不是把后台残留进程当成优化结果;第三,gfxinfo 不是为了替代 DevTools,而是补一份可落盘的 Android 侧数据,方便你在不同版本之间横向比较。

记录基线时至少写入这些字段:versionName/versionCode、设备型号、Android 版本、flavor、启动命令、首帧观察值、是否打开预拉取、是否包含 debug banner、对应日志文件名。没有这些配置上下文,后面再好的命令输出也很难复盘。

步骤二:把首帧前必须做的事和首帧后 warmup 拆开

很多 Flutter Android 项目真正的问题,不是某个单点 API 特别慢,而是所有“顺手做一下”的初始化都被塞进了首帧前。比如:恢复完整用户资料、请求远程配置、初始化图片索引、预建 Dio client、上报埋点、拉取首页首批网络数据、同步打开本地数据库。这些动作单看都合理,叠在一起就会把主线程和 Dart UI 线程都压住。

更稳的做法是把启动拆成两个阶段:critical path 只保留真正影响首帧展示的最小集合,例如环境配置、最轻量的会话恢复、主题和路由决策;其余动作进入首帧后的 warmup。下面是一个常见的 Dart 思路:

import 'dart:developer';
import 'package:flutter/widgets.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final coordinator = StartupCoordinator();
  await coordinator.prepareCritical();
  runApp(MyApp(coordinator: coordinator));

  WidgetsBinding.instance.addPostFrameCallback((_) {
    coordinator.warmUpDeferred();
  });
}

class StartupCoordinator {
  Future<void> prepareCritical() async {
    Timeline.startSync('startup.critical');
    try {
      await EnvConfig.load();
      await SessionStore.restoreTokenOnly();
    } finally {
      Timeline.finishSync();
    }
  }

  Future<void> warmUpDeferred() async {
    final task = TimelineTask()..start('startup.deferred');
    final watch = Stopwatch()..start();
    try {
      await Future.wait([
        RemoteConfigService.instance.fetch(),
        ImageManifestCache.instance.prime(),
        AnalyticsService.instance.configure(),
      ]);
      AppLog.info('startup.deferred.ok', {'ms': watch.elapsedMilliseconds});
    } catch (error, stack) {
      AppLog.error('startup.deferred.fail', error, stack, {'ms': watch.elapsedMilliseconds});
    } finally {
      task.finish();
    }
  }
}

这里的关键取舍不是“所有初始化都推迟”,而是只把不会阻止首帧展示的任务后移。比如首页依赖 token 判断登录态,那么 token 恢复就属于 critical;但远程配置、图片索引、分析 SDK、某些推荐流预取通常都可以延后。这样改之后,你在 DevTools 时间线里能看到更明确的阶段边界,日志里也能保留 startup.deferred.ok 或失败耗时,后面验证时就更容易判断优化到底发生在哪一段。

步骤三:在 Android 原生侧补一层主线程阻塞检查

Flutter 启动慢不一定全是 Dart 层问题。很多项目接入插件或自定义原生初始化后,真正卡住的是 Android 主线程。Android 官方在启动优化文档里明确建议用 StrictMode 识别主线程上的昂贵操作,尤其是磁盘读写、网络访问和其他会阻塞首帧的同步工作。

如果你的项目已经有 Application 类,或者准备通过插件做原生侧启动治理,可以先在 debug 或内部测试包上打开 StrictMode:

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .build()
            )
        }
    }
}

这段配置不是为了让用户环境直接崩掉,而是让开发和测试阶段尽早暴露问题。你常见会抓到几类情况:插件在 Application.onCreate() 同步读文件;某个 SDK 启动就访问网络;首页依赖的数据库在主线程上直接打开;或者自定义 WebView、图片库、地图 SDK 做了超出预期的同步初始化。抓到以后,不要急着“全部扔协程”,先判断它是不是首帧前必需。如果不是,就下移到首帧后 warmup,或者改成惰性初始化。

步骤四:用 DevTools、gfxinfo 和日志一起做验证

仅靠 am start -W 不足以说明首屏就真的顺了,因为它更偏启动时长;而仅看 DevTools 某一次时间线截图,也很容易忽略 Android 侧掉帧趋势。更稳的验证方法,是把两边证据拼在一起。

Flutter 侧建议至少看两样:profile 模式下的 DevTools Performance 视图,以及必要时打开 showPerformanceOverlay。重点不是追求页面绝对干净,而是确认首帧前后的大块耗时是否被压缩,UI thread 和 raster thread 的长条是否减少。Android 侧则继续保留 gfxinfo 输出,用来比较不同版本首页进入后的 frame stats 变化。日志层面建议在应用启动时打出:版本号、flavor、设备冷启动入口、critical 阶段耗时、deferred 阶段耗时、首页数据第一次可见时间。

如果你还怀疑包体与资源加载也在拖慢启动,可以额外跑一次:

flutter build appbundle --analyze-size --flavor prod --target lib/main_prod.dart

这条命令不是直接给出启动时间,而是帮助你判断是否因为图片资源、字体、原生库或 Dart AOT 体积增长,导致后续首屏加载链路变重。启动优化不是只看某一条命令,而是把配置、命令、日志和构建结果放到同一条验证链上。

验证方式:每次优化都做同一轮小演练

建议把验证步骤固定成一套 runbook,而不是每个同学按自己的习惯测。最小验证流程可以是:

  1. 用固定 flavor 跑 flutter run --profile,确认拿到的是目标包和目标配置。
  2. 连上真机执行 adb shell am force-stopadb shell am start -S -W,记录冷启动输出。
  3. 进入首页后立刻导出 adb shell dumpsys gfxinfo ... framestats,保留版本对应文件。
  4. 打开 DevTools Performance 视图,观察 startup.criticalstartup.deferred 时间线事件是否符合预期。
  5. 检查应用日志里是否真的写出了 critical/deferred 两段耗时,而不是只看到模糊的“app start”。
  6. 与上一个发布版本对照基线,确认改善的是首帧、首页首批渲染,还是只是把卡顿挪到了页面进入后几百毫秒。

只有这套验证能稳定复现,才能说“这次优化有效”。否则很多优化只是把等待时间换了位置。

避坑点:启动优化最容易走偏的几个方向

第一,不要拿 debug 模式、模拟器或热重载后的体感当结论。Flutter 官方已经明确说明 debug 模式不代表 release 性能,模拟器也不适合做移动端真实性能判断。第二,不要一股脑把所有初始化都推迟。首帧快了,但登录态、路由守卫或关键配置缺失,会把问题变成“进得快、进错页”。第三,不要只看一个命令。am start -W、DevTools、gfxinfo 和日志各自覆盖的层面不同,单看任何一个都容易误判。

第四,不要忘了记录配置。设备不同、Android 版本不同、flavor 不同、图片资源不同,都会让启动数字飘动。第五,不要把启动慢和列表滑动卡顿混成一个问题。启动优化优先解决首帧和首页首次可用,列表滚动、图片缓存和滚动掉帧是下一阶段的专项。第六,不要优化完却没有复盘文件。没有复盘,下次升级 SDK、改插件或加首页模块时,你根本不知道自己退化到了哪里。

复盘清单:把启动优化变成可持续维护能力

每次版本发布前后,至少做一次最小复盘:

  • 这次测量使用了哪台设备、哪个 Android 版本、哪个 flavor、哪条启动命令。
  • startup.criticalstartup.deferred 分别包含哪些任务,是否有新任务偷偷回到了首帧前。
  • StrictMode 是否抓到了新的主线程磁盘、网络或插件初始化问题。
  • DevTools 时间线、gfxinfo 输出和应用日志是否能互相对上同一个版本号。
  • 如果启动数字没有改善,瓶颈是在 Dart、原生初始化、资源体积,还是首页 UI 结构。
  • 如果后续需要回滚,应该恢复哪一版配置、哪一份基线文件、哪一组日志。

当这些问题都能被稳定回答时,Flutter Android 的启动优化才不再是一次性活动,而会变成工程化能力。你不需要承诺“所有设备都秒开”,但必须保证团队能用同一套配置、命令和日志,持续发现首帧前的阻塞,并验证每一次改动到底有没有让真实用户启动更顺。

下一步进阶方向

等这条链路跑顺之后,下一篇更值得进入的方向不是继续堆零散小技巧,而是把启动与发布回归测试接到一起:例如用 Macrobenchmark 建立固定启动用例、把首页 frame stats 纳入 CI 观察、或者继续细拆图片解码与列表首屏渲染。到那一步,你的 Flutter Android 发布流程才真正从“能发、能排查”走向“能长期防回退”。