Skip to content

20 · 异常捕获与错误监控

目标:让线上 App 的每一次崩溃/异常都可捕获、可上报、可定位——建立从全局异常钩子到错误上报平台的完整链路,告别"用户说闪退但我这边好好的"。


开篇:前端概念对照

前端(Web)Flutter 对应
window.onerrorFlutterError.onError(框架层异常)
未处理的 Promise rejection未 await 的 Future → runZonedGuarded
window.addEventListener('unhandledrejection')PlatformDispatcher.instance.onError(引擎/原生层)
Sentry JS SDKSentry / 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 落本地,保证线上线下行为一致。


二、三层异常,三道钩子(骨架代码)

dart
// 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)

dart
// 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 里主动报

dart
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 为例的接入骨架(版本以官方文档为准 [易变]):

dart
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 包是混淆/裁剪过的,上报的堆栈默认是地址。发版时必须保留符号信息:

bash
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 · 性能分析与优化实战