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 落本地,保证线上线下行为一致。
1.1 本项目的真实监控链路(先看这个,再往下读骨架代码)
用户不同意隐私协议 → 什么都不初始化
│
用户同意(Phase 2)
│
lib/config/post_privacy_bootstrap.dart:65 → ArmsAnalytics.initialize()
│
┌───────────────┴────────────────┐
MethodChannel('haier.energy/arms') ARMS Flutter 插件
启动 Native SDK(见 21 篇 2.5) Flutter 侧性能/崩溃/自定义事件
│
请求链路:Dio 拦截器链(lib/data/services/api/api_client.dart:26-40)
auth → [release] AlibabaCloudRUMDioInterceptor → ErrorInterceptor
→ [非 release] ChuckerDioInterceptor → LoggingInterceptor拦截器顺序是有讲究的,改动前先理解:
| 位置 | 拦截器 | 为什么在这个位置 |
|---|---|---|
| 1 | AuthInterceptor | 第一个加,保证后续拦截器(含 RUM)看到的是已带 token 的请求 |
| 2 | AlibabaCloudRUMDioInterceptor(仅 release) | 要统计真实的耗时与成功率,必须在错误转换之前,拿到原始 DioException |
| 3 | ErrorInterceptor | 把 DioException 转成 AppError,之后上层再也见不到 Dio 异常 |
| 4 | ChuckerDioInterceptor(非 release) | 注释明确写了"挂在 Logging 之前以抓到 Error 处理后的 response"——抓包要看业务看到的结果,不是原始异常 |
| 5 | LoggingInterceptor | 最后打日志,输出的是终态 |
1.2 错误在哪一层被翻译:AppError
lib/data/services/api/error_interceptor.dart:30-52 是唯一的翻译点:
DioExceptionType / 状态码 | AppError | 说明 |
|---|---|---|
sendTimeout / receiveTimeout | AppError.timeout(msg) | |
connectionError | AppError.network(msg) | |
401 | AppError.auth('登录已过期') | 同时触发 AuthInterceptor 的刷新重放 |
403 | AppError.forbidden('没有访问权限') | |
| 其他 4xx | AppError.server(body.message, code) | 透传后端 message(如"工单不存在"),比通用文案有用 |
| 5xx | AppError.server(null, code) | 维持 generic,不把后端脏话透给用户 |
cancel | AppError.unknown('请求已取消') | 主动取消不算错误,别弹 toast |
| 其他 | AppError.unknown(msg) |
上面这张表的唯一实现点(lib/data/services/api/error_interceptor.dart:30-52):
AppError _mapError(DioException err) {
final statusCode = err.response?.statusCode;
switch (err.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return AppError.timeout(err.message);
case DioExceptionType.connectionError:
return AppError.network(err.message);
case DioExceptionType.badResponse:
if (statusCode == 401) return const AppError.auth('登录已过期');
if (statusCode == 403) return const AppError.forbidden('没有访问权限');
// 4xx(非 401/403)透传 body.message;5xx 维持 generic
final bodyMessage = statusCode != null && statusCode < 500
? _extractBodyMessage(err.response?.data)
: null;
return AppError.server(bodyMessage, statusCode ?? 0);
case DioExceptionType.cancel:
return const AppError.unknown('请求已取消');
default:
return AppError.unknown(err.message);
}
}AppError 本身是 freezed 联合类型(lib/data/services/api/app_error.dart:7-16)——timeout / network / server / auth / forbidden / unknown 六个分支,UI 侧用 when 穷举,漏一个编译不过。这就是为什么本篇第二节的骨架代码里,主动上报要收敛到一个门面:翻译规则只有一处,改起来不用满仓库找。
// lib/data/services/api/app_error.dart:7-16
@freezed
class AppError with _$AppError {
const factory AppError.timeout([String? message]) = _Timeout;
const factory AppError.network([String? message]) = _Network;
const factory AppError.server([
String? message,
@Default(0) int statusCode,
]) = _Server;
const factory AppError.auth([String? message]) = _Auth;
const factory AppError.forbidden([String? message]) = _Forbidden;
const factory AppError.unknown([String? message]) = _Unknown;
}1.3 线上平台:阿里云 ARMS(RUM)
| 项 | 现状 |
|---|---|
| SDK | alibabacloud_rum_flutter_plugin + 原生 SDK(Android/iOS 各一份) |
| 初始化时机 | 隐私协议同意后,lib/config/post_privacy_bootstrap.dart:65(合规要求) |
| 原生启动 | MethodChannel haier.energy/arms,见 21 篇 2.5 |
| 全局属性 | setExtraInfo({'environment': ...}),按环境隔离数据 |
| 网络性能 | AlibabaCloudRUMDioInterceptor(release) |
| 业务事件 | ArmsAnalytics.setUserName() / clearUser() / trackLanguageChange() |
| 日志级别 | kReleaseMode ? LogLevel.Error : LogLevel.Debug |
⚠️ spec 与代码已经分叉,以代码为准:openspec/specs/aliyun-monitor-* 五个 spec 写的是 AliyunMonitorService(lib/core/services/aliyun_monitor_service.dart)与"AppLogger 自动上报 ARMS",但这两个都不存在——实际实现是 lib/core/analytics/arms_analytics.dart 的 ArmsAnalytics,且 AppLogger 未接上报。 这正是 32 篇 3.4 说的"spec 落后于代码"的活样本:读 spec 发现类名对不上时,先信代码,再提 change 修 spec。
1.4 现状还差什么(读完本篇可以顺手补的)
按本篇第二~六节的骨架对照,项目当前尚未落地的部分:
| 缺口 | 怎么验证 | 影响 |
|---|---|---|
| 全局 Dart 异常钩子 | 全仓库搜 FlutterError.onError / PlatformDispatcher.instance.onError / runZonedGuarded → 0 命中 | 未捕获异常目前依赖 ARMS 插件自动接管,没有自己的兜底与降级 UI |
ErrorReporter 上报门面 | 搜 ErrorReporter / reportError → 0 命中 | 业务 catch 里的异常无处上报,只能 AppLogger 落本地 |
| 日志上报 ARMS | lib/utils/app_logger.dart 无 ARMS 调用 | release 下 warning/error 日志不上云 |
| 符号化与告警 | 未见配置 | 线上堆栈可能仍是无符号地址 |
判断优先级:前两个是"线上看不见问题",第三个是"看见了没上下文",第四个是"看见了看不懂"。如果只能补一个,先补全局异常钩子 + ErrorReporter 门面。
二、三层异常,三道钩子(骨架代码)
// 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 影响面"的优先级原则
- [ ] 记得上报数据要脱敏、上报自身不能再抛异常
- [ ] 能画出本项目 Dio 五个拦截器的顺序,并解释 Chucker 为什么挂在 Logging 之前
- [ ] 知道
AppError六个分支,以及 4xx 透传 message、5xx 维持 generic 的取舍 - [ ] 知道本项目监控的三个现状缺口(无全局异常钩子、无 ErrorReporter 门面、日志未上云)
导航:上一章 19 · 测试实战 | 下一章 22 · 性能分析与优化实战