Flutter 基础体系 · 第 73/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Crash 诊断:错误捕获、符号化、版本、Breadcrumb 和隐私
Crash 诊断不是“把异常对象上传到服务器”这么简单。一个可用的诊断系统至少要回答五个问题:
- 哪里发生了错误?——错误捕获是否覆盖了 Flutter 框架、Dart 异步代码、平台线程和其他 Isolate。
- 堆栈中的地址或缩短后的名称代表什么?——符号化文件是否与发生崩溃的二进制完全匹配。
- 这次崩溃来自哪个构建产物?——版本、构建号、Git 提交和发布批次是否能唯一定位。
- 崩溃前发生了什么?——Breadcrumb 是否记录了足够的状态变化,又没有变成包含隐私的完整日志。
- 报告本身是否泄露数据?——异常消息、URL、设备信息、用户标识和日志内容是否经过最小化与脱敏。
这些问题互相依赖:没有正确捕获,后面没有事件;没有版本关联,事件无法定位;没有符号文件,堆栈无法阅读;没有 Breadcrumb,堆栈只能说明“在哪里失败”,不能说明“为什么走到这里”;如果没有隐私边界,诊断系统本身会成为数据泄露路径。
一、先区分 Crash、未处理错误和已处理异常
**异常(exception)**是程序用于表示失败的一种运行时对象。异常不一定导致进程退出,例如网络请求失败后可以被 try/catch 处理。
**未处理错误(uncaught error)**是没有被当前错误传播机制消费的错误。它可能最终导致:
- Flutter 框架显示错误组件;
- Dart 当前 Isolate 的未捕获错误回调被调用;
- Android、iOS 或桌面原生层终止进程;
- Web 页面出现未捕获的 JavaScript 错误;
- 异步任务失败,但主界面仍然继续运行。
Crash通常指进程级不可恢复终止,但不同采集平台对“fatal”和“non-fatal”的定义可能不同。一次 Flutter 框架错误可能被报告为 non-fatal,而同一错误在某些状态下也可能导致后续状态损坏并最终 Crash。因此,不能仅根据异常类型推断严重性,必须记录错误发生时的运行状态和后续进程结果。
例如:
try {
final result = await repository.loadProfile();
updateState(result);
} catch (error, stackTrace) {
showRetryUI();
reporter.captureException(
error,
stackTrace: stackTrace,
fatal: false,
);
}
这里的网络异常被业务代码处理,程序仍然可以继续运行。如果 catch 中又访问了空对象,或者状态更新发生在已经销毁的页面上,后续可能产生另一个未处理错误:
try {
final result = await repository.loadProfile();
updateState(result);
} catch (error, stackTrace) {
showRetryUI();
// 这里又抛出异常,原来的错误只是被记录,并没有阻止新的错误传播。
errorPresenter.show(errorDetails!);
}
因此,错误诊断至少要区分:
- 已处理异常:业务知道如何恢复,例如显示重试按钮;
- 未处理错误:程序没有定义恢复路径;
- 进程 Crash:原生运行时或操作系统终止了进程;
- 错误报告丢失:错误发生了,但应用在发送报告前已经退出。
最后一种情况尤其重要:Crash 采集是一个有损系统。不能把“服务器没有收到事件”解释成“应用没有崩溃”。
二、Flutter 中有哪些错误入口
Flutter 应用通常运行在一个主 Isolate 中。错误可能从 Flutter 框架回调、Dart 异步任务、原生平台通道、其他 Isolate 或平台运行时进入。不同入口的覆盖范围不同。
2.1 FlutterError.onError:Flutter 框架回调中的错误
Flutter 框架会在构建、布局、绘制、手势、动画等框架回调中捕获部分错误,并通过 FlutterError.onError 暴露给应用。
import 'package:flutter/foundation.dart';
void installFlutterErrorHandler() {
FlutterError.onError = (FlutterErrorDetails details) {
// 生产环境通常记录错误,不应把完整异常文本直接展示给用户。
reporter.captureFlutterError(details);
// 保留 Flutter 默认的错误输出行为,便于开发期诊断。
FlutterError.presentError(details);
};
}
这里的 details 可能包含:
exception:原始异常;stack:堆栈;library:报告来源,例如 rendering、widgets;context:框架提供的上下文;informationCollector:用于补充诊断信息的回调。
FlutterError.onError 不是所有 Dart 错误的全局捕获器。例如,一个普通的异步任务如果不在 Flutter 框架回调中运行,通常不会因为设置了该回调就自动被捕获。
在调试模式中调用 FlutterError.presentError(details) 很有用,因为它保留了 Flutter 默认的控制台诊断行为。生产环境是否保留该输出,要根据日志收集和隐私策略决定;不能把包含用户输入的异常消息无条件写入系统日志。
2.2 PlatformDispatcher.instance.onError:主 Isolate 中未处理的错误
PlatformDispatcher.instance.onError 用于处理没有被 Flutter 框架捕获的、发生在主 Isolate 中的未处理错误:
import 'dart:ui' as ui;
void installPlatformErrorHandler() {
ui.PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
reporter.captureException(
error,
stackTrace: stack,
fatal: true,
source: 'platform-dispatcher',
);
// true 表示应用已经处理了该错误。
return true;
};
}
回调返回值有实际语义:
- 返回
true:表示错误已被处理; - 返回
false:表示错误未被处理,平台可能继续使用默认错误处理路径。
如果错误报告函数本身可能抛出异常,应避免让报告代码覆盖原始错误:
ui.PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
try {
reporter.captureException(
error,
stackTrace: stack,
fatal: true,
source: 'platform-dispatcher',
);
} catch (_) {
// 诊断系统故障不能再次破坏错误处理路径。
}
return true;
};
这段代码并不保证进程一定不会退出。某些错误发生在运行时已经不一致、原生代码已经触发终止或操作系统即将杀死进程的阶段,Dart 回调即使被调用,也可能没有足够时间完成网络发送。
2.3 runZonedGuarded:Dart Zone 中的异步错误边界
Dart 的 Zone 是一组可以拦截异步调度、定时器、打印和未处理异步错误的运行时上下文。runZonedGuarded 可以为应用入口建立一个异步错误边界:
import 'dart:async';
import 'package:flutter/widgets.dart';
void main() {
installFlutterErrorHandler();
installPlatformErrorHandler();
runZonedGuarded<void>(
() {
runApp(const MyApp());
},
(Object error, StackTrace stack) {
reporter.captureException(
error,
stackTrace: stack,
fatal: true,
source: 'root-zone',
);
},
);
}
它的作用不是“重新捕获一切错误”,而是捕获在这个 Zone 中产生、并按照 Zone 错误传播规则到达边界的未处理异步错误。
需要注意几个边界:
- 在
runZonedGuarded建立之前启动的异步任务不属于该 Zone。 - 其他 Isolate 有自己的内存和错误传播边界,不会自动进入主 Isolate 的 Zone。
- 某些 Flutter 框架错误已经通过
FlutterError.onError处理,不应假设它们还会再次经过 Zone。 - 同一个错误可能先由某个入口记录,又由另一个入口重复记录。
因此,实际系统需要事件去重,而不是简单地把三个入口的记录数相加。
2.4 其他 Isolate:必须显式连接错误通道
Isolate 之间不共享内存。主 Isolate 中安装的 FlutterError.onError、PlatformDispatcher.instance.onError 和 Zone 不会自动捕获后台 Isolate 的错误。
创建后台 Isolate 时,可以显式监听其错误端口:
import 'dart:isolate';
Future<void> runWorker() async {
final errorPort = ReceivePort();
// 将错误发送到 errorPort。列表通常包含错误文本和堆栈文本。
Isolate.current.addErrorListener(errorPort.sendPort);
errorPort.listen((dynamic message) {
if (message is List && message.length >= 2) {
final errorText = message[0].toString();
final stackText = message[1].toString();
reporter.captureIsolateError(
errorText: errorText,
stackText: stackText,
isolateName: 'worker',
);
}
});
// 执行后台工作……
}
生产代码需要管理 ReceivePort 的生命周期:任务完成后移除监听并关闭端口,否则会造成资源泄漏或重复报告。
Isolate.addErrorListener 传递的数据通常已经是可跨 Isolate 传输的表示形式,而不是可以直接按业务类型处理的异常对象。因此,报告器应把它当作“错误文本和堆栈文本”,不要假设可以重新构造原始异常类型。
另外,Isolate.spawn 可以配置 errorsAreFatal。将其设为 false 可以让 Isolate 在错误后继续运行,但这并不代表状态一定可靠。后台任务的错误恢复策略必须由业务定义:重启 Isolate、丢弃任务、回滚状态,或者终止整个功能。
三、建立一个不会重复覆盖彼此的错误捕获层
错误入口和报告器之间应有明确的数据流:
flowchart LR
A[Flutter 框架回调] --> B[FlutterError.onError]
C[主 Isolate 未处理错误] --> D[PlatformDispatcher.onError]
E[Zone 内异步错误] --> F[runZonedGuarded]
G[后台 Isolate] --> H[Isolate error port]
I[Android/iOS/桌面原生 Crash] --> J[平台 Crash Reporter]
B --> K[统一事件规范化]
D --> K
F --> K
H --> K
J --> K
K --> L[脱敏与大小限制]
L --> M[版本和符号元数据]
M --> N[本地队列]
N --> O[网络上传]
统一事件结构可以抽象为:
class CrashEvent {
CrashEvent({
required this.error,
required this.stackTrace,
required this.source,
required this.fatal,
required this.release,
required this.build,
});
final String error;
final String stackTrace;
final String source;
final bool fatal;
final String release;
final String build;
}
真实系统通常还会包含:
- 平台:Android、iOS、macOS、Windows、Linux、Web;
- Flutter 和 Dart 版本;
- 操作系统版本;
- 设备型号或浏览器;
- 是否 Debug、Profile 或 Release;
- 当前页面或路由;
- 用户是否登录,但不直接记录账号、邮箱等身份信息;
- Breadcrumb;
- 采集时间和事件 ID。
推荐在最早的应用入口安装处理器:
void main() {
WidgetsFlutterBinding.ensureInitialized();
installFlutterErrorHandler();
installPlatformErrorHandler();
runZonedGuarded<void>(
() => runApp(const MyApp()),
(error, stack) {
reporter.captureException(
error,
stackTrace: stack,
fatal: true,
source: 'root-zone',
);
},
);
}
这段顺序的因果关系是:
ensureInitialized让 Flutter 绑定可用,适合在启动阶段访问平台能力。- 先安装错误处理器,避免初始化后、
runApp前产生的错误无人记录。 - 最后启动应用和异步任务,让它们尽量处在已建立的捕获边界内。
但不要把所有入口都标记为 fatal: true。例如 FlutterError.onError 中的布局错误可能由 ErrorWidget 替代显示,进程未退出;它通常更适合先报告为框架错误或 non-fatal,是否升级为 fatal 应由后续进程状态和采集平台策略决定。
四、为什么同一错误会被报告两次
重复报告通常有三类来源。
4.1 多个错误入口同时记录
一个错误可能在 Zone 中被捕获,同时 Flutter 框架又调用了 FlutterError.onError。如果两条路径都直接上传,就产生两条事件。
4.2 重启或重试重复执行
例如页面初始化失败后自动重试,错误堆栈相同,但每次失败都可能是独立事件。此时不能只用异常文本去重,因为重复发生次数本身就是重要指标。
4.3 报告上传失败后重试
移动端经常先写入本地队列,再在下次启动或网络恢复时上传。如果上传请求超时但服务器其实已经收到事件,客户端重试会产生重复数据。
一个实用的事件模型应包含:
eventId:每次报告唯一;errorGroupKey:用于聚合同类错误;occurrenceCount:同一次会话内的发生次数;release和build:区分不同版本;timestamp:用于时序分析。
事件去重和错误聚合不是同一件事:
- 去重解决“同一个事件是否被上传两次”;
- 聚合解决“多个相似事件是否属于同一类问题”。
堆栈首帧、异常类型、规范化消息和构建版本常被用于聚合,但具体分组算法属于采集平台实现,不是 Flutter 规范保证。业务不应假设修改一行代码后错误组一定保持不变。
五、符号化:把不可读堆栈还原成源码位置
5.1 什么是符号化
**符号化(symbolication)**是使用与崩溃二进制匹配的调试符号和映射文件,将机器地址、压缩名称或混淆名称还原为:
- 函数名;
- 文件名;
- 行号;
- 调用栈层级;
- 某些原生模块信息。
未符号化的原生堆栈可能类似:
#00 pc 000000000004a7c0 libapp.so
#01 pc 00000000000b12f4 libflutter.so
符号化后才可能变成:
#00 package:checkout/cart.dart:184
#01 package:checkout/payment.dart:72
这里的地址不是“错误位置本身”,而是某个二进制文件中的偏移。要正确解释它,必须知道:
- 哪个二进制;
- 二进制的精确构建版本;
- 是否经过混淆、压缩或裁剪;
- 对应的符号文件是否来自同一次构建。
5.2 Dart AOT Release 构建
Flutter 移动端和桌面端的 Release 构建通常使用 AOT(Ahead-of-Time)编译。为了减小产物或隐藏 Dart 标识符,构建可以使用:
flutter build appbundle \
--release \
--obfuscate \
--split-debug-info=build/symbols/android/1.4.0+42
这几个参数的关系不能混淆:
--release:构建发布模式;--obfuscate:混淆 Dart 符号名称;--split-debug-info=<dir>:把 Dart 调试信息分离到指定目录;- 输出目录中的文件必须与这次构建的产物绑定保存。
--split-debug-info 不是上传命令,也不是自动完成符号化。它只生成供后续符号化使用的调试信息。实际上传方式由 Crash 平台决定;如果平台没有接收这些文件,线上堆栈仍然可能不可读。
使用 --obfuscate 也不是加密。它不能保护:
- 用户输入;
- 网络请求;
- APK/IPA 中的静态资源;
- 原生符号;
- 运行时可观察行为。
一旦丢失分离出来的符号文件,通常无法仅凭线上混淆堆栈恢复原始 Dart 名称。因此,符号文件应进入受限访问的构建制品库,并至少按应用、平台、版本和构建号保存。
5.3 Android 的多种符号来源
Android Crash 可能来自不同层:
- Dart 层:需要 Flutter AOT 的分离调试信息。
- Java/Kotlin 层:如果使用 R8 或 ProGuard,需要保存对应构建产生的
mapping.txt。 - C/C++/NDK 层:需要未剥离的 native symbols。
- Flutter Engine 层:需要与所用 Flutter Engine 版本匹配的符号,通常由平台或构建链路管理。
这些符号不能互相替代。上传 Dart 符号文件并不能还原 Java/Kotlin 混淆名称;保存 mapping.txt 也不能还原 Dart AOT 堆栈。
5.4 iOS 的 dSYM 和 UUID 匹配
iOS 原生 Crash 通常依赖 dSYM。dSYM 必须与崩溃二进制的 UUID 匹配。仅仅“应用版本号相同”不够,因为同一个版本号可以对应多个重新构建的二进制。
常见失败过程是:
- 构建了 IPA;
- 上传到 App Store 或企业分发;
- CI 清理了 Derived Data;
- 发生线上 Crash;
- 发现本地没有对应的 dSYM;
- 只能看到十六进制地址或部分符号。
因此,CI 在构建完成后应保存:
- IPA 或可追溯的构建元数据;
- dSYM;
- Flutter 产生的 Dart 符号文件;
- Git 提交;
- Flutter SDK 和 Dart SDK 版本;
- 构建参数;
- 最终二进制的 UUID 或平台提供的匹配信息。
iOS 系统、插件和 Flutter Engine 的原生 Crash 可能分别需要不同符号来源。符号化平台显示“缺少符号”时,应先判断缺的是 App 自身符号、插件符号还是系统/Engine 符号。
5.5 Web 的符号化边界
Flutter Web 最终运行在浏览器 JavaScript 环境中。Release 构建通常包含压缩后的 JavaScript,浏览器堆栈可能只有短名称和行列号。此时需要构建产生的 source map:
flutter build web --release --source-maps
是否使用该参数、如何上传和保护 source map,取决于当前 Flutter SDK 和采集平台支持。关键原则是:
- source map 必须对应实际部署的 JavaScript;
- 不要用另一次构建产生的 map;
- source map 通常不应作为公开静态资源长期暴露;
- 浏览器扩展、用户脚本、跨域资源和网络中断会影响错误采集;
- 页面刷新会丢失尚未发送的事件。
Web 没有移动端那样统一的进程 Crash 语义。浏览器标签页可能因内存压力被回收,应用也可能因为跨域、脚本加载失败或浏览器策略而无法报告。因此,Web 诊断通常同时依赖 JavaScript 错误、网络错误和前端状态 Breadcrumb。
六、版本信息必须能唯一定位构建产物
“版本”至少有四个层次:
| 字段 | 含义 |
|---|---|
| 应用版本 | 面向用户的版本,例如 1.4.0 |
| 构建号 | 同一应用版本下的可安装构建序号,例如 42 |
| 发布版本标识 | 采集系统中的 release,例如 com.example.app@1.4.0+42 |
| 源码版本 | Git commit、分支或构建流水线编号 |
Flutter 可以通过构建参数传入应用版本和构建号:
flutter build apk \
--release \
--build-name=1.4.0 \
--build-number=42
在 Android 上,它们通常对应 versionName 和 versionCode;在 iOS 上通常对应 CFBundleShortVersionString 和 CFBundleVersion。但最终仍受平台工程配置、Flavor 和构建脚本影响,不能只看 Flutter 命令行就假设所有平台配置一致。
一个 Crash 事件至少应记录:
platform = android
app_version = 1.4.0
build_number = 42
release = com.example.app@1.4.0+42
git_commit = 9c1e7a4
flutter_version = <CI 中实际使用的 Flutter 版本>
dart_version = <CI 中实际使用的 Dart 版本>
build_mode = release
flavor = production
为什么构建号比版本号重要?因为以下两个产物可能用户看到的版本相同:
1.4.0 build 41
1.4.0 build 42
它们可能有不同的 Dart 代码、插件、原生配置和符号文件。只按 1.4.0 聚合,会把已经修复的问题和仍然存在的问题混在一起。
对于灰度发布,还应记录:
- 发布渠道;
- 灰度批次;
- 远程配置版本;
- 功能开关状态;
- 是否从旧版本升级安装。
Crash 的错误率通常应按“受影响会话数”或“受影响用户数”计算,而不是只看事件总量。例如:
分子和分母必须使用同一版本、平台和时间范围。一个自动重启并重复触发的 Crash 可能产生大量事件,但只对应少数用户;只看事件数会夸大影响。
七、Breadcrumb:记录崩溃前的因果线索
Breadcrumb是崩溃发生前按时间排列的一小段结构化事件。它不是完整业务日志,也不是异常堆栈,而是用于回答:
应用在出错前经历了哪些状态变化?
典型 Breadcrumb 包括:
- 路由进入和离开;
- 用户点击了哪个功能按钮;
- 请求开始、成功、失败及响应类别;
- 数据库迁移开始和结束;
- 权限状态变化;
- App 从后台回到前台;
- 功能开关或远程配置版本;
- 页面状态从 loading 变为 error。
一个最小实现可以使用固定容量的环形队列:
class Breadcrumb {
Breadcrumb({
required this.category,
required this.message,
required this.timestamp,
this.data = const <String, Object?>{},
});
final String category;
final String message;
final DateTime timestamp;
final Map<String, Object?> data;
}
class BreadcrumbBuffer {
BreadcrumbBuffer({this.capacity = 40});
final int capacity;
final List<Breadcrumb> _items = <Breadcrumb>[];
void add(Breadcrumb item) {
if (capacity <= 0) return;
if (_items.length == capacity) {
_items.removeAt(0);
}
_items.add(item);
}
List<Breadcrumb> snapshot() {
return List<Breadcrumb>.unmodifiable(_items);
}
void clear() {
_items.clear();
}
}
当 capacity 为 40 时,加入第 41 条事件会丢弃最早的一条。这个策略的意义是限制内存和报告体积,同时保留“最近发生了什么”。它并不保证保留真正的根因,因为根因可能发生在更早的事件中;容量应根据问题类型和报告大小限制选择,而不是越大越好。
使用时应记录状态变化,而不是所有函数调用:
breadcrumbs.add(
Breadcrumb(
category: 'network',
message: 'profile_request_finished',
timestamp: DateTime.now().toUtc(),
data: <String, Object?>{
'result': 'timeout',
'retry_count': 1,
'endpoint_group': 'profile',
},
),
);
这里记录了接口类别和结果,却没有记录完整 URL、Authorization Header、请求体或用户输入。endpoint_group 比完整 URL 更适合诊断,也更容易控制隐私风险。
Breadcrumb 的数据流通常是:
状态变化
-> 结构化事件
-> 脱敏、截断、限长
-> 内存环形缓冲区
-> 错误发生时复制快照
-> 与错误报告一起上传
不要在正常运行期间持续上传每条 Breadcrumb。这样会把诊断系统变成高成本日志系统,并且扩大数据泄露面。更常见的做法是只在错误发生时附带最近一段事件,或者仅对低比例会话采样。
Breadcrumb 也有并发和时钟边界:
- 多个异步任务可能交错写入,时间顺序不等于因果顺序;
DateTime.now()受系统时钟调整影响;- 不同 Isolate 的事件需要额外标记来源;
- 网络完成事件晚于用户操作,但不代表它导致了错误;
- 日志写入顺序不能自动证明业务执行顺序。
因此,重要状态变化应带有明确的 operation_id、request_id 或状态版本号,但这些标识必须是随机或伪随机值,不应包含用户账号、手机号等业务身份信息。
八、隐私:错误报告也是数据处理系统
Crash 报告常被误认为“只有堆栈,所以没有隐私问题”。实际可能泄露的数据包括:
- 异常消息中的文件路径或用户输入;
- URL 查询参数;
- HTTP Header 和请求体;
- 用户名、邮箱、手机号;
- 设备标识符;
- 剪贴板内容;
- 页面标题或截图中的文本;
- SQL 语句、文件名和本地目录;
- Web 页面地址中的 token;
- Android 日志或 iOS 控制台中由其他组件输出的敏感内容。
8.1 脱敏必须发生在采集端
错误事件应在写入本地队列或发送网络前完成脱敏:
String redact(String value) {
var result = value;
// 示例规则:实际项目应使用经过测试的结构化字段规则。
result = result.replaceAll(
RegExp(r'Bearer\s+[A-Za-z0-9._-]+', caseSensitive: false),
'Bearer [REDACTED]',
);
result = result.replaceAll(
RegExp(r'[\w.+-]+@[\w-]+\.[\w.-]+'),
'[EMAIL_REDACTED]',
);
if (result.length > 2000) {
result = '${result.substring(0, 2000)}...[TRUNCATED]';
}
return result;
}
这个示例只能说明机制,不能作为完整的安全规则。正则脱敏可能漏掉编码后的数据、JSON 嵌套字段、二进制转储和自定义 token。更可靠的方式是:
- 结构化字段白名单;
- 对 URL、Header、错误消息分别处理;
- 默认不采集请求体;
- 对允许采集的字段设置长度上限;
- 用包含敏感样本的自动化测试验证脱敏规则。
“上传后再脱敏”是不安全的,因为敏感数据已经经过了本地持久化、网络传输或第三方服务。
8.2 用户标识应当最小化
诊断通常需要判断“是否同一用户反复 Crash”,但不一定需要知道用户是谁。可以使用:
- 应用内生成的随机安装 ID;
- 可重置的伪名 ID;
- 经过明确策略处理的内部用户键。
不要把邮箱、手机号或业务主键直接作为 user_id。如果必须关联业务系统,应让诊断系统保存短期、受权限控制的关联键,并定义保留期限、访问审计和删除机制。
哈希也不自动等于匿名化:如果原始值空间很小,例如手机号或邮箱,攻击者可能通过字典枚举哈希结果。因此,哈希不是替代数据最小化和访问控制的通用方案。
8.3 同意、地区和保留期限
不同地区、应用类型和数据类别可能适用不同的隐私要求。工程上至少应明确:
- Crash 采集是否属于必要服务,还是需要用户同意;
- 是否采集设备标识、诊断日志或截图;
- 数据发送到哪个地区;
- 第三方平台是否承担处理者角色;
- 数据保留多久;
- 用户删除账号后如何删除或隔离关联数据;
- 谁有权限查看原始事件。
在用户尚未完成隐私初始化时,应用仍可能发生启动 Crash。可以采用“默认只保留最低诊断字段”的策略:先记录非身份化的版本、平台、错误类型和堆栈;在获得授权后才启用更丰富的 Breadcrumb。具体是否允许采集,必须服从产品和法律评估,而不是由 Crash SDK 默认行为决定。
九、离线、启动 Crash 和进程即将退出
移动设备的网络并不可靠,错误发生后立即发送 HTTP 请求不能保证成功。常见的可靠流程是:
- 规范化错误事件;
- 脱敏并限制大小;
- 写入应用沙盒中的本地队列;
- 下次启动或网络恢复时上传;
- 服务端确认后删除本地事件;
- 超过重试次数或保留期限后丢弃。
写入本地队列时也要注意:
- 不能因为磁盘满而让错误处理再次崩溃;
- 队列必须设置总大小上限;
- 文件写入应尽量原子化,避免进程中断留下半条 JSON;
- 事件可能重复上传,服务端需要幂等键;
- 本地事件中同样不能保存未经脱敏的密码、token 或完整输入。
启动 Crash 是一个特殊问题:应用可能在最早初始化阶段失败,甚至还没有创建正常的页面和网络客户端。因此,启动诊断需要独立于业务页面,并尽量记录:
- 启动阶段,例如 binding、配置加载、数据库迁移;
- 上一次启动是否异常退出;
- 上一个成功启动的时间;
- 本次启动的构建号和远程配置版本。
如果应用在操作系统层面被强制终止,例如 iOS watchdog、Android 原生崩溃或系统内存回收,Dart 代码可能没有机会执行最后一次上传。此时应依赖平台 Crash 报告,并在下次启动通过“上次启动标记”判断是否发生异常退出:
启动前:写入 launch_in_progress = true
完成关键初始化:写入 launch_in_progress = false
下次启动:
如果仍为 true,则上次启动未正常完成
这个标记只能帮助判断异常退出,不能证明具体原因。电量耗尽、用户强制结束、系统升级和进程被回收都可能产生相似结果。
十、按平台理解“错误捕获”的边界
Android
Android 上应分别诊断:
- Dart/Flutter 未处理错误;
- Java/Kotlin 异常;
- NDK 原生 Crash;
- ANR(Application Not Responding);
- 系统杀进程、内存压力和后台限制。
ANR 通常不是普通 Dart 异常。主线程长时间阻塞、同步原生调用或插件实现问题,可能导致 ANR,即使没有 Dart 堆栈。Flutter 错误处理器不能替代 Android 的 ANR 和 native Crash 监控。
iOS
iOS 上需要区分:
- Dart 异步错误;
- Objective-C/Swift 异常或信号;
- watchdog termination;
- 内存压力终止;
- 后台执行时间耗尽。
watchdog 终止未必能得到一个可由 Dart 捕获的异常。要定位这类问题,通常需要结合启动耗时、生命周期 Breadcrumb、主线程状态和系统 Crash/termination 信息。
桌面端
Windows、macOS、Linux 的 Flutter 应用同时受到 Dart、Flutter Engine 和原生窗口系统的影响。不同平台的崩溃转储格式、符号文件和分发方式不同:
- macOS 仍涉及 dSYM 和原生符号;
- Windows 可能需要 PDB 等调试符号;
- Linux 的核心转储和发行版符号管理差异较大。
不能把 Android 的 mapping.txt 或 iOS 的 dSYM 直接套用于桌面平台。版本元数据也可能来自 MSI、DMG、安装包或自定义更新器,必须在构建流水线中统一注入。
Web
Web 中没有应用自己控制的进程级 Crash 生命周期。浏览器可能拦截异常、阻止跨域请求、丢弃后台页面请求,或者直接回收标签页。除了 Dart/Flutter 侧事件,还应记录:
- 浏览器和操作系统;
- 当前 URL 的路径部分,但默认移除查询参数;
- 资源加载错误;
- 网络状态;
- source map 对应的部署版本。
Web 上的隐私边界尤其容易被 URL 破坏:密码重置 token、邀请 token 或用户标识可能出现在查询参数中。记录完整 window.location 是高风险行为。
十一、验证错误捕获和符号化是否真的有效
错误监控不能只在生产 Crash 后验证。应在专用测试构建中逐条验证每条故障路径。
11.1 验证 Flutter 框架错误
class CrashTestPage extends StatelessWidget {
const CrashTestPage({super.key});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
throw StateError('diagnostic_test_framework_error');
},
child: const Text('触发测试错误'),
);
}
}
这类错误实际如何传播,取决于抛出位置和 Flutter 当前框架处理路径。验证目标不是“屏幕是否显示红色”,而是确认:
FlutterError.onError是否收到事件;- 事件是否包含堆栈;
- 是否意外重复上传;
- Release 构建中是否把内部异常文本展示给用户;
- 事件是否带有正确版本和 Breadcrumb。
11.2 验证主 Isolate 未处理异步错误
Future<void> triggerAsyncError() async {
await Future<void>.delayed(const Duration(milliseconds: 10));
throw StateError('diagnostic_test_async_error');
}
调用时不要 await 或 catch:
void startTest() {
unawaited(triggerAsyncError());
}
这能验证 Zone 和 PlatformDispatcher 的实际边界。测试时应检查最终由哪个入口收到事件,而不是默认两个入口都会收到。
11.3 验证符号文件匹配
发布验证至少应包含:
- 生成 Release 产物;
- 记录版本、构建号和 Git commit;
- 保存对应 Dart 符号、Android mapping、iOS dSYM 或 Web source map;
- 上传符号文件;
- 在测试设备或测试环境触发已知错误;
- 确认服务端显示真实类名、文件名和行号;
- 使用另一构建产生的错误符号文件,确认平台拒绝匹配或报告未符号化;
- 删除测试事件,避免污染生产指标。
验证“上传成功”不等于“符号化成功”。上传接口接受文件后,还要检查事件是否能被正确还原。典型失败现象包括:
- 堆栈仍是十六进制地址;
- Dart 方法名仍是混淆名;
- iOS 显示 UUID mismatch;
- Android Java 堆栈可读,但 Dart 堆栈不可读;
- Web 显示压缩后的
main.dart.js行号; - 只有部分栈帧被还原。
这些现象通常表示符号文件类型、版本、构建号、UUID、架构或构建产物不匹配,而不是“Flutter 没有生成堆栈”。
十二、一个可操作的发布产物清单
一次发布构建应形成可追溯的制品集合:
release/
android/
app-release.aab
flutter-symbols/
mapping.txt
build-metadata.json
ios/
app.ipa
App.app.dSYM.zip
flutter-symbols/
build-metadata.json
web/
build/web/
main.dart.js.map
build-metadata.json
build-metadata.json 可以包含:
{
"app_version": "1.4.0",
"build_number": "42",
"release": "com.example.app@1.4.0+42",
"git_commit": "9c1e7a4",
"build_mode": "release",
"flavor": "production",
"platform": "android",
"flutter_version": "recorded-by-ci",
"dart_version": "recorded-by-ci"
}
这里的版本值只是示例,实际值应由构建系统生成,而不是开发者手工复制。构建脚本还应阻止以下情况进入发布阶段:
- Release 构建没有生成符号文件;
- 符号文件没有归档;
- release 标识为空;
- 构建号重复;
- 符号上传针对错误的平台或 Flavor;
- source map 被错误地部署为公开文件;
- 采集配置包含开发环境 DSN、测试项目或调试开关。
十三、常见误解与对应失败表现
误解一:设置 FlutterError.onError 就能捕获全部 Crash
失败表现:布局错误能收到,但后台 Isolate、原生 Crash、ANR 和 watchdog 终止没有事件。
原因:它只覆盖 Flutter 框架主动报告的错误路径,不是操作系统级 Crash 处理器。
误解二:try/catch 能保护所有异步代码
失败表现:
try {
Future<void>.delayed(
const Duration(seconds: 1),
() => throw StateError('late error'),
);
} catch (_) {
// 通常捕获不到延迟回调中的错误。
}
try/catch 只覆盖同步执行范围和被正确 await 的 Future。正确写法是:
try {
await Future<void>.delayed(
const Duration(seconds: 1),
);
throw StateError('late error');
} catch (error, stack) {
reporter.captureException(error, stackTrace: stack);
}
或者让任务进入已建立的 Zone 并由全局边界处理。
误解三:上传了符号文件,所有堆栈都应该可读
失败表现:Dart 栈可读但 Java 栈不可读,或 iOS 仍显示地址。
原因:不同语言、二进制和编译阶段需要不同符号;并且符号必须匹配精确构建产物。
误解四:混淆等于加密
失败表现:代码名称变短了,但 token、用户输入和静态资源仍可被读取。
原因:混淆只改变部分标识符,不能保护运行时数据和应用通信。
误解五:Breadcrumb 越多越有用
失败表现:报告体积过大、上传失败、隐私审核困难,真正重要的状态变化反而被淹没。
原因:Breadcrumb 的目标是提供有限的因果上下文,不是替代业务日志平台。应记录结构化状态转换,而不是所有函数调用。
误解六:异常消息可以原样上传
失败表现:生产报告包含邮箱、请求参数、文件路径或访问令牌。
原因:异常消息经常拼接输入数据,框架不会自动替应用完成隐私分类和脱敏。
十四、诊断一条线上 Crash 的完整推理路径
假设线上收到一条 Android Crash,事件包含:
release: com.example.app@1.4.0+42
git_commit: 9c1e7a4
source: root-zone
breadcrumbs:
app_resumed
cart_opened
payment_request_started
payment_request_timeout
retry_button_pressed
error:
NoSuchMethodError: ...
stack:
#0 package:checkout/payment.dart ...
诊断步骤应是:
- 确认版本:检查
1.4.0+42是否对应线上实际安装包,而不是同版本的另一构建。 - 确认构建模式:如果是 Release,检查是否使用了
--split-debug-info或混淆。 - 确认符号匹配:使用构建号、Git commit 和平台元数据验证符号文件来源。
- 确认错误入口:
root-zone表示它从 Zone 边界传播,但仍需检查是否同时存在 Flutter 框架事件。 - 读取 Breadcrumb:错误前先发生请求超时和重试,说明问题可能与重试后的状态机有关,而不是首次请求本身。
- 对照源码:符号化后定位
payment.dart,检查重试路径是否使用了已经清理的对象或空响应。 - 按版本比较:查看
1.4.0+42与前一个构建的错误率,判断是新回归还是原有问题。 - 检查隐私:确认报告中的支付信息只是状态类别,没有银行卡号、完整请求体或 token。
- 复现并验证修复:在相同平台、构建模式和远程配置下复现,再生成新构建并验证新符号。
这个流程的核心不是“看一眼堆栈”,而是将错误路径、构建产物、时间上下文和版本变化连接起来。缺少其中任意一项,都可能把相关性误判为因果关系。
十五、生产取舍:完整性、成本和隐私之间的边界
错误捕获应尽量覆盖更多路径,但覆盖率不是唯一目标:
- 捕获逻辑太少,Crash 无法定位;
- 捕获逻辑太复杂,可能在错误处理期间再次失败;
- Breadcrumb 太多,增加内存、网络和隐私成本;
- 元数据太少,无法按版本和设备分组;
- 元数据太多,可能构成用户画像;
- 符号保存不完整,线上事件无法还原;
- 符号公开暴露,又可能泄露应用内部结构。
一个成熟的实现通常遵循这样的故障隔离关系:
业务错误
-> 规范化失败:丢弃该字段,不影响应用
-> 脱敏失败:拒绝该字段,不发送原文
-> 本地写入失败:限制重试,不阻塞主线程
-> 网络上传失败:保留有限队列,稍后重试
-> 符号化失败:保留原始事件,标记待补符号
尤其不要在 FlutterError.onError、PlatformDispatcher.onError 或 Zone 处理器中执行阻塞式网络请求。错误处理器的首要职责是隔离故障;上传可以异步进行,本地持久化和服务端幂等负责提高成功率。
最终,Crash 诊断系统应让每条事件都能回答:
哪个平台?
哪个应用版本和构建?
哪个源码提交?
哪个 Isolate 或原生线程?
哪个错误入口?
崩溃前发生了哪些关键状态变化?
堆栈是否由匹配的符号文件还原?
报告中是否只包含必要且经过脱敏的数据?
只有当这些信息形成闭环时,Flutter Crash 监控才不仅是“收集异常”,而是可以支持定位、回归判断、发布决策和隐私审计的质量系统。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 包体积优化:AOT、资源、字体、符号、拆分和分析
- 下一篇:Flutter CI/CD:分析、测试、签名、构建、商店上传和回滚
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论