20 · 异常捕获与错误监控
目标:让线上 App 的每一次崩溃/异常都可捕获、可上报、可定位——建立从全局异常钩子到错误上报平台的完整链路,告别"用户说闪退但我这边好好的"。
开篇:前端概念对照
| 前端(Web) | Flutter 对应 |
|---|---|
window.onerror | FlutterError.onError(框架层异常) |
| 未处理的 Promise rejection | 未 await 的 Future → runZonedGuarded |
window.addEventListener('unhandledrejection') | PlatformDispatcher.instance.onError(引擎/原生层) |
| Sentry JS SDK | Sentry / Firebase Crashlytics Flutter SDK |
| SourceMap 还原 | 符号表 / --split-debug-info(见 23 篇发布) |
| try/catch 局部捕获 | 同样存在,且 async 里不 catch 就是全局未捕获 |
核心心智模型:前端的崩溃面在"主线程 JS 异常";Flutter 的异常面分三层——框架层(Widget build 抛错)、异步层(Dart Future 未捕获)、引擎/原生层(OOM、原生崩溃)。三层要分别挂钩子,漏一层线上就有一类静默崩溃。
一、先看本项目的现状(对照已有设施)
16 篇已介绍 AppLogger(统一日志,禁止 print)与 chucker(本地网络抓包)。本篇在其上补齐线上监控的另一半:
开发期:DevFloatingButton + chucker + AppLogger → 问题当场看
线上期:全局异常钩子 + 上报平台(Sentry/Crashlytics)→ 问题事后查两套不是二选一:上报 SDK 的回调里统一走 AppLogger 落本地,保证线上线下行为一致。
二、三层异常,三道钩子(骨架代码)
// bootstrap.dart —— App 入口的异常防护网
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
void runGuarded(void Function() app) {
// ① 框架层:build/layout/paint 阶段抛出的异常(最常见)
FlutterError.onError = (details) {
FlutterError.presentError(details); // 保留开发期红屏/日志
ErrorReporter.report(
type: 'flutter_framework',
message: details.exceptionAsString(),
stack: details.stack,
extra: details.informationCollector?.call().join('\n'),
);
};
// ② 引擎/原生层:不走 Dart 异常体系的错误(平台通道、部分引擎错误)
PlatformDispatcher.instance.onError = (error, stack) {
ErrorReporter.report(type: 'platform', message: error.toString(), stack: stack);
return true; // true = 已处理
};
// ③ 异步层:所有未捕获的 Future 异常(runApp 包在这里面)
runZonedGuarded(() {
app();
}, (error, stack) {
ErrorReporter.report(type: 'async_uncaught', message: error.toString(), stack: stack);
});
}
// main.dart
void main() => runGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
// ... 初始化后 runApp(const MyApp());
});三道钩子各管什么,务必分清:
| 钩子 | 捕获范围 | 典型案例 |
|---|---|---|
FlutterError.onError | 框架构建期异常 | build 里访问 null、布局越界溢出 |
PlatformDispatcher.onError | 引擎层错误 | 平台通道失败、部分 GPU 错误 |
runZonedGuarded | 未 await 的 Future | 网络回调抛错没人接、Timer 回调异常 |
⚠️ 常见误传:runZonedGuarded 与新版 Flutter 的异步钩子有替代关系上的变化 [核对当前 Flutter 版本推荐写法]——但"三层都要覆盖"这个结构不会变。
三、ErrorReporter:自己的上报门面(别直接耦合 SDK)
// error_reporter.dart
class ErrorReporter {
static Future<void> report({
required String type,
required String message,
StackTrace? stack,
String? extra,
}) async {
// 1. 永远先落本地(开发可见 + 上报失败时兜底)
AppLogger.I.e('[$type] $message\n$stack');
// 2. 上报到平台(按需换 Sentry/Crashlytics,业务代码不感知)
try {
await MonitoringSdk.capture(
type: type, message: message, stackTrace: stack, extra: extra);
} catch (_) {
// 上报本身失败绝不能再抛 —— 否则就是异常套异常
// 可写入本地队列,下次启动补报
}
}
}为什么加一层门面:直接在 20 个页面里调 Sentry.captureException,将来换平台就是 20 处改动;走门面只改一处。这也是 09 篇Repository 思想同款。
主动上报:catch 里也要报
全局钩子只兜"漏网"异常,已知可能失败的业务点要在 catch 里主动报:
Future<void> submitForm(FormData data) async {
final result = await guard(() => api.submit(data)); // guard 见 09 篇
if (result.isErr) {
// 用户已看到友好错误提示(Result 链路负责),这里补观测:
ErrorReporter.report(
type: 'biz_submit',
message: '提交失败: ${result.errOrNull?.message}',
extra: 'formId=${data.id}', // 只带必要上下文
);
}
}四、接入监控平台:两条主流路线
| 平台 | 特点 | 适合 |
|---|---|---|
| Sentry | 异常聚合去重、堆栈还原、性能监控、跨端一致 | 已有自建后端/多端统一监控 |
| Firebase Crashlytics | 免费稳定、崩溃分组、Android/iOS 原生崩溃一并收 | 已在 Firebase 生态 |
两家 Flutter SDK 都是"初始化 + 自动挂钩子 + 手动 capture"三步,选一家即可。以 Sentry 为例的接入骨架(版本以官方文档为准 [易变]):
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> main() async {
await SentryFlutter.init(
(options) {
options.dsn = const String.fromEnvironment('SENTRY_DSN'); // 不硬编码
options.tracesSampleRate = 0.2; // 性能采样:生产别开 1.0,流量与隐私都吃不起
options.environment = kReleaseMode ? 'production' : 'dev';
},
appRunner: () => runApp(const MyApp()),
);
}无论哪家,第三节的
ErrorReporter门面里换成对应 SDK 调用即可;同时把它的自动钩子与你手写的三层钩子合并(SDK 一般接管FlutterError.onError,手写部分负责runZonedGuarded与业务主动上报),避免互相覆盖。
五、符号化:让堆栈不是 #0 0x1a2b3c
Release 包是混淆/裁剪过的,上报的堆栈默认是地址。发版时必须保留符号信息:
fvm flutter build apk --release \
--split-debug-info=build/debug-info \ # 抽出调试符号,顺便缩小包体
--obfuscate # 开启混淆(与符号配对使用)build/debug-info按版本号+构建号归档保存,上传到监控平台后堆栈还原为可读代码- 这一步是 23 篇(打包发布)的必做项,与上报配置绑定
六、崩溃率与告警:监控的"看板"指标
| 指标 | 定义 | 经验红线 |
|---|---|---|
| 崩溃率 | 崩溃会话数 / 总会话数 | > 0.5% 必须处理,> 1% 是事故 |
| 崩溃免费用户率 | 没遇到崩溃的用户占比 | 关注趋势而非绝对值 |
| Top 崩溃 | 按影响用户数排序 | 修"影响人多"的,不是"你见得多"的 |
| 新版本崩溃率对比 | vs 上一版本 | 发布后 48h 内盯紧,劣化即回滚候选 |
告警配置建议:崩溃率突增(如 10 分钟内超阈值)、新异常类型首次出现(新 bug 信号)、关键业务错误(登录成功率下跌)三类必配。
版本发布与灰度流程见 23 篇;性能类问题(卡顿、内存)的定位工具见 22 篇。
七、常见陷阱速查
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
只挂 FlutterError.onError | 异步异常全部漏报 | 三层钩子都挂 |
catch 块里空实现 catch (_) {} | 静默吞错,线上无痕迹 | 至少 AppLogger + 必要时上报 |
| 上报接口在 catch 里再抛 | 异常套异常,雪崩 | 上报自身 try/catch 包住(第三节) |
| DSN/密钥硬编码 | 泄露风险 | --dart-define 注入 |
| 开发期把上报也打开 | 测试噪声污染线上数据 | environment 区分 + debug 不上报 |
| 忘记配符号还原 | 堆栈全是地址,白监控 | 发版流水线固化 --split-debug-info |
| 敏感信息(Token/手机号)进上报 | 合规风险 | 上报前脱敏(见 09 篇合规要求) |
八、动手练习
练习 20.1:在 playground 里写三个按钮,分别触发:build 阶段异常(如 throw StateError)、未 await 的 Future 异常(Future.delayed 后 throw)、已 catch 的异常。配好三层钩子后逐个点击,观察各自的捕获路径与日志格式差异。
练习 20.2:实现 ErrorReporter 的"离线补报"——上报失败时写入本地队列(可用 18 篇的 Hive),下次 App 启动时读队列补报并清理。(提示:上报失败写 Hive、成功删除;启动时 box.values 逐条补报)
练习 20.3(有账号条件做):注册 Sentry/Crashlytics 免费额度,接入后在测试环境故意抛一个异常,体验"从点击崩溃到平台看到聚合详情"的完整链路,注意检查堆栈是否已符号化。
九、自检清单
- [ ] 能说出 Flutter 三层异常各自的范围,并写出三道钩子的骨架
- [ ] 知道为什么要在 SDK 之上包一层
ErrorReporter门面 - [ ] 能解释 release 包堆栈符号化的原理与
--split-debug-info的作用 - [ ] 知道崩溃率红线与"修 Top 影响面"的优先级原则
- [ ] 记得上报数据要脱敏、上报自身不能再抛异常
导航:上一章 19 · 测试实战 | 下一章 22 · 性能分析与优化实战