如果你已经按上一篇 Flutter Android 发布前别只会点 Build:签名、AGP/JDK 兼容与 AAB 验证清单 把签名、AAB 和发布前检查跑通,下一步就不是“能不能发出去”,而是“线上闪退后能不能在半小时内定位到版本、堆栈和责任边界”。很多 Flutter Android 团队第一次上线后都会遇到同一种尴尬:测试同学只反馈“打开就退”,Play Console 里堆栈被混淆,adb logcat 抓到一半进程已经没了,本地仓库里又只剩一个最新版的构建产物,最后大家只能凭感觉回滚。

这篇文章只解决一个长期、常青而且真实的能力建设问题:让 Flutter Android 发布后的崩溃排查变成一条固定流程。前置基础默认你已经会出 release 包、理解 flavor 或至少能区分测试版和正式版;本文新增能力是把 Dart split-debug-info、Android R8 mapping.txt、统一错误上报入口、adb 命令和 Play Console 对照方法收口成一个可执行 runbook。正文不会写第三方平台营销,也不承诺“绝对不崩”;目标是让你在真实项目里拿到足够的日志和配置证据,知道下一步该查 Flutter 代码、原生插件,还是发布配置。

前置基础与本文新增能力

先说边界。本文不是 Flutter 入门,也不是重新讲一遍签名、keystore、JDK 或 AGP 兼容;这些基础建议先看上一篇发布清单,以及更早的 Flutter Android 多环境配置实战:flavor、dart-define-from-file 与 CI 构建检查清单。如果你现在还没有稳定的 release 构建命令、没有固定的 applicationId、也没有内部测试轨道,这篇文章先别急着照抄命令,因为排查链路的前提是版本可辨认、产物可回溯。

本文新增的能力只有四项,但都很关键:第一,发布时同时保留 Dart 符号表、R8 mapping 和必要的原生符号;第二,在 Flutter 框架异常、异步异常和平台层异常三个入口统一收集日志;第三,用 adb logcat 先判断“是不是这个包、是不是这个版本、是不是这次构建”;第四,把 Play Console 的 crash cluster 和本地文件一一对上,而不是看到堆栈就盲猜业务代码。

适用场景与排查目标

这套流程适用的场景很明确:你已经有一个真实 Flutter Android 项目,平时从 CI 或本机执行 release 构建,线上可能跑在内部测试、封闭测试或正式生产环境;应用里既有 Dart 页面状态,也可能接入了图片、WebView、相机、推送或其他原生插件。此时最常见的排查起点通常不是“我知道哪一行错了”,而是下面几种模糊信号:

  • QA 只说“点击某个入口后闪退”,但没有完整日志。
  • Play Console 里能看到 cluster,却看不懂混淆后的 Java/Kotlin 名称。
  • Flutter release 开了 --obfuscate,Dart 栈只剩压缩后的符号。
  • 真机连上 adb 以后,main buffer 里日志太多,找不到自己进程。
  • 同一个版本既可能是业务空指针,也可能是插件原生崩溃,甚至是旧包没卸干净。

排查目标不要定得过大。第一轮排查只求回答五个问题:哪个 versionCode/versionName 在崩、这是不是当前包、崩溃发生在 Dart 还是原生边界、对应版本的符号文件是否还在、有没有足够的配置和日志让你复现或回滚。只要这五个问题能稳定回答,后续再接 Crashlytics、Sentry 或更细的埋点都不晚。

项目结构:先把产物落到固定目录

比起“有人记得去保存文件”,更稳的方式是让项目结构天然留痕。下面这个目录不是唯一答案,但对小团队很实用:

your_app/
├─ lib/
│  ├─ bootstrap/app_bootstrap.dart
│  ├─ core/errors/app_error_reporter.dart
│  └─ core/logging/app_log.dart
├─ android/app/
│  ├─ build.gradle
│  └─ proguard-rules.pro
├─ build/symbols/android/release/
├─ release_artifacts/
│  └─ 1.4.0+87/
│     ├─ app-release.aab
│     ├─ mapping.txt
│     ├─ app.android-arm64.symbols
│     └─ notes.txt
└─ tool/crash_samples/
   ├─ dart_stack.txt
   └─ r8_trace.txt

关键不是目录长什么样,而是每个版本都要有自己的归档目录。mapping.txt 每次 release 构建都会被新结果覆盖,Dart split-debug-info 目录如果复用同一路径也很容易被后一次构建污染;所以不要把“最新一次 build 目录”当成长期证据仓。项目里最好把归档动作写进发布脚本或 CI artifact 步骤,而不是靠手工记忆。

步骤一:发布时同时保留 Dart 符号表和 R8 mapping

Flutter Android 项目只要进入 release,通常至少有两层需要保留。第一层是 Dart 代码的符号表,对应 --obfuscate--split-debug-info;第二层是 Android 侧的 mapping.txt,它负责把 R8/ProGuard 混淆后的 Java、Kotlin 名称还原。不要只保存其中一层,否则看到一半可读、一半不可读的堆栈时还是会卡住。

推荐把 release 命令固定成可复用的脚本,而不是每次手敲:

flutter build appbundle --release --flavor prod --target lib/main_prod.dart --obfuscate --split-debug-info=build/symbols/android/release

$release = "1.4.0+87"
New-Item -ItemType Directory -Force "release_artifacts/$release" | Out-Null
Copy-Item "build/app/outputs/bundle/prodRelease/app-prod-release.aab" "release_artifacts/$release/app-release.aab"
Copy-Item "build/symbols/android/release/*" "release_artifacts/$release/" -Recurse
Copy-Item "android/app/build/outputs/mapping/prodRelease/mapping.txt" "release_artifacts/$release/mapping.txt"

如果你的项目没有 flavor,路径改成 release 即可。命令的重点有三个。第一,--split-debug-info 一定要和当前发布版本绑定,不要混用老目录。第二,归档目录最好包含版本号或 CI build number。第三,mapping.txt 不是“上 Play Console 就不用管了”的文件,本地保留仍然有价值,因为离线 retrace、自建日志平台和排查旧包都可能要用。

如果项目含有自定义 NDK 代码,或者依赖里的 .so 崩溃经常出现到堆栈里,还要把原生 debug symbols 作为额外配置纳入 release。官方 Android 文档给出的方向是为 release build 保留 SYMBOL_TABLEFULL 级别的 native symbols;但即使你暂时没这层原生代码,也别因此忽略 Dart symbols 和 mapping.txt,它们已经覆盖了多数 Flutter 团队第一年的排查需求。

步骤二:统一 Flutter、异步和平台错误入口,日志不要只靠 print

很多团队线上崩溃排查失败,不是因为没有日志工具,而是因为错误入口太分散。FlutterError.onError 只能兜住框架回调里的异常;Future 漏出的异步错误、平台通道抛出的异常、runApp 之外的未捕获错误,如果不额外接住,就会变成“现场有人看到闪退,但仓库里没有对应日志”。

可以把启动收口到一个 bootstrap 文件里,用统一 reporter 记录最少现场:版本号、构建环境、页面路由、错误类型和 stack trace。思路如下:

import 'dart:async';
import 'dart:ui';

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

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  FlutterError.onError = (FlutterErrorDetails details) {
    FlutterError.presentError(details);
    AppErrorReporter.recordFlutter(details);
  };

  PlatformDispatcher.instance.onError = (error, stack) {
    AppErrorReporter.recordZone(error, stack);
    return true;
  };

  runZonedGuarded(() {
    runApp(const MyApp());
  }, (error, stack) {
    AppErrorReporter.recordZone(error, stack);
  });
}

AppErrorReporter 不一定一开始就直连第三方服务,哪怕先写到统一日志也比零散 print 强。比如可以先把关键字段整理成结构化内容:

import 'dart:developer' as developer;

class AppLog {
  static void error(String event, Object error, StackTrace stack, Map<String, Object?> extra) {
    developer.log(
      event,
      name: 'app.crash',
      error: error,
      stackTrace: stack,
      time: DateTime.now(),
    );
  }
}

这里的设计取舍很现实:第一轮不要追求“完美观测平台”,先保证错误不会完全丢失。日志字段也不要太贪心,用户手机号、token、完整响应体这类敏感信息不要直接进日志;先记录版本、页面、动作、异常类型和 request id 就够了。这样你用 adb logcat、自建上传接口或第三方平台时,看到的是同一套错误语义,而不是不同模块各写各的格式。

步骤三:先用 adb logcat 判断是不是当前版本,再决定看哪层栈

线上崩溃排查最容易浪费时间的地方,是没先确认“我抓的是不是这次发布出去的包”。先用 adb 把设备、包名、版本号和 crash buffer 对齐,再看具体代码,会少走很多弯路。

推荐先跑这组命令:

adb devices
adb shell dumpsys package com.example.app | Select-String "versionName|versionCode"
adb logcat -c
adb logcat -b crash -d > .\tool\crash_samples\android_crash.txt

如果应用还能拉起,进一步只盯自己进程:

$pid = (adb shell pidof -s com.example.app).Trim()
adb logcat --pid=$pid -v time

如果进程在启动阶段秒退,--pid 常常抓不到完整现场,这时要回到 crash buffer:

adb logcat -b crash -v threadtime
adb logcat -s flutter AndroidRuntime ActivityManager

这一步的目标不是立刻修 bug,而是先判断堆栈属于哪一层。看到 PlatformException、Dart StateError、Widget build trace,优先查 Flutter 页面状态和异步边界;看到 FATAL EXCEPTIONAndroidRuntime、插件包名或 libapp.so,就先切到 Android 原生配置、插件版本或 native symbols 路线。只要先把层次分清,排查速度会快很多。

步骤四:把 Play Console 堆栈和本地符号文件一一对上

有了本地归档之后,Play Console 的作用就不再是“提供一个神秘错误页面”,而是帮助你确认影响范围、设备分布和 cluster 是否已经扩大。真正关键的动作是:找到和 versionCode 对应的 mapping.txt 与 Dart symbols,然后本地做一次还原验证。

Dart 栈的还原命令很直接:

flutter symbolize -i .\tool\crash_samples\dart_stack.txt -d .\release_artifacts\1.4.0+87\app.android-arm64.symbols

如果是 Java/Kotlin 混淆栈,可以用 Android SDK 里的 retrace:

& "$env:ANDROID_HOME\cmdline-tools\latest\bin\retrace.bat" .\release_artifacts\1.4.0+87\mapping.txt .\tool\crash_samples\r8_trace.txt

这里有一个很实用的判断标准:只要本地 flutter symbolizeretrace 还原不出来,先不要急着改业务代码,先检查三件事。第一,符号文件是不是同一个版本;第二,当前堆栈是不是同一 ABI,比如 arm64 的 Dart symbols 别拿去还原 x86 栈;第三,CI 是否把发布构建后的 mapping.txt 又被后续任务覆盖了。很多“线上神秘 crash”最后都不是代码太诡异,而是版本和符号没有对应上。

对于走 AAB 的项目,Play Console 可以帮助你做持续聚合,但本地还原仍然必要。因为当 QA、运营或老板把某个截图发到群里时,你第一时间往往拿到的是一段文本堆栈或一份设备日志,而不是已经整理好的 cluster 页面。本地命令能跑通,说明你的排查链路是真正闭环的。

验证方式:每次发布前后都做一次小演练

排查链路不做验证,等于没有。最简单的验证方法不是等线上真实用户来踩,而是在内部测试版本主动做一轮小演练。你可以在非生产 flavor 下留一个 QA 入口,点击后故意抛一个可识别异常,确认从日志、堆栈到符号表都能对上。

建议按下面顺序验证:

  1. 用 release 命令重新构建,并确认 release_artifacts/<version>/ 下真的有 AAB、Dart symbols 和 mapping.txt
  2. 安装到内部测试设备,先跑 adb shell dumpsys package 核对 versionName/versionCode
  3. 触发一次可控异常,导出 adb logcat -b crash 日志。
  4. 对 Dart 栈执行 flutter symbolize,确认能还原到实际类名和方法名。
  5. 对 Java/Kotlin 混淆栈执行 retrace,确认插件或原生层调用链可读。
  6. 最后再看 Play Console 或自建平台,确认同一版本的 crash cluster 没有漂移到错误 release。

如果你已经在多环境流程里使用 dart-define-from-file,这一轮验证也顺带能检查“测试环境配置是不是误带到了正式包”。验证不是额外负担,而是发布质量的一部分。

避坑点:这几类错误最容易让排查断链

第一,只保留 AAB 不保留符号表。能发布不代表能排查,AAB 只是可安装产物,不是完整诊断资料。第二,把 build/symbols/android/release 反复复用成共享目录,最后不同版本互相覆盖。第三,以为 FlutterError.onError 能接住所有异常,结果异步区、平台通道或 isolate 里的错误完全没有日志。

第四,adb logcat 只盯 main buffer,不看 crash buffer,应用一秒退时很容易错过关键日志。第五,没有在日志里记录版本号、flavor、构建时间或 request id,导致你知道“崩了”,却不知道崩的是哪个发布包。第六,拿错 mapping.txt 或 ABI 不匹配的 symbols 去还原堆栈,然后误判为“Flutter symbolize 不可靠”。这些坑一旦踩中,后面所有命令都可能看似正常,结果却完全不可信。

复盘清单:把崩溃排查当成发布资产的一部分

每次 release 后,至少做一轮最小复盘:

  • 版本目录里是否同时保存了 AAB、Dart symbols、mapping.txt 和必要说明。
  • 发布命令、配置和 CI artifact 名称是否稳定,别人接手时能复现。
  • 统一错误入口是否还在生效,是否真的打出了关键日志字段。
  • adb 命令是否能在 Windows 开发机直接执行,不依赖某个人的本地 alias。
  • Play Console、QA 日志和本地还原结果是否能对应到同一 versionCode
  • 回滚时需要恢复哪个版本目录、哪个构建配置、哪个发布说明。

只要把这份复盘清单养成习惯,Flutter Android 线上崩溃就不再是“看运气的黑盒”。你不需要承诺应用永远不出错,但必须保证出错时有足够的命令、配置和日志把问题缩到具体版本和具体边界。

下一步进阶方向

当这条链路跑顺以后,下一篇更值得进阶的方向不是继续堆更多命令,而是做“稳定性长期维护”:比如把 ANR、启动耗时、图片解码和内存峰值一起纳入 release review,把内部测试的可控异常演练接进 CI,或者把 Flutter 与原生插件的版本边界做成固定升级清单。到那一步,你的发布流程才算真正从“能上线”走到了“能长期维护”。