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

Flutter 错误处理与可观测性:Zone、日志、Crash、性能和隐私

Flutter 应用中的“错误处理”不只是给 try/catch 找一个统一入口。“可观测性”也不只是把日志上传到服务器。一个可交付的系统需要回答以下问题:

  • 错误发生在哪个执行上下文、哪个用户操作和哪个版本?
  • 它是预期业务失败、框架异常、Dart 未捕获异常,还是原生进程崩溃?
  • 当前错误是否已经显示给用户、是否已经上报、是否可能被重复上报?
  • 错误是否伴随帧耗时、内存、网络或磁盘异常?
  • 日志和 Crash 报告是否泄露了用户输入、令牌、位置或业务数据?
  • Android、iOS、桌面和 Web 的异常边界是否相同?

本文基于当前稳定版 Flutter 与 Dart 3 的公开能力,重点说明 Dart Zone、Flutter 错误回调、结构化日志、Dart 异常与原生 Crash、性能观测以及隐私控制之间的关系。


一、先建立错误模型:错误发生在哪里,谁能看见它

1. Dart 中的错误不只有一种

在 Dart 中,函数可以同步抛出异常,也可以通过 Future 异步完成并携带错误。错误本质上是一个对象和一条堆栈:

try {
  throw StateError('cache is corrupted');
} catch (error, stackTrace) {
  print(error);
  print(stackTrace);
}

工程上应把错误分为几类:

  1. 预期业务失败

    例如认证失败、商品已售罄、服务端返回业务错误。这类结果通常需要转换为界面状态,而不是当作 Crash。

  2. 可恢复技术错误

    例如网络超时、缓存读取失败、图片加载失败。应用可以重试、降级或显示错误状态,同时记录适量诊断信息。

  3. 编程错误

    例如空值假设错误、状态机违反前置条件、数组越界。它们可能在开发阶段暴露,也可能在生产环境中变成 Dart 未捕获异常。

  4. 框架或渲染错误

    例如 build、布局、绘制、手势回调中的异常。Flutter 通常通过 FlutterError.onError 暴露。

  5. 原生进程崩溃

    例如 Android/iOS 原生插件中的非法内存访问、abort、致命信号或引擎层崩溃。这类问题不是 Dart try/catch 能捕获的。

分类的目的不是贴标签,而是决定错误路径:业务失败进入状态管理,技术错误进入恢复逻辑,未处理异常进入错误上报,原生崩溃进入平台 Crash 系统。

2. try/catch 的边界由执行时机决定

下面的代码可以捕获错误:

Future<void> load() async {
  try {
    final value = await repository.fetch();
    print(value);
  } catch (error, stackTrace) {
    print('caught: $error');
  }
}

因为 awaitFuture 的失败重新带回当前异步函数,try 仍然处于有效范围内。

但下面的代码不能捕获“脱离当前控制流”的错误:

void start() {
  try {
    fetchWithoutAwait();
  } catch (error) {
    print('不会捕获异步完成后的错误');
  }
}

Future<void> fetchWithoutAwait() async {
  throw StateError('late failure');
}

fetchWithoutAwait() 返回 Future 后,start() 已经结束。错误发生在稍后的异步完成阶段,因此必须显式等待或为该 Future 安装处理器:

Future<void> start() async {
  try {
    await fetchWithoutAwait();
  } catch (error, stackTrace) {
    print('捕获成功: $error');
  }
}

形式化地说,若异步操作 F 的失败发生在时间 t_error,而 try 所在调用帧在 t_return < t_error 时已经结束,则该 try 不再是错误处理边界。await 的作用是把异步结果重新接入当前函数的控制流。

常见反例是:

void init() {
  unawaited(loadConfig());
}

这种写法本身可以是合理的,但必须确保 loadConfig() 内部处理了预期错误,或者外层 Zone 能够接收其未处理错误。不能因为调用点没有抛异常,就认为异步操作没有失败。


二、Zone:异步错误的执行上下文

1. Zone 解决什么问题

Dart Zone 是异步执行上下文。它可以携带:

  • 未处理异步错误的处理器;
  • 日志、计时器、随机数等上下文信息;
  • 对部分异步操作的拦截;
  • 当前请求、会话或操作的关联标识。

Zone.current 表示当前代码正在其中运行的 Zone:

import 'dart:async';

void main() {
  runZonedGuarded(
    () {
      print(Zone.current[#requestId]);
      Future<void>.delayed(
        const Duration(milliseconds: 10),
        () => throw StateError('async failure'),
      );
    },
    (error, stackTrace) {
      print('zone caught: $error');
    },
    zoneValues: {
      #requestId: 'request-123',
    },
  );
}

这里的错误发生在 Future.delayed 的异步回调中,但该回调继承了创建它时的 Zone,因此会进入 runZonedGuarded 的错误处理器。

2. Zone 不是全局异常捕获器

Zone 有三个重要限制:

限制一:不能代替所有 Flutter 错误入口

Flutter 框架对 build、布局、绘制等错误有自己的报告路径。应配置 FlutterError.onError,而不是只依赖 Zone。

限制二:不同 Isolate 之间不共享 Zone

Dart 的 Isolate 有独立的堆、事件循环和 Zone。子 Isolate 中的错误不会自动进入主 Isolate 的 Zone。

限制三:原生崩溃不会变成 Dart 异常

当进程被原生代码终止时,Dart 代码可能没有机会运行错误处理器。Zone 只能处理仍处于 Dart 运行时控制范围内的错误。

3. runZonedGuarded 的正确使用方式

应用入口通常需要同时配置 Flutter 和平台错误入口:

import 'dart:async';
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() {
  runZonedGuarded(
    () {
      WidgetsFlutterBinding.ensureInitialized();
      _installFlutterErrorHandler();
      _installPlatformErrorHandler();
      runApp(const MyApp());
    },
    (error, stackTrace) {
      AppReporter.instance.report(
        error,
        stackTrace,
        source: 'zone',
      );
    },
  );
}

void _installFlutterErrorHandler() {
  FlutterError.onError = (details) {
    // 保留 Flutter 默认的本地诊断行为。
    FlutterError.presentError(details);

    AppReporter.instance.report(
      details.exception,
      details.stack ?? StackTrace.empty,
      source: 'flutter',
      context: details.context?.toString(),
    );
  };
}

void _installPlatformErrorHandler() {
  PlatformDispatcher.instance.onError = (error, stackTrace) {
    AppReporter.instance.report(
      error,
      stackTrace,
      source: 'platform-dispatcher',
    );

    // true 表示该错误已被应用处理。
    // 如果返回 false,平台可能继续按未处理错误处理。
    return true;
  };
}

class AppReporter {
  AppReporter._();

  static final instance = AppReporter._();

  void report(
    Object error,
    StackTrace stackTrace, {
    required String source,
    String? context,
  }) {
    // 实际项目中应在这里进行去重、脱敏、采样和异步发送。
    debugPrint(
      '[error] source=$source error=$error '
      'context=$context\n$stackTrace',
    );
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: Text('ready')),
      ),
    );
  }
}

这段代码中的几个顺序有实际意义:

  1. runZonedGuarded 包住应用启动和 runApp,使异步启动错误具备 Zone 边界。
  2. FlutterError.onError 负责 Flutter 框架报告的错误。
  3. PlatformDispatcher.instance.onError 负责根 Isolate 中未被其他入口处理的异步错误。
  4. 回调返回 true 表示错误已处理;如果上报器没有真正处理错误,应谨慎决定是否返回 false
  5. FlutterError.presentError 保留了 Flutter 默认的本地诊断输出,便于开发阶段排查。

4. 三个入口可能重复上报

同一个故障有时可能经过多个入口,尤其是启动异常、框架回调异常或某些平台实现差异。上报系统应做去重,而不是假设“每个回调只会触发一次”。

一个实用的错误指纹可以由以下字段组成:

fingerprint =
  releaseVersion
  + errorType
  + normalizedMessage
  + topStackFrames

不能直接用完整错误消息作为唯一键,因为消息中可能包含用户输入、请求参数或动态 ID。应先归一化,例如把数字、UUID、路径中的用户目录替换为占位符。


三、Flutter 错误边界:框架异常与用户界面

1. FlutterError.onError 负责报告,不负责自动恢复所有状态

在 Widget 的 build 方法中发生异常时,Flutter 会调用 FlutterError.onError。默认处理通常会输出诊断信息,并在合适的场景中显示错误 Widget。

可以自定义错误 Widget:

void configureErrorWidget() {
  ErrorWidget.builder = (FlutterErrorDetails details) {
    return const Material(
      child: Center(
        child: Text('页面暂时无法显示'),
      ),
    );
  };
}

但这只是显示层降级。它不能保证:

  • 当前页面状态已经恢复;
  • 业务请求已经取消;
  • 错误只被上报一次;
  • 页面之外的异步任务不会继续运行。

因此,ErrorWidget.builder 不应被当作完整错误边界。页面级恢复应由状态管理或路由层明确实现。

2. 局部 try/catch 仍然必要

全局处理器适合处理“最后未处理的异常”,不适合替代正常控制流。例如网络请求失败应在服务或状态层处理:

class LoadState<T> {
  const LoadState.loading()
      : value = null,
        error = null;

  const LoadState.data(this.value) : error = null;

  const LoadState.failure(this.error) : value = null;

  final T? value;
  final Object? error;
}

Future<LoadState<String>> loadProfile(ProfileRepository repository) async {
  try {
    final profile = await repository.fetchProfile();
    return LoadState<String>.data(profile);
  } on TimeoutException catch (error, stackTrace) {
    AppReporter.instance.report(
      error,
      stackTrace,
      source: 'profile-timeout',
    );
    return const LoadState<String>.failure(
      TimeoutException('profile request timed out'),
    );
  } on FormatException catch (error, stackTrace) {
    AppReporter.instance.report(
      error,
      stackTrace,
      source: 'profile-invalid-response',
    );
    return const LoadState<String>.failure(
      FormatException('invalid profile response'),
    );
  }
}

abstract interface class ProfileRepository {
  Future<String> fetchProfile();
}

这里的关键点是:已知且可恢复的错误在靠近业务语义的地方被转换为状态;未知错误继续向全局边界传播。

如果需要重新抛出错误,应使用 rethrow 保留原始堆栈:

Future<void> save() async {
  try {
    await writeToDisk();
  } catch (error, stackTrace) {
    logStorageFailure(error, stackTrace);
    rethrow; // 保留原始调用位置
  }
}

下面这种写法会丢失原始调用位置:

catch (error) {
  throw error; // 可能重置堆栈起点
}

四、从根 Isolate 到子 Isolate:错误传播和故障路径

1. 根 Isolate 的典型路径

flowchart TD
    A[用户操作或系统回调] --> B{错误发生位置}
    B -->|业务 Future| C[局部 try/catch]
    B -->|Flutter build/layout/paint| D[FlutterError.onError]
    B -->|根 Isolate 未处理异步错误| E[PlatformDispatcher.onError]
    B -->|Zone 中的未处理异步错误| F[runZonedGuarded]
    B -->|原生代码或进程级崩溃| G[平台 Crash 系统]
    C --> H[状态恢复或错误上报]
    D --> H
    E --> H
    F --> H
    G --> I[原生 Crash 报告与符号化]

这不是严格的单一路由图。某些错误可能同时符合多个边界,具体顺序受 Flutter 框架、Dart 运行时和平台实现影响。因此上报层必须有来源字段和去重策略。

2. 子 Isolate 不会自动继承主 Isolate 的 Reporter

如果使用 Isolate.spawn,需要显式设计错误回传协议。一个简化示例:

import 'dart:async';
import 'dart:isolate';

Future<void> runWorker() async {
  final receivePort = ReceivePort();

  final isolate = await Isolate.spawn(
    _workerMain,
    receivePort.sendPort,
  );

  final message = await receivePort.first;
  receivePort.close();
  isolate.kill(priority: Isolate.immediate);

  if (message is List && message.length == 2) {
    final error = message[0];
    final stack = message[1];
    throw StateError('worker failed: $error\n$stack');
  }
}

void _workerMain(SendPort sendPort) {
  try {
    throw StateError('worker failure');
  } catch (error, stackTrace) {
    sendPort.send([error.toString(), stackTrace.toString()]);
  }
}

这个例子用字符串传递错误和堆栈,便于说明边界,但生产代码还要考虑:

  • 消息是否可发送;
  • 子 Isolate 是否正常退出;
  • 主 Isolate 等待期间是否超时;
  • 错误消息是否包含敏感数据;
  • 计算任务失败后是否需要重试或降级。

如果错误未在子 Isolate 内处理,可以使用错误监听端口:

final errors = RawReceivePort((dynamic message) {
  // message 通常是包含错误描述和堆栈文本的可传输结构。
  // 具体协议应以当前 Dart 平台行为和 API 文档为准。
});

isolate.addErrorListener(errors.sendPort);

不要假设这种监听会把错误自动转成主 Isolate 中原始的异常对象。跨 Isolate 传输的是可发送数据,通常需要自行定义序列化协议。

3. Web 和桌面差异

  • Android/iOS:Dart 异常、Flutter 异常和原生异常通常由不同系统负责。原生插件崩溃需要 Android/iOS 原生 Crash 工具链。
  • 桌面:Windows、macOS、Linux 的原生崩溃、窗口系统异常和插件实现差异较大,不能只依赖 Flutter 层回调。
  • Web:没有传统移动端意义上的进程 Crash。JavaScript 异常、浏览器运行时错误、资源加载失败和 WebAssembly/编译产物问题需要结合浏览器错误监控与服务器日志。浏览器安全策略还可能限制堆栈、跨域资源和 source map 获取。
  • Isolate:跨平台能力和实现细节存在差异,特别是 Web 不应简单套用移动端 Isolate 的线程模型。应以当前 Flutter/Dart API Reference 对目标平台的支持情况为准。

五、日志:记录可解释的事实,而不是倾倒字符串

1. 日志和 Crash 报告的职责不同

日志主要回答“系统按什么顺序做了什么”;Crash 报告主要回答“哪里发生了未处理故障”。二者可以共享事件,但不应完全合并。

例如:

INFO  checkout_started cart_size=3
INFO  payment_request_started provider=alipay
WARN  payment_request_timeout elapsed_ms=8000
ERROR checkout_failed error_type=TimeoutException

一个 print(error) 通常缺少:

  • 版本和构建号;
  • 当前路由;
  • 会话或操作关联 ID;
  • 设备与平台;
  • 前后事件;
  • 错误来源;
  • 是否已经向用户展示恢复界面。

2. 用结构化事件表示日志

可以使用 dart:developerlog 输出结构化字段:

import 'dart:developer' as developer;

void logCheckoutStarted({
  required String operationId,
  required int cartSize,
}) {
  developer.log(
    'checkout started',
    name: 'app.checkout',
    level: 800, // 约定为 INFO;项目应统一等级定义
    sequenceNumber: DateTime.now().microsecondsSinceEpoch,
    error: null,
    stackTrace: null,
  );

  // 实际生产日志通常还会把 operationId、cartSize 等字段
  // 放入统一事件对象,再由 sink 写入控制台或远端系统。
}

developer.log 适合接入 Dart DevTools 等诊断工具,但它不是完整的远程日志系统。应在应用层定义事件模型,例如:

enum LogLevel { debug, info, warning, error }

class LogEvent {
  const LogEvent({
    required this.level,
    required this.name,
    required this.timestamp,
    this.operationId,
    this.route,
    this.fields = const {},
  });

  final LogLevel level;
  final String name;
  final DateTime timestamp;
  final String? operationId;
  final String? route;
  final Map<String, Object?> fields;
}

建议把字段分为三类:

  • 稳定维度:版本、平台、渠道、功能模块;
  • 关联维度:session ID、operation ID、trace ID;
  • 故障维度:错误类型、网络状态、重试次数、耗时、状态码。

不要把完整请求体、响应体和用户输入默认写入日志。

3. 操作关联 ID 比全局用户 ID 更安全

对于一次支付、上传或登录操作,生成一个短期 operationId

String newOperationId() {
  final now = DateTime.now().microsecondsSinceEpoch;
  return 'op-$now';
}

生产环境应使用更合适的随机 ID 生成方式,而不是依赖时间戳。这个 ID 用于串联:

screen_opened
request_started
request_retry
request_failed
fallback_shown

与永久用户 ID 相比,短期操作 ID 更容易控制保留时间和关联范围。即使使用哈希,用户 ID 仍可能属于个人数据,不应因为“不可逆”就自动视为匿名数据。


六、Crash:未处理异常和进程崩溃不是同一件事

1. Dart Crash 与原生 Crash

工程上常把所有严重故障都称为 Crash,但至少要区分:

类型 典型来源 Dart try/catch 能否捕获
Dart 未处理异常 Future、回调、业务代码 通常不能,取决于边界
Flutter 框架异常 build、布局、绘制 通过 FlutterError 报告
子 Isolate 错误 后台计算、解析任务 不会自动进入主 Isolate
Android/iOS 原生崩溃 插件、JNI/Obj-C/Swift、非法内存访问 不能
Web JavaScript 异常 编译产物、浏览器 API 由 Web 运行时监控

因此,一个完整的 Crash 方案通常包含:

  1. Flutter/Dart 未处理异常收集;
  2. Android/iOS 原生 Crash 收集;
  3. 桌面原生 Crash 收集;
  4. Web JavaScript 异常收集;
  5. 符号化和版本构建物管理;
  6. 与发布版本、Flavor、渠道关联。

2. 符号化决定堆栈能否被人读懂

发布构建可能经过压缩、混淆或编译。没有匹配的符号文件,报告可能只有地址和缩短后的函数名。

Flutter/Dart 混淆发布常见形式是:

flutter build apk --release \
  --obfuscate \
  --split-debug-info=build/symbols/android-arm64

参数含义:

  • --obfuscate:对 Dart 符号进行混淆;
  • --split-debug-info=...:把还原符号所需的信息输出到指定目录;
  • 该目录必须作为发布构建物的一部分安全保存;
  • 不同版本、架构、Flavor 应使用可追踪的构建目录。

风险在于:如果符号文件丢失,线上堆栈可能无法还原;如果符号文件被公开发布,则可能暴露代码结构。CI 应在构建后记录:

git_commit
flutter_version
dart_version
flavor
target_platform
build_number
artifact_checksum
symbol_directory

Android 原生代码还可能涉及 R8/ProGuard mapping;iOS 原生 Crash 通常需要对应的 dSYM;Web 则需要与部署产物严格匹配的 source map。Flutter Dart 符号、Android mapping、iOS dSYM 和 Web source map 是不同的符号体系,不能相互替代。

3. 上报器本身也会失败

错误处理代码运行在故障路径上,可能遇到:

  • 网络不可用;
  • 磁盘已满;
  • 应用即将退出;
  • 序列化异常;
  • 递归触发新的日志或上报错误;
  • 隐私过滤器自身抛异常。

因此上报逻辑应遵循“尽力而为、有限耗时、禁止递归”的原则:

class SafeReporter {
  bool _reporting = false;

  Future<void> report(Object error, StackTrace stackTrace) async {
    if (_reporting) return;

    _reporting = true;
    try {
      final payload = _buildPayload(error, stackTrace);
      await _sendWithTimeout(payload);
    } catch (_) {
      // 上报失败不能再次调用 report,否则可能无限递归。
      _writeMinimalLocalDiagnostic(error);
    } finally {
      _reporting = false;
    }
  }

  Map<String, Object?> _buildPayload(
    Object error,
    StackTrace stackTrace,
  ) {
    return {
      'error_type': error.runtimeType.toString(),
      'message': _redact(error.toString()),
      'stack': _redact(stackTrace.toString()),
    };
  }

  Future<void> _sendWithTimeout(Map<String, Object?> payload) {
    return Future<void>.delayed(const Duration(milliseconds: 1))
        .timeout(const Duration(seconds: 2));
  }

  void _writeMinimalLocalDiagnostic(Object error) {
    // 不把完整敏感信息无限期写入本地。
  }

  String _redact(String value) => value
      .replaceAll(RegExp(r'Bearer\s+[A-Za-z0-9._-]+'), 'Bearer [REDACTED]')
      .replaceAll(RegExp(r'[\w.+-]+@[\w.-]+\.\w+'), '[EMAIL_REDACTED]');
}

这段代码只是演示边界,不是通用脱敏器。正则表达式可能漏掉编码后的令牌、嵌套 JSON 和自定义凭证格式。真正可靠的做法是:在数据产生源头就禁止敏感字段进入事件模型,而不是只依赖最后一步字符串替换。


七、性能可观测性:错误发生时,应用是否已经变慢

错误报告如果只有堆栈,通常无法解释“为什么用户觉得页面坏了”。移动端体验还受到帧流水线、重建、栅格线程、内存和 I/O 的影响。

1. 帧预算与刷新率

设屏幕刷新率为 R,单帧可用时间近似为:

Tframe=1000R msT_{\text{frame}} = \frac{1000}{R}\ \text{ms}

因此:

  • 60 Hz 时约为 16.67 ms
  • 90 Hz 时约为 11.11 ms
  • 120 Hz 时约为 8.33 ms

这不是 Flutter 保证的“每帧一定完成时间”,而是渲染系统在理想情况下的时间预算。若 UI 线程的 build/layout 超时,可能出现 UI jank;若 raster 线程绘制超时,也可能出现栅格卡顿。

2. 采集 FrameTiming

可以通过 SchedulerBinding 观察帧耗时:

import 'package:flutter/scheduler.dart';

void installFrameObserver() {
  SchedulerBinding.instance.addTimingsCallback((timings) {
    for (final timing in timings) {
      final buildMs =
          timing.buildDuration.inMicroseconds / Duration.microsecondsPerMillisecond;
      final rasterMs =
          timing.rasterDuration.inMicroseconds / Duration.microsecondsPerMillisecond;

      if (buildMs > 16.67 || rasterMs > 16.67) {
        // 这里只是针对 60 Hz 的示例阈值。
        // 多刷新率设备应根据设备刷新率调整阈值。
        debugPrint(
          'slow frame: build=${buildMs.toStringAsFixed(2)}ms '
          'raster=${rasterMs.toStringAsFixed(2)}ms',
        );
      }
    }
  });
}

FrameTiming 中的 buildDurationrasterDuration 有助于区分问题位置:

  • build 高:可能是 Widget 重建过多、同步计算、列表构建或状态传播范围过大;
  • raster 高:可能是过度绘制、复杂阴影、图片处理、裁剪或 GPU 压力;
  • 两者都低但体验仍差:可能是输入、平台线程、动画调度、I/O 或系统资源问题。

不应在每一帧都上传远程事件。可采用本地聚合:

session_id
route
sample_window = 30 seconds
frame_count
slow_frame_count
p95_build_ms
p95_raster_ms
memory_warning_count

只在达到阈值或发生关联错误时上报摘要,能显著降低网络和存储开销。

3. 在性能事件中关联错误

例如一次图片解码失败同时伴随栅格耗时升高,单独看 Crash 堆栈很难判断原因。可以给操作打标:

import 'dart:developer' as developer;

Future<void> decodeAvatar() async {
  final operationId = 'avatar-${DateTime.now().microsecondsSinceEpoch}';

  developer.Timeline.startSync(
    'decode_avatar',
    arguments: {'operation_id': operationId},
  );

  try {
    await Future<void>.delayed(const Duration(milliseconds: 5));
  } catch (error, stackTrace) {
    AppReporter.instance.report(
      error,
      stackTrace,
      source: 'avatar-decode',
      context: 'operation_id=$operationId',
    );
    rethrow;
  } finally {
    developer.Timeline.finishSync();
  }
}

Timeline 事件主要用于本地调试和 DevTools 时间线分析,不应默认当作远程日志上传。生产环境如需保留性能数据,应使用采样和聚合,而不是上传完整时间线。

4. 不要在错误回调里做重工作

错误处理器中同步执行以下操作会放大故障:

  • 同步 JSON 序列化大对象;
  • 读取完整日志文件;
  • 截取高分辨率屏幕;
  • 立即压缩多个文件;
  • 发起无限等待的网络请求;
  • 触发复杂 Widget 重建。

错误路径应尽快完成本地记录,将有限大小的事件放入队列,再由后台任务发送。应用即将退出时,队列不一定有机会发送,因此 Crash 系统通常还需要原生层的启动时缓存和下次启动发送机制。


八、隐私:错误信息本身就是数据出口

1. 需要禁止进入报告的数据

以下内容不应默认进入日志、Breadcrumb 或 Crash 上下文:

  • 访问令牌、刷新令牌、Cookie;
  • 密码、验证码、银行卡号;
  • 完整地址、精确位置;
  • 用户输入、聊天内容、医疗或财务信息;
  • 完整请求头和响应体;
  • 可直接识别个人的设备标识;
  • 未经必要性评估的截图、剪贴板和文件内容。

堆栈本身也可能泄露数据,例如异常消息包含请求 URL、文件路径、邮箱或服务端返回内容。

2. 脱敏应在建模阶段完成

不推荐:

final event = {
  'request_body': jsonEncode(body),
};
// 最后一步再尝试替换 password 和 token。

更安全的做法是建立允许字段:

Map<String, Object?> safeRequestContext({
  required String method,
  required int statusCode,
  required int elapsedMs,
}) {
  return {
    'method': method,
    'status_code': statusCode,
    'elapsed_ms': elapsedMs,
  };
}

如果确实需要业务标识,应使用最小化字段,例如商品数量、错误码、接口名称,而不是整个业务对象。

3. 同意、保留和删除

不同地区的隐私法规、应用商店政策和企业合规要求不同。技术实现至少应支持:

  • 在允许的情况下延后启用可选分析;
  • 区分必要的安全错误报告和可选行为分析;
  • 按环境关闭开发日志或敏感调试字段;
  • 配置数据保留期限;
  • 支持用户数据删除或关联清理;
  • 限制后台访问权限并记录访问行为;
  • 使用 TLS 传输,并验证服务端配置;
  • 在跨境传输或第三方 SDK 场景下进行合规评估。

“只上传哈希”不自动等于匿名。稳定哈希仍可能用于长期识别同一个用户或设备,因此仍需按实际用途评估其隐私性质。

4. 屏幕截图和用户同意

截图对 UI 错误非常有帮助,但也最容易泄露隐私。若启用截图,应:

  1. 明确用途和必要性;
  2. 默认关闭敏感页面截图;
  3. 对输入框、支付页面和个人资料页面进行遮挡;
  4. 限制分辨率、保留时间和访问人员;
  5. 只在用户允许或明确的诊断流程中采集。

九、发布、Flavor 与可观测性版本一致性

可观测性数据必须能定位到准确的发布产物。至少应在每个事件中记录:

app_version
build_number
flavor
platform
architecture
git_commit
environment

例如同一个 1.4.0 可能有 devstagingproduction 三个 Flavor。如果只记录版本号,错误会被错误地合并。

发布流程应形成以下关系:

flowchart LR
    A[源代码 commit] --> B[CI 构建]
    B --> C[版本和 Flavor]
    C --> D[Android/iOS/Web/桌面产物]
    C --> E[Dart 符号与平台符号]
    D --> F[商店或灰度发布]
    E --> G[Crash 平台符号化]
    F --> H[线上事件]
    H --> G

验证步骤应包括:

  1. 构建产物记录版本、Flavor、commit 和架构;
  2. 符号文件上传到与版本匹配的位置;
  3. 在测试环境人为触发一个已知 Dart 异常;
  4. 验证报告能显示可读的 Dart 类名和源码位置;
  5. 验证 Android/iOS 原生测试 Crash 能完成符号化;
  6. 验证 Web source map 不会被错误地作为公开静态资源暴露;
  7. 验证灰度版本和正式版本不会共享错误的环境标识。

如果上报平台显示的是旧版本堆栈,首先检查构建号、Flavor、架构和符号文件是否匹配,而不是立即修改业务代码。


十、一个可落地的错误处理分层

可以把处理逻辑分为四层。

第一层:业务层

处理已知失败,并转成明确状态:

TimeoutException -> 可重试
401             -> 重新认证
403             -> 无权限页面
业务错误码      -> 用户可理解的提示

这一层决定用户体验和恢复动作。

第二层:组件层

对图片、列表、页面和插件调用提供局部降级。例如图片加载失败显示占位图,单个列表项失败不影响整个列表。

第三层:Flutter/Dart 全局层

配置:

  • FlutterError.onError
  • PlatformDispatcher.instance.onError
  • runZonedGuarded
  • 子 Isolate 错误消息协议

这一层的目标是捕获遗漏并上报,不是吞掉所有错误。

第四层:平台 Crash 层

处理:

  • Android Java/Kotlin 和 native Crash;
  • iOS Objective-C/Swift/native Crash;
  • 桌面原生崩溃;
  • Web JavaScript 异常。

这一层需要平台对应的符号化和发布物管理。


十一、常见误解与诊断方法

误解一:设置了 runZonedGuarded 就不会漏错

错误表现:业务异步异常能上报,但 Flutter 构建错误或原生插件崩溃没有记录。

原因:Zone、Flutter 错误回调、平台错误回调和原生 Crash 属于不同边界。

诊断方法:

  1. 人为触发一个 Future 未处理异常;
  2. build 中触发一个 Flutter 异常;
  3. 在插件或原生测试代码中触发平台异常;
  4. 分别检查三个系统是否收到事件。

误解二:所有异常都应立即上报为 Crash

错误表现:错误数量极高,但真正的编程故障被网络超时和用户取消淹没。

原因:没有区分可恢复错误和未处理错误。

改进方式是为事件建模:

handled_error
recoverable_failure
unhandled_exception
fatal_native_crash

可恢复错误通常按比例采样或聚合;未处理异常应保留更高完整度;原生 Crash 按平台 Crash 系统的机制处理。

误解三:日志越详细,诊断能力越强

错误表现:日志包含令牌、请求体、用户输入,且远程成本高、查询困难。

原因:缺少字段白名单、采样和保留策略。

正确方向是让日志“结构稳定、字段有限、可关联、可脱敏”,而不是无条件记录更多文本。

误解四:看到 buildDuration 高,就一定是 Widget 重建问题

错误表现:优化了 Widget 重建,仍然卡顿。

原因:buildDuration 只描述帧流水线的一部分。同步 JSON 解码、平台通道、内存压力、I/O、raster 和系统调度都可能造成体验问题。

诊断时应结合:

  • Flutter DevTools Performance;
  • CPU Profile;
  • Memory;
  • Timeline;
  • 平台原生工具;
  • 错误事件前后的操作日志。

误解五:返回 true 就等于问题已经解决

PlatformDispatcher.instance.onError 中返回 true 的含义是“该错误已被处理”,它不会自动恢复页面状态,也不会阻止已经发生的数据损坏。返回值应与应用实际的处理策略一致,不能只为避免系统继续输出而盲目返回 true


十二、最小验收清单

一个可交付的 Flutter 错误与可观测性实现,至少应通过以下验证:

  • Future 未处理异常可以被捕获;
  • Flutter build 或布局异常可以被捕获;
  • try/catch 不会覆盖原始堆栈;
  • 同一故障不会在多个入口重复计数;
  • 子 Isolate 错误有明确的跨 Isolate 协议;
  • Android、iOS、桌面和 Web 的平台异常路径已单独验证;
  • 发布版本的 Dart、Android、iOS 和 Web 符号文件可恢复;
  • 日志包含版本、Flavor、平台和操作关联信息;
  • 日志不会包含令牌、密码和完整用户输入;
  • 性能事件使用采样和聚合,不在每帧上传;
  • 上报失败不会递归触发新的上报;
  • 应用退出前的错误不会依赖无限等待的网络请求;
  • 灰度和正式环境使用不同且准确的环境标识;
  • 已定义保留、删除、访问权限和用户同意策略。

错误处理解决的是“故障如何被控制”,可观测性解决的是“故障如何被解释”。Zone 负责异步执行上下文,Flutter 回调负责框架边界,平台 Crash 系统负责进程级故障,结构化日志负责还原因果链,性能数据负责解释体验退化,而隐私策略负责限制这些能力的副作用。只有把这些边界连接起来,错误报告才会从一条堆栈信息变成可以验证、修复和回归的工程证据。


系列导航与关联阅读

官方资料

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