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 落本地,保证线上线下行为一致。

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

拦截器顺序是有讲究的,改动前先理解:

位置拦截器为什么在这个位置
1AuthInterceptor第一个加,保证后续拦截器(含 RUM)看到的是已带 token 的请求
2AlibabaCloudRUMDioInterceptor(仅 release)要统计真实的耗时与成功率,必须在错误转换之前,拿到原始 DioException
3ErrorInterceptor把 DioException 转成 AppError,之后上层再也见不到 Dio 异常
4ChuckerDioInterceptor(非 release)注释明确写了"挂在 Logging 之前以抓到 Error 处理后的 response"——抓包要看业务看到的结果,不是原始异常
5LoggingInterceptor最后打日志,输出的是终态

1.2 错误在哪一层被翻译:AppError ​

lib/data/services/api/error_interceptor.dart:30-52 是唯一的翻译点:

DioExceptionType / 状态码AppError说明
sendTimeout / receiveTimeoutAppError.timeout(msg)
connectionErrorAppError.network(msg)
401AppError.auth('登录已过期')同时触发 AuthInterceptor 的刷新重放
403AppError.forbidden('没有访问权限')
其他 4xxAppError.server(body.message, code)透传后端 message(如"工单不存在"),比通用文案有用
5xxAppError.server(null, code)维持 generic,不把后端脏话透给用户
cancelAppError.unknown('请求已取消')主动取消不算错误,别弹 toast
其他AppError.unknown(msg)

上面这张表的唯一实现点(lib/data/services/api/error_interceptor.dart:30-52):

dart
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 穷举,漏一个编译不过。这就是为什么本篇第二节的骨架代码里,主动上报要收敛到一个门面:翻译规则只有一处,改起来不用满仓库找。

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

项现状
SDKalibabacloud_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 落本地
日志上报 ARMSlib/utils/app_logger.dart 无 ARMS 调用release 下 warning/error 日志不上云
符号化与告警未见配置线上堆栈可能仍是无符号地址

判断优先级:前两个是"线上看不见问题",第三个是"看见了没上下文",第四个是"看见了看不懂"。如果只能补一个,先补全局异常钩子 + ErrorReporter 门面。


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

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 影响面"的优先级原则
  • [ ] 记得上报数据要脱敏、上报自身不能再抛异常
  • [ ] 能画出本项目 Dio 五个拦截器的顺序,并解释 Chucker 为什么挂在 Logging 之前
  • [ ] 知道 AppError 六个分支,以及 4xx 透传 message、5xx 维持 generic 的取舍
  • [ ] 知道本项目监控的三个现状缺口(无全局异常钩子、无 ErrorReporter 门面、日志未上云)

导航:上一章 19 · 测试实战 | 下一章 22 · 性能分析与优化实战