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);
}
工程上应把错误分为几类:
-
预期业务失败
例如认证失败、商品已售罄、服务端返回业务错误。这类结果通常需要转换为界面状态,而不是当作 Crash。
-
可恢复技术错误
例如网络超时、缓存读取失败、图片加载失败。应用可以重试、降级或显示错误状态,同时记录适量诊断信息。
-
编程错误
例如空值假设错误、状态机违反前置条件、数组越界。它们可能在开发阶段暴露,也可能在生产环境中变成 Dart 未捕获异常。
-
框架或渲染错误
例如
build、布局、绘制、手势回调中的异常。Flutter 通常通过FlutterError.onError暴露。 -
原生进程崩溃
例如 Android/iOS 原生插件中的非法内存访问、
abort、致命信号或引擎层崩溃。这类问题不是 Darttry/catch能捕获的。
分类的目的不是贴标签,而是决定错误路径:业务失败进入状态管理,技术错误进入恢复逻辑,未处理异常进入错误上报,原生崩溃进入平台 Crash 系统。
2. try/catch 的边界由执行时机决定
下面的代码可以捕获错误:
Future<void> load() async {
try {
final value = await repository.fetch();
print(value);
} catch (error, stackTrace) {
print('caught: $error');
}
}
因为 await 将 Future 的失败重新带回当前异步函数,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')),
),
);
}
}
这段代码中的几个顺序有实际意义:
runZonedGuarded包住应用启动和runApp,使异步启动错误具备 Zone 边界。FlutterError.onError负责 Flutter 框架报告的错误。PlatformDispatcher.instance.onError负责根 Isolate 中未被其他入口处理的异步错误。- 回调返回
true表示错误已处理;如果上报器没有真正处理错误,应谨慎决定是否返回false。 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:developer 的 log 输出结构化字段:
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 方案通常包含:
- Flutter/Dart 未处理异常收集;
- Android/iOS 原生 Crash 收集;
- 桌面原生 Crash 收集;
- Web JavaScript 异常收集;
- 符号化和版本构建物管理;
- 与发布版本、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,单帧可用时间近似为:
因此:
- 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 中的 buildDuration 和 rasterDuration 有助于区分问题位置:
- 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 错误非常有帮助,但也最容易泄露隐私。若启用截图,应:
- 明确用途和必要性;
- 默认关闭敏感页面截图;
- 对输入框、支付页面和个人资料页面进行遮挡;
- 限制分辨率、保留时间和访问人员;
- 只在用户允许或明确的诊断流程中采集。
九、发布、Flavor 与可观测性版本一致性
可观测性数据必须能定位到准确的发布产物。至少应在每个事件中记录:
app_version
build_number
flavor
platform
architecture
git_commit
environment
例如同一个 1.4.0 可能有 dev、staging 和 production 三个 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
验证步骤应包括:
- 构建产物记录版本、Flavor、commit 和架构;
- 符号文件上传到与版本匹配的位置;
- 在测试环境人为触发一个已知 Dart 异常;
- 验证报告能显示可读的 Dart 类名和源码位置;
- 验证 Android/iOS 原生测试 Crash 能完成符号化;
- 验证 Web source map 不会被错误地作为公开静态资源暴露;
- 验证灰度版本和正式版本不会共享错误的环境标识。
如果上报平台显示的是旧版本堆栈,首先检查构建号、Flavor、架构和符号文件是否匹配,而不是立即修改业务代码。
十、一个可落地的错误处理分层
可以把处理逻辑分为四层。
第一层:业务层
处理已知失败,并转成明确状态:
TimeoutException -> 可重试
401 -> 重新认证
403 -> 无权限页面
业务错误码 -> 用户可理解的提示
这一层决定用户体验和恢复动作。
第二层:组件层
对图片、列表、页面和插件调用提供局部降级。例如图片加载失败显示占位图,单个列表项失败不影响整个列表。
第三层:Flutter/Dart 全局层
配置:
FlutterError.onErrorPlatformDispatcher.instance.onErrorrunZonedGuarded- 子 Isolate 错误消息协议
这一层的目标是捕获遗漏并上报,不是吞掉所有错误。
第四层:平台 Crash 层
处理:
- Android Java/Kotlin 和 native Crash;
- iOS Objective-C/Swift/native Crash;
- 桌面原生崩溃;
- Web JavaScript 异常。
这一层需要平台对应的符号化和发布物管理。
十一、常见误解与诊断方法
误解一:设置了 runZonedGuarded 就不会漏错
错误表现:业务异步异常能上报,但 Flutter 构建错误或原生插件崩溃没有记录。
原因:Zone、Flutter 错误回调、平台错误回调和原生 Crash 属于不同边界。
诊断方法:
- 人为触发一个
Future未处理异常; - 在
build中触发一个 Flutter 异常; - 在插件或原生测试代码中触发平台异常;
- 分别检查三个系统是否收到事件。
误解二:所有异常都应立即上报为 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 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 应用安全:Secret、网络、存储、WebView、证书和供应链
- 下一篇:Flutter 大型应用架构:分层、Feature、依赖注入和多端边界
- 延伸:Flutter 性能优化:帧流水线、重建、栅格、内存和 DevTools
- 延伸:Flutter 应用发布:签名、Flavor、商店、Web/桌面、灰度和回滚
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论