Flutter 基础体系 · 第 73/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Crash 诊断:错误捕获、符号化、版本、Breadcrumb 和隐私

Crash 诊断不是“把异常对象上传到服务器”这么简单。一个可用的诊断系统至少要回答五个问题:

  1. 哪里发生了错误?——错误捕获是否覆盖了 Flutter 框架、Dart 异步代码、平台线程和其他 Isolate。
  2. 堆栈中的地址或缩短后的名称代表什么?——符号化文件是否与发生崩溃的二进制完全匹配。
  3. 这次崩溃来自哪个构建产物?——版本、构建号、Git 提交和发布批次是否能唯一定位。
  4. 崩溃前发生了什么?——Breadcrumb 是否记录了足够的状态变化,又没有变成包含隐私的完整日志。
  5. 报告本身是否泄露数据?——异常消息、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 错误传播规则到达边界的未处理异步错误。

需要注意几个边界:

  1. runZonedGuarded 建立之前启动的异步任务不属于该 Zone。
  2. 其他 Isolate 有自己的内存和错误传播边界,不会自动进入主 Isolate 的 Zone。
  3. 某些 Flutter 框架错误已经通过 FlutterError.onError 处理,不应假设它们还会再次经过 Zone。
  4. 同一个错误可能先由某个入口记录,又由另一个入口重复记录。

因此,实际系统需要事件去重,而不是简单地把三个入口的记录数相加。

2.4 其他 Isolate:必须显式连接错误通道

Isolate 之间不共享内存。主 Isolate 中安装的 FlutterError.onErrorPlatformDispatcher.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',
      );
    },
  );
}

这段顺序的因果关系是:

  1. ensureInitialized 让 Flutter 绑定可用,适合在启动阶段访问平台能力。
  2. 先安装错误处理器,避免初始化后、runApp 前产生的错误无人记录。
  3. 最后启动应用和异步任务,让它们尽量处在已建立的捕获边界内。

但不要把所有入口都标记为 fatal: true。例如 FlutterError.onError 中的布局错误可能由 ErrorWidget 替代显示,进程未退出;它通常更适合先报告为框架错误或 non-fatal,是否升级为 fatal 应由后续进程状态和采集平台策略决定。


四、为什么同一错误会被报告两次

重复报告通常有三类来源。

4.1 多个错误入口同时记录

一个错误可能在 Zone 中被捕获,同时 Flutter 框架又调用了 FlutterError.onError。如果两条路径都直接上传,就产生两条事件。

4.2 重启或重试重复执行

例如页面初始化失败后自动重试,错误堆栈相同,但每次失败都可能是独立事件。此时不能只用异常文本去重,因为重复发生次数本身就是重要指标。

4.3 报告上传失败后重试

移动端经常先写入本地队列,再在下次启动或网络恢复时上传。如果上传请求超时但服务器其实已经收到事件,客户端重试会产生重复数据。

一个实用的事件模型应包含:

  • eventId:每次报告唯一;
  • errorGroupKey:用于聚合同类错误;
  • occurrenceCount:同一次会话内的发生次数;
  • releasebuild:区分不同版本;
  • 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

这里的地址不是“错误位置本身”,而是某个二进制文件中的偏移。要正确解释它,必须知道:

  1. 哪个二进制;
  2. 二进制的精确构建版本;
  3. 是否经过混淆、压缩或裁剪;
  4. 对应的符号文件是否来自同一次构建。

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 可能来自不同层:

  1. Dart 层:需要 Flutter AOT 的分离调试信息。
  2. Java/Kotlin 层:如果使用 R8 或 ProGuard,需要保存对应构建产生的 mapping.txt
  3. C/C++/NDK 层:需要未剥离的 native symbols。
  4. Flutter Engine 层:需要与所用 Flutter Engine 版本匹配的符号,通常由平台或构建链路管理。

这些符号不能互相替代。上传 Dart 符号文件并不能还原 Java/Kotlin 混淆名称;保存 mapping.txt 也不能还原 Dart AOT 堆栈。

5.4 iOS 的 dSYM 和 UUID 匹配

iOS 原生 Crash 通常依赖 dSYM。dSYM 必须与崩溃二进制的 UUID 匹配。仅仅“应用版本号相同”不够,因为同一个版本号可以对应多个重新构建的二进制。

常见失败过程是:

  1. 构建了 IPA;
  2. 上传到 App Store 或企业分发;
  3. CI 清理了 Derived Data;
  4. 发生线上 Crash;
  5. 发现本地没有对应的 dSYM;
  6. 只能看到十六进制地址或部分符号。

因此,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 上,它们通常对应 versionNameversionCode;在 iOS 上通常对应 CFBundleShortVersionStringCFBundleVersion。但最终仍受平台工程配置、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-free sessions=1发生 Crash 的会话数总会话数\text{Crash-free sessions} = 1-\frac{\text{发生 Crash 的会话数}}{\text{总会话数}}

分子和分母必须使用同一版本、平台和时间范围。一个自动重启并重复触发的 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_idrequest_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。更可靠的方式是:

  1. 结构化字段白名单;
  2. 对 URL、Header、错误消息分别处理;
  3. 默认不采集请求体;
  4. 对允许采集的字段设置长度上限;
  5. 用包含敏感样本的自动化测试验证脱敏规则。

“上传后再脱敏”是不安全的,因为敏感数据已经经过了本地持久化、网络传输或第三方服务。

8.2 用户标识应当最小化

诊断通常需要判断“是否同一用户反复 Crash”,但不一定需要知道用户是谁。可以使用:

  • 应用内生成的随机安装 ID;
  • 可重置的伪名 ID;
  • 经过明确策略处理的内部用户键。

不要把邮箱、手机号或业务主键直接作为 user_id。如果必须关联业务系统,应让诊断系统保存短期、受权限控制的关联键,并定义保留期限、访问审计和删除机制。

哈希也不自动等于匿名化:如果原始值空间很小,例如手机号或邮箱,攻击者可能通过字典枚举哈希结果。因此,哈希不是替代数据最小化和访问控制的通用方案。

8.3 同意、地区和保留期限

不同地区、应用类型和数据类别可能适用不同的隐私要求。工程上至少应明确:

  • Crash 采集是否属于必要服务,还是需要用户同意;
  • 是否采集设备标识、诊断日志或截图;
  • 数据发送到哪个地区;
  • 第三方平台是否承担处理者角色;
  • 数据保留多久;
  • 用户删除账号后如何删除或隔离关联数据;
  • 谁有权限查看原始事件。

在用户尚未完成隐私初始化时,应用仍可能发生启动 Crash。可以采用“默认只保留最低诊断字段”的策略:先记录非身份化的版本、平台、错误类型和堆栈;在获得授权后才启用更丰富的 Breadcrumb。具体是否允许采集,必须服从产品和法律评估,而不是由 Crash SDK 默认行为决定。


九、离线、启动 Crash 和进程即将退出

移动设备的网络并不可靠,错误发生后立即发送 HTTP 请求不能保证成功。常见的可靠流程是:

  1. 规范化错误事件;
  2. 脱敏并限制大小;
  3. 写入应用沙盒中的本地队列;
  4. 下次启动或网络恢复时上传;
  5. 服务端确认后删除本地事件;
  6. 超过重试次数或保留期限后丢弃。

写入本地队列时也要注意:

  • 不能因为磁盘满而让错误处理再次崩溃;
  • 队列必须设置总大小上限;
  • 文件写入应尽量原子化,避免进程中断留下半条 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');
}

调用时不要 awaitcatch

void startTest() {
  unawaited(triggerAsyncError());
}

这能验证 Zone 和 PlatformDispatcher 的实际边界。测试时应检查最终由哪个入口收到事件,而不是默认两个入口都会收到。

11.3 验证符号文件匹配

发布验证至少应包含:

  1. 生成 Release 产物;
  2. 记录版本、构建号和 Git commit;
  3. 保存对应 Dart 符号、Android mapping、iOS dSYM 或 Web source map;
  4. 上传符号文件;
  5. 在测试设备或测试环境触发已知错误;
  6. 确认服务端显示真实类名、文件名和行号;
  7. 使用另一构建产生的错误符号文件,确认平台拒绝匹配或报告未符号化;
  8. 删除测试事件,避免污染生产指标。

验证“上传成功”不等于“符号化成功”。上传接口接受文件后,还要检查事件是否能被正确还原。典型失败现象包括:

  • 堆栈仍是十六进制地址;
  • 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. 确认版本:检查 1.4.0+42 是否对应线上实际安装包,而不是同版本的另一构建。
  2. 确认构建模式:如果是 Release,检查是否使用了 --split-debug-info 或混淆。
  3. 确认符号匹配:使用构建号、Git commit 和平台元数据验证符号文件来源。
  4. 确认错误入口root-zone 表示它从 Zone 边界传播,但仍需检查是否同时存在 Flutter 框架事件。
  5. 读取 Breadcrumb:错误前先发生请求超时和重试,说明问题可能与重试后的状态机有关,而不是首次请求本身。
  6. 对照源码:符号化后定位 payment.dart,检查重试路径是否使用了已经清理的对象或空响应。
  7. 按版本比较:查看 1.4.0+42 与前一个构建的错误率,判断是新回归还是原有问题。
  8. 检查隐私:确认报告中的支付信息只是状态类别,没有银行卡号、完整请求体或 token。
  9. 复现并验证修复:在相同平台、构建模式和远程配置下复现,再生成新构建并验证新符号。

这个流程的核心不是“看一眼堆栈”,而是将错误路径、构建产物、时间上下文和版本变化连接起来。缺少其中任意一项,都可能把相关性误判为因果关系。


十五、生产取舍:完整性、成本和隐私之间的边界

错误捕获应尽量覆盖更多路径,但覆盖率不是唯一目标:

  • 捕获逻辑太少,Crash 无法定位;
  • 捕获逻辑太复杂,可能在错误处理期间再次失败;
  • Breadcrumb 太多,增加内存、网络和隐私成本;
  • 元数据太少,无法按版本和设备分组;
  • 元数据太多,可能构成用户画像;
  • 符号保存不完整,线上事件无法还原;
  • 符号公开暴露,又可能泄露应用内部结构。

一个成熟的实现通常遵循这样的故障隔离关系:

业务错误
  -> 规范化失败:丢弃该字段,不影响应用
  -> 脱敏失败:拒绝该字段,不发送原文
  -> 本地写入失败:限制重试,不阻塞主线程
  -> 网络上传失败:保留有限队列,稍后重试
  -> 符号化失败:保留原始事件,标记待补符号

尤其不要在 FlutterError.onErrorPlatformDispatcher.onError 或 Zone 处理器中执行阻塞式网络请求。错误处理器的首要职责是隔离故障;上传可以异步进行,本地持久化和服务端幂等负责提高成功率。

最终,Crash 诊断系统应让每条事件都能回答:

哪个平台?
哪个应用版本和构建?
哪个源码提交?
哪个 Isolate 或原生线程?
哪个错误入口?
崩溃前发生了哪些关键状态变化?
堆栈是否由匹配的符号文件还原?
报告中是否只包含必要且经过脱敏的数据?

只有当这些信息形成闭环时,Flutter Crash 监控才不仅是“收集异常”,而是可以支持定位、回归判断、发布决策和隐私审计的质量系统。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。