Flutter 基础体系 · 第 26/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Dart 错误处理:Exception、Error、StackTrace、Zone 和契约
Dart 的错误处理不是单一的 try/catch 问题。一个完整的错误路径至少包含五个问题:
- 错误对象是什么:
Exception还是Error? - 错误发生在哪里:
StackTrace如何记录调用路径? - 错误何时传播:同步调用、
Future、Stream和未等待的异步任务有什么差异? - 错误由谁接收:局部
catch、Zone、Flutter 框架还是平台入口? - 调用者和实现者之间的契约是什么:哪些输入合法,失败时返回什么,哪些错误必须暴露?
如果只记住“捕获异常然后打印日志”,通常无法解释下面这些现象:
try/catch明明包住了调用,却没有捕获异步错误;catch (e)能捕获错误,但日志缺少有用的调用栈;throw e之后,原始调用位置消失;runZonedGuarded能捕获一部分错误,却捕获不到另一些错误;assert在开发环境有效,发布环境却完全不执行;- Flutter 界面显示了错误页,但后台
Future仍然发生了未处理错误。
下面从语言机制开始,再连接到 Flutter 的错误入口和工程契约。
一、Dart 中的“抛出”与“捕获”
1. throw 可以抛出任意对象
Dart 的 throw 表达式接受任意对象:
void main() {
throw 'network failed';
}
这段代码在语法和运行时上都是合法的,因为 Dart 的错误传播对象最终都是 Object。但是,直接抛出字符串会损失类型信息,也不利于调用者区分错误类别。
通常应该抛出实现了 Exception 或继承了 Error 的对象:
class UserNotFoundException implements Exception {
final String userId;
UserNotFoundException(this.userId);
@override
String toString() => 'UserNotFoundException(userId: $userId)';
}
void loadUser(String userId) {
throw UserNotFoundException(userId);
}
throw 会终止当前控制流。对于同步函数,它会沿着调用栈向上寻找匹配的 catch;如果没有找到,错误成为当前执行上下文中的未处理错误。
void main() {
try {
loadUser('42');
print('这行不会执行');
} on UserNotFoundException catch (error) {
print('用户不存在:$error');
}
}
on 用于按类型筛选,catch 可以同时获得错误对象和栈轨迹:
try {
loadUser('42');
} on UserNotFoundException catch (error, stackTrace) {
print(error);
print(stackTrace);
} catch (error, stackTrace) {
print('其他错误:$error');
print(stackTrace);
}
一个重要边界是:catch 不会自动把所有错误都转换成同一种类型。它只是根据运行时类型匹配异常对象。
2. Exception 和 Error 的语义区别
Exception 与 Error 都可以被抛出和捕获,但它们表达的设计意图不同。
Exception:操作失败,调用者通常有机会处理
Exception 通常表示外部条件、输入条件或可预期的操作失败,例如:
- 网络请求失败;
- 文件不存在;
- JSON 格式不符合接口约定;
- 用户输入非法;
- 数据库事务被拒绝。
Dart 标准库中常见的异常包括:
FormatException
TimeoutException
IOException
UnsupportedError
其中 UnsupportedError 的名字虽然包含 Error,但实际经常表示某个操作在当前环境不支持。类型名称本身不能替代具体语义,仍然需要阅读 API 契约。
Error:程序状态或实现出现问题
Error 通常表示程序自身的缺陷、违反语言运行时条件或不可恢复的状态,例如:
AssertionError
TypeError
NoSuchMethodError
RangeError
StateError
LateInitializationError
StackOverflowError
例如:
void main() {
final List<int> values = [1, 2, 3];
print(values[10]); // RangeError
}
这个错误不是“用户可以换一个输入就正常”的业务失败,而是当前代码访问了不合法的索引。
再如:
void main() {
late String token;
print(token); // LateInitializationError
}
late 延迟初始化把检查推迟到了运行时。如果变量读取时还没有赋值,运行时抛出 LateInitializationError。
实际项目中不应把“所有 Error 都绝对不能捕获”理解成语言规则。Dart 允许捕获它们:
try {
final value = <int>[1][3];
print(value);
} on RangeError catch (error, stackTrace) {
print('捕获到了 RangeError:$error');
print(stackTrace);
}
但在设计层面,捕获 Error 后继续执行通常会掩盖程序缺陷。更合理的做法是:
- 在边界层记录并报告;
- 修复产生
Error的代码; - 不要把任意
Error转换成“用户输入有误”; - 只有明确知道状态仍然安全时,才在局部恢复。
3. 不要把 Exception 当成“轻量错误”
Exception 不是“可以忽略的错误”,Error 也不一定代表进程立即退出。两者主要是语义分类,而不是严重程度数值。
例如网络超时是 Exception,但如果应用完全依赖该请求,它可能比某个局部 StateError 更影响用户。反过来,某些 Error 可能只发生在开发阶段的错误分支。
因此,一个实用的分类维度是:
| 维度 | 要回答的问题 |
|---|---|
| 可恢复性 | 当前操作失败后,是否能继续运行? |
| 责任归属 | 是用户输入、外部系统,还是程序实现错误? |
| 处理位置 | 应由当前业务层处理,还是上报到全局边界? |
| 用户表现 | 应显示提示、重试、降级,还是显示错误页? |
Exception/Error 提供初始语义,但不能完全替代项目自己的错误模型。
二、StackTrace:错误对象之外的因果证据
1. 错误对象和栈轨迹是两种不同信息
错误对象回答:
发生了什么类型的失败?
StackTrace 回答:
当前执行路径是如何走到失败位置的?
例如:
void parseId(String value) {
final id = int.parse(value);
print(id);
}
void loadUser(String value) {
parseId(value);
}
void main() {
try {
loadUser('abc');
} catch (error, stackTrace) {
print('错误:$error');
print('栈轨迹:$stackTrace');
}
}
可能看到类似以下调用路径:
FormatException: Invalid radix-10 number
#0 int._throwFormatException (...)
#1 int.parse (...)
#2 parseId (...)
#3 loadUser (...)
#4 main (...)
格式和具体帧会因 Dart VM、编译模式、平台和版本而不同。栈轨迹是诊断信息,不应被程序依赖为固定字符串格式。
2. 用 catch (error, stackTrace) 保留完整上下文
只写:
try {
doWork();
} catch (error) {
log(error.toString());
}
会丢失最重要的调用位置。日志至少应同时记录错误对象和栈轨迹:
try {
doWork();
} catch (error, stackTrace) {
logger.error(
'doWork failed',
error: error,
stackTrace: stackTrace,
);
}
如果错误要继续向上交给调用者,通常不应该重新创建错误:
Future<void> run() async {
try {
await doWorkAsync();
} catch (error, stackTrace) {
logger.error('doWorkAsync failed', error: error, stackTrace: stackTrace);
rethrow;
}
}
rethrow 的特殊意义是:重新抛出当前正在处理的错误,并保留原始栈轨迹。
3. rethrow 与 throw error 的差异
下面两段代码都能继续传播错误,但诊断信息不同:
void badRethrow() {
try {
doWork();
} catch (error) {
throw error;
}
}
void goodRethrow() {
try {
doWork();
} catch (error) {
rethrow;
}
}
throw error 是一个新的 throw 动作,栈轨迹可能从重新抛出的位置开始或包含该位置;rethrow 明确表示保留当前异常传播上下文。
如果必须把底层错误转换为领域错误,应保留原因和栈轨迹。Dart 没有 Java 那种统一的 cause 语言语法,但可以设计自己的包装类型:
class UserLoadException implements Exception {
final String userId;
final Object cause;
final StackTrace causeStackTrace;
UserLoadException({
required this.userId,
required this.cause,
required this.causeStackTrace,
});
@override
String toString() {
return 'UserLoadException(userId: $userId, cause: $cause)';
}
}
Future<void> loadUser(String userId) async {
try {
await fetchFromServer(userId);
} catch (error, stackTrace) {
throw UserLoadException(
userId: userId,
cause: error,
causeStackTrace: stackTrace,
);
}
}
这种转换的成立条件是:上层确实需要一个更稳定的领域语义。若只是为了“换一个更好看的错误名称”,反而可能丢失底层信息。
Dart 还提供了 Error.throwWithStackTrace,可以把一个错误对象与指定的 StackTrace 一起抛出:
void convertError(Object error, StackTrace stackTrace) {
Error.throwWithStackTrace(
UserLoadException(
userId: '42',
cause: error,
causeStackTrace: stackTrace,
),
stackTrace,
);
}
应当谨慎使用它,因为错误对象和栈轨迹需要保持语义一致。随意拼接不相关的栈轨迹会使日志误导排查。
4. StackTrace.current 的用途和限制
可以主动获取当前位置的栈轨迹:
void logCurrentLocation() {
final stackTrace = StackTrace.current;
print(stackTrace);
}
它适合诊断和日志,但不应作为业务判断依据。不同编译模式下可能发生:
- 栈帧名称不同;
- 调试信息被压缩或缺失;
- 异步调用链显示方式不同;
- Web 编译后栈轨迹来自 JavaScript 环境;
- 发布构建中某些帧被优化。
因此,生产日志应把错误类型、业务上下文、请求标识和栈轨迹一起记录,而不是只保存 stackTrace.toString()。
三、同步错误、Future 错误与 Stream 错误
错误传播是否能被 try/catch 捕获,取决于错误发生时是否仍在当前同步控制流中。
1. 同步函数中的 try/catch
int parseAge(String text) {
return int.parse(text);
}
void main() {
try {
final age = parseAge('unknown');
print(age);
} on FormatException catch (error, stackTrace) {
print('年龄格式错误:$error');
print(stackTrace);
}
}
调用 parseAge 时,int.parse 立即执行并抛错,所以外层 try 可以捕获。
2. async 函数的错误通常进入返回的 Future
看起来像同步抛错的代码,在 async 函数中有不同语义:
Future<int> parseAgeAsync(String text) async {
return int.parse(text);
}
void main() {
try {
parseAgeAsync('unknown');
print('try 仍然结束了');
} catch (error) {
print('这里通常不会捕获到错误');
}
}
parseAgeAsync 的返回类型是 Future<int>。调用它会立即返回一个 Future;函数体中的异常会使这个 Future 以错误完成,而不是直接沿调用者的同步栈抛出。
正确写法是 await:
Future<void> main() async {
try {
final age = await parseAgeAsync('unknown');
print(age);
} on FormatException catch (error, stackTrace) {
print('年龄格式错误:$error');
print(stackTrace);
}
}
这里的因果顺序是:
parseAgeAsync返回一个尚未成功完成的Future<int>;int.parse抛出FormatException;Future<int>以错误完成;await重新把该错误引入当前异步函数的异常控制流;- 外层
catch得以匹配。
3. 忘记 await 是常见的错误丢失原因
Future<void> save() async {
throw StateError('save failed');
}
Future<void> submit() async {
try {
save(); // 忘记 await
print('submit 看起来成功了');
} catch (error) {
print('不会按预期捕获 save 的错误');
}
}
改成:
Future<void> submit() async {
try {
await save();
print('save 成功后才继续');
} catch (error, stackTrace) {
print('保存失败:$error');
print(stackTrace);
}
}
如果确实要启动一个不等待的任务,必须显式决定它的错误归属:
void startBackgroundJob() {
unawaited(
save().catchError((Object error, StackTrace stackTrace) {
logBackgroundError(error, stackTrace);
}),
);
}
使用 unawaited 需要导入:
import 'dart:async';
它表达的是“我有意不等待这个 Future”,但不会自动处理错误。错误处理仍然必须由 catchError、局部 try/await 或外部执行边界完成。
4. Future 的错误处理方式
可以通过 try/await 处理:
Future<void> load() async {
try {
final response = await request();
print(response);
} catch (error, stackTrace) {
print('request failed: $error');
print(stackTrace);
}
}
也可以使用链式方法:
request()
.then((response) {
print(response);
})
.catchError((Object error, StackTrace stackTrace) {
print('request failed: $error');
print(stackTrace);
});
在复杂业务中,async/await 通常更容易看清错误边界。无论使用哪种写法,关键都不是语法形式,而是必须有一个错误处理者消费这个错误。
5. Stream 的错误是事件
Stream 不只有数据事件,也可以发送错误事件:
final stream = Stream<int>.fromIterable([1, 2, 3]).map((value) {
if (value == 2) {
throw StateError('invalid value');
}
return value;
});
stream.listen(
(value) => print('data: $value'),
onError: (Object error, StackTrace stackTrace) {
print('stream error: $error');
print(stackTrace);
},
);
错误进入 listen 的 onError,而不是由创建 Stream 的地方自动捕获。使用 await for 时,可以用 try/catch:
Future<void> consume(Stream<int> stream) async {
try {
await for (final value in stream) {
print(value);
}
} catch (error, stackTrace) {
print('消费 Stream 失败:$error');
print(stackTrace);
}
}
Stream 是否继续发送事件取决于流的实现和订阅配置。某些错误会终止流,某些自定义流可能继续发送;调用者不能仅根据“发生了错误”推断流一定已经结束。
四、Zone:异步执行上下文与未处理错误边界
1. Zone 解决什么问题
Zone 是 Dart 的异步执行上下文。它可以携带:
- 当前执行上下文中的键值;
- 未处理异步错误的处理器;
- 定时器、微任务等异步操作的拦截;
- 标准输出、时间和部分平台操作的拦截。
一个 Zone 不是线程,也不是 isolate。它通常运行在同一个 isolate 中,不能提供并行内存隔离。
最常见的用法是 runZonedGuarded:
import 'dart:async';
void main() {
runZonedGuarded(
() {
Future<void>.delayed(const Duration(milliseconds: 10), () {
throw StateError('background failure');
});
},
(Object error, StackTrace stackTrace) {
print('Zone 捕获到未处理错误:$error');
print(stackTrace);
},
);
}
代码的执行过程是:
runZonedGuarded创建一个带错误处理器的子 Zone;- 回调在该 Zone 内运行;
Future.delayed创建的异步任务继承这个 Zone;- 回调抛出错误且没有局部
catch; - 错误沿 Zone 的未处理错误边界传播;
- 第二个回调接收错误对象和栈轨迹。
2. Zone 不会替代局部 try/catch
局部 try/catch 负责业务恢复:
Future<void> loadPage() async {
try {
await request();
} on TimeoutException {
showRetryButton();
}
}
Zone 更适合负责无法在局部恢复的错误,例如:
- 统一记录未处理异常;
- 添加请求 ID、用户会话等上下文;
- 将错误交给崩溃收集系统;
- 为应用入口提供最后一道兜底。
如果所有错误都等到 Zone 才处理,业务层就失去了根据错误类型重试、降级或提示用户的机会。
3. Zone 值可以携带诊断上下文
import 'dart:async';
void main() {
runZonedGuarded(
() async {
await handleRequest();
},
(Object error, StackTrace stackTrace) {
final requestId = Zone.current['requestId'];
print('requestId=$requestId, error=$error');
print(stackTrace);
},
zoneValues: <Object?, Object?>{
'requestId': 'req-123',
},
);
}
Future<void> handleRequest() async {
await Future<void>.delayed(const Duration(milliseconds: 1));
throw StateError('request failed');
}
这里的 requestId 不需要通过每一层函数参数传递。但这种能力应主要用于诊断上下文,而不是隐藏业务依赖。若函数的正确性依赖某个值,应将它作为显式参数或对象状态传入。
4. Zone error boundary 的隔离现象
Dart 的错误 Zone 具有隔离性。一个 Future 在错误 Zone 中创建后,如果错误跨越到另一个错误 Zone,错误可能不会按普通 Future 那样被重新交给外部处理,表现为外部监听者一直等不到成功或错误完成。
这类行为的根源是:错误不能简单地跨越错误 Zone 边界传播,否则同一个错误可能在多个 Zone 中被重复处理。
因此,不要随意把一个 Zone 中创建的长期 Future 暴露给另一个不相关的 Zone,尤其是:
- 测试代码为每个测试建立独立 Zone;
- 插件或框架内部创建 Future;
- 全局缓存保存了某个 Zone 中创建的 Future。
更稳妥的做法是,在创建 Future 的同一层完成错误处理或转换为明确的领域结果。
5. Zone 与 isolate 的关系
一个 isolate 有自己的堆、事件循环和 Zone 树。Zone 只能影响当前 isolate 中的执行上下文。
如果使用 Isolate.spawn 创建新的 isolate:
- 新 isolate 不会自动继承父 isolate 的 Zone;
- 父 Zone 的错误处理器不会直接捕获子 isolate 的错误;
- 必须通过消息端口传递结果或错误信息;
- 未捕获的 isolate 错误可以通过
Isolate.addErrorListener接收。
示例:
import 'dart:isolate';
void worker(SendPort sendPort) {
try {
throw StateError('worker failed');
} catch (error, stackTrace) {
sendPort.send(<Object?>[
error.toString(),
stackTrace.toString(),
]);
}
}
Future<void> main() async {
final receivePort = ReceivePort();
await Isolate.spawn(worker, receivePort.sendPort);
final message = await receivePort.first as List<Object?>;
print('来自 worker 的消息:$message');
}
跨 isolate 传递的不是原始 Dart 异常对象和执行栈,而是可发送的消息。若需要保留错误类型、代码、参数和栈轨迹,应定义明确的消息格式,而不是依赖 toString() 解析。
五、Flutter 中的错误入口和生命周期
Flutter 应用通常同时存在多个错误入口。它们解决的问题不同,不能用一个全局回调替代全部入口。
1. FlutterError.onError:Flutter 框架报告的错误
Flutter 框架在构建 Widget、布局、绘制和部分框架回调中,会将错误包装为 FlutterErrorDetails 并报告:
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
void main() {
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
final error = details.exception;
final stackTrace = details.stack ?? StackTrace.empty;
debugPrint('Flutter framework error: $error');
debugPrint('$stackTrace');
};
runZonedGuarded(
() {
runApp(const MyApp());
},
(Object error, StackTrace stackTrace) {
debugPrint('Unhandled async error: $error');
debugPrint('$stackTrace');
},
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const Directionality(
textDirection: TextDirection.ltr,
child: Text('Hello'),
);
}
}
FlutterError.onError 的输入不是简单的 (Object, StackTrace),而是包含上下文的 FlutterErrorDetails。其中可能有:
exception:错误对象;stack:栈轨迹;library:发生错误的 Flutter 子系统;context:例如构建 Widget、布局或绘制;informationCollector:额外诊断信息。
如果覆盖了默认处理器,通常应考虑调用 FlutterError.presentError(details),否则开发环境控制台中原本有价值的 Flutter 诊断信息可能消失。
2. PlatformDispatcher.instance.onError:平台调度层的未处理异步错误
对于没有被局部 catch、Flutter 框架或 Zone 处理的某些平台调度错误,可以配置:
import 'dart:ui';
void configurePlatformErrorHandling() {
PlatformDispatcher.instance.onError = (Object error, StackTrace stackTrace) {
debugPrint('Platform dispatcher error: $error');
debugPrint('$stackTrace');
// 返回 true 表示该错误已被处理。
return true;
};
}
返回值表示是否处理了错误。返回 false 会让平台或后续机制继续采用默认处理路径。
这个入口的适用范围取决于错误产生的调度路径和 Flutter 版本实现。它不是一个“所有 Dart 错误都会经过”的全局捕获器,也不能替代局部 try/catch、FlutterError.onError 或 Zone。
3. runZonedGuarded:Dart 异步错误边界
在应用入口使用 Zone,可以为当前 isolate 建立未处理异步错误的兜底:
void main() {
runZonedGuarded(
() {
runApp(const MyApp());
},
(Object error, StackTrace stackTrace) {
reportError(error, stackTrace);
},
);
}
它适合接收未被其他边界消费的异步错误,但仍然要注意:
- 已经被
catch处理的错误不会再次进入 Zone; - 其他 isolate 的错误不会自动进入当前 Zone;
- Flutter 框架报告的错误可能首先经过
FlutterError.onError; - 如果在 Zone 外创建异步任务,该任务不一定使用目标 Zone;
- Zone 不是应用崩溃恢复机制。
4. 构建 Widget 失败与后台任务失败不是同一种故障
Widget 构建失败通常发生在 Flutter 框架调用构建方法的过程中:
class BrokenWidget extends StatelessWidget {
const BrokenWidget({super.key});
@override
Widget build(BuildContext context) {
throw StateError('build failed');
}
}
这类错误可以被 Flutter 框架包装和报告,开发模式下通常显示红色错误界面。生产模式下的展示行为由 Flutter 的错误 Widget 机制和配置决定,不能把开发环境的红屏当成业务兜底方案。
而下面的错误发生在后台异步任务:
class PageState extends State<MyPage> {
@override
void initState() {
super.initState();
Future<void>.delayed(const Duration(seconds: 1), () {
throw StateError('background task failed');
});
}
@override
Widget build(BuildContext context) {
return const SizedBox();
}
}
它不一定导致当前 Widget 树显示错误页。若任务与页面生命周期有关,还可能在页面销毁后继续执行。因此需要同时处理:
- 异步错误归属;
mounted状态;- 任务取消;
- 页面销毁后的资源释放。
例如:
class MyPage extends StatefulWidget {
const MyPage({super.key});
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> {
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final result = await fetchData();
if (!mounted) {
return;
}
setState(() {
// 使用 result 更新状态
});
} catch (error, stackTrace) {
reportError(error, stackTrace);
if (!mounted) {
return;
}
// 只有页面仍然存在时,才显示 SnackBar 或错误状态
}
}
@override
Widget build(BuildContext context) {
return const SizedBox();
}
}
mounted 只能判断 State 是否仍在树中,不能取消已经发出的网络请求。对可取消任务,还应使用请求库提供的取消机制或在业务层设计任务生命周期。
5. Android、iOS、桌面与 Web 的差异
Dart 语言层面的 throw、catch、Future、Stream 和 Zone 语义在各平台基本一致,但错误的最终展示和栈轨迹来源会有差异:
- Android、iOS 和桌面通常运行在 Dart VM 的移动端或桌面编译模式,原生插件还可能产生 Java/Kotlin、Objective-C/Swift 或 C/C++ 层错误;
- Web 代码会编译为 JavaScript,栈轨迹格式可能包含 JavaScript 帧,浏览器的 Promise、事件回调和 JavaScript 异常也可能参与错误路径;
- Web 不支持
dart:io,依赖该库的文件、Socket 和进程级错误处理不能直接移植; - 不同平台的系统权限、文件路径、网络栈和原生插件失败类型不同;
- isolate、后台任务和应用进程生命周期在移动端、桌面端和 Web 上也不同。
因此,跨平台库的契约应暴露稳定的 Dart 层错误类型,同时把平台原始错误作为原因或诊断字段保留,而不是让上层直接依赖某个平台的异常字符串。
六、契约:错误处理的前置条件、后置条件与不变量
1. 契约描述调用者与实现者的责任
契约可以形式化为:
- 前置条件:调用操作前必须满足的条件,记为
P; - 后置条件:操作成功返回后必须满足的条件,记为
Q; - 不变量:对象或组件在可观察状态下始终必须满足的条件,记为
I。
对于一个操作 f,理想语义是:
P(x) 成立,并且执行没有外部故障
=> f(x) 返回,且 Q(x, result) 成立
如果对象状态从 s 变为 s',则状态不变量还要求:
I(s) 且操作成功 => I(s')
这个公式的直觉是:
- 调用者负责提供满足
P的输入; - 实现者负责在
P成立时完成操作; - 成功返回时,实现者必须保证
Q; - 无论操作前后,对象的公开状态都不能破坏
I。
错误处理的本质,是明确说明违反这些条件时发生什么,而不是让调用者猜测。
2. assert 是开发期检查,不是完整契约机制
Dart 支持 assert:
int divide(int dividend, int divisor) {
assert(divisor != 0);
return dividend ~/ divisor;
}
它表达了前置条件:divisor 不应为零。但 assert 可能在非调试模式中被禁用,因此不能依赖它执行必须存在的输入校验。
如果这是公开 API 的运行时要求,应显式抛出异常:
int divide(int dividend, int divisor) {
if (divisor == 0) {
throw ArgumentError.value(
divisor,
'divisor',
'must not be zero',
);
}
return dividend ~/ divisor;
}
调用者可以据此区分:
try {
print(divide(10, 0));
} on ArgumentError catch (error) {
print('调用参数不合法:$error');
}
assert 仍然有价值,尤其适合检查内部开发假设:
class Counter {
int _value = 0;
void decrement() {
assert(_value > 0);
_value--;
}
}
但如果 _value > 0 是生产环境也必须保证的条件,就必须在运行时显式处理,而不能只写 assert。
3. 类型系统也是契约的一部分
Dart 3 的健全空安全和静态类型检查可以表达一部分契约:
String displayName(String? name) {
return name ?? 'Anonymous';
}
这里的参数契约允许 null,实现者明确处理了 null。
如果契约不允许 null:
String displayName(String name) {
return name.trim();
}
调用者需要传入非空 String。但类型系统并不能保证所有外部数据都可信。JSON、平台通道、数据库和 dynamic 都可能把不符合预期的数据带入运行时:
dynamic raw = 'not an integer';
final int value = raw as int; // 运行时 TypeError
类型断言 as 的含义是:
运行时值的实际类型必须是目标类型,否则抛出 TypeError
它不是转换。字符串 "1" 不会因为 as int 变成整数。
需要转换时,应使用显式解析并定义失败契约:
int parseRequiredInt(Object? value) {
if (value is int) {
return value;
}
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) {
return parsed;
}
}
throw FormatException('Expected an integer, got $value');
}
4. 输入边界应尽早把不稳定数据转换为稳定模型
假设接口返回:
{
"id": "42",
"name": "Ada"
}
不要让 Map<String, dynamic> 在业务层传播:
final id = json['id'] as int; // 实际数据是 String,运行时失败
可以在边界处完成校验和转换:
class User {
final int id;
final String name;
User({
required this.id,
required this.name,
});
factory User.fromJson(Map<String, dynamic> json) {
final id = parseRequiredInt(json['id']);
final name = json['name'];
if (name is! String || name.trim().isEmpty) {
throw const FormatException('User.name must be a non-empty string');
}
return User(id: id, name: name);
}
}
此时契约变得清晰:
User.fromJson接受一个 JSON 映射;id必须是整数或可解析为整数的字符串;name必须是非空字符串;- 数据不符合协议时抛出
FormatException; - 构造成功后,
User.id和User.name满足模型不变量。
业务层不需要反复检查 dynamic,也不需要理解服务端字段的原始格式。
七、错误类型、恢复策略与边界设计
1. 按错误责任选择处理位置
一个典型的 Flutter 数据流可以表示为:
flowchart TD
A[用户操作] --> B[Widget/Controller]
B --> C[Repository]
C --> D[HTTP/数据库/平台插件]
D -->|成功| E[领域模型]
E --> F[更新界面]
D -->|可预期失败| G[领域异常或失败结果]
G --> B
D -->|未处理错误| H[Zone/FlutterError/平台入口]
H --> I[日志与监控]
关键路径是:
- 外部输入先经过 Repository 或数据边界;
- 可预期的失败转换为稳定的领域错误;
- Controller 或 Widget 根据领域错误决定重试、提示或显示空状态;
- 未被处理的错误进入全局边界,用于诊断和兜底;
- 全局边界不应假装完成了业务恢复。
例如:
sealed class LoginFailure implements Exception {
const LoginFailure();
}
class InvalidCredentials extends LoginFailure {
const InvalidCredentials();
}
class LoginNetworkFailure extends LoginFailure {
final Object cause;
const LoginNetworkFailure(this.cause);
}
Future<void> login(String username, String password) async {
if (username.isEmpty || password.isEmpty) {
throw const InvalidCredentials();
}
try {
await sendLoginRequest(username, password);
} on TimeoutException catch (error) {
throw LoginNetworkFailure(error);
}
}
界面层可以针对稳定的领域类型处理:
Future<void> submit() async {
try {
await login(username, password);
showSuccess();
} on InvalidCredentials {
showMessage('用户名或密码不正确');
} on LoginNetworkFailure {
showMessage('网络不可用,请稍后重试');
} catch (error, stackTrace) {
reportError(error, stackTrace);
showMessage('发生未知错误');
}
}
这里的 catch 顺序很重要:具体类型必须放在一般类型之前,否则一般的 catch 会先匹配。
2. 什么时候返回失败结果,什么时候抛异常
不是所有失败都必须通过异常表达。可以采用显式结果类型:
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final Object error;
final StackTrace stackTrace;
const Failure(this.error, this.stackTrace);
}
使用时:
Future<Result<User>> loadUser(String id) async {
try {
final json = await fetchUserJson(id);
return Success(User.fromJson(json));
} catch (error, stackTrace) {
return Failure(error, stackTrace);
}
}
两种方式的差别是:
- 抛异常:错误沿控制流传播,适合“正常路径不应包含失败”的操作;
- 返回结果:调用者必须显式处理成功和失败,适合查询、校验或预期频繁失败的操作。
关键不是建立一条绝对规则,而是保持同一个 API 的语义稳定。若某个函数有时返回 null,有时抛异常,有时返回错误对象,调用者很难建立可靠契约。
3. 不要捕获后静默忽略
下面代码破坏了错误契约:
try {
await save();
} catch (_) {
// 什么都不做
}
它会导致调用者看到“提交完成”,但数据实际上可能没有保存。至少应当:
- 显式转换为调用者理解的失败;
- 记录错误与栈轨迹;
- 或者确认该错误确实可以忽略,并写出原因。
try {
await save();
} catch (error, stackTrace) {
logger.warning(
'Optional cache write failed; main operation remains valid',
error: error,
stackTrace: stackTrace,
);
}
这段代码只有在缓存写入不影响主操作契约时才成立。如果缓存是后续读取的必要条件,就不能把它当成可选失败。
八、常见失败表现与诊断方法
1. 只记录 error.toString()
失败表现:
FormatException: Invalid number
但不知道错误从哪里发生。
修复方式:
catch (error, stackTrace) {
logger.error(
'Parsing user response failed',
error: error,
stackTrace: stackTrace,
);
}
同时记录必要的上下文,例如接口名称、请求 ID、用户操作和平台,但不要直接记录密码、令牌和完整个人数据。
2. 在异步回调外部使用 try/catch
失败表现:
try {
Future.delayed(const Duration(seconds: 1), () {
throw StateError('failed');
});
} catch (error) {
print('捕获不到');
}
原因是 try 的同步作用域已经结束,延迟回调才开始执行。修复方式是在回调内部处理,或让 Future 被 await:
Future<void> main() async {
try {
await Future<void>.delayed(const Duration(seconds: 1));
throw StateError('failed');
} catch (error, stackTrace) {
print('捕获到:$error');
print(stackTrace);
}
}
3. 用 catch (e) 捕获后无法区分错误
失败表现:
try {
await request();
} catch (error) {
showMessage('失败');
}
这会把超时、权限、协议错误和程序缺陷全部合并。更好的方式是先处理可恢复的具体错误:
try {
await request();
} on TimeoutException {
showMessage('请求超时,请重试');
} on FormatException catch (error, stackTrace) {
reportError(error, stackTrace);
showMessage('服务器返回了无效数据');
} catch (error, stackTrace) {
reportError(error, stackTrace);
showMessage('操作失败');
}
4. 把所有异常转成 Exception('failed')
失败表现:
try {
await request();
} catch (_) {
throw Exception('request failed');
}
这样丢失了原始类型、错误细节和栈轨迹。除非上层确实需要统一领域类型,否则应使用 rethrow。需要转换时,应保存原因和原始栈轨迹。
5. 认为全局处理器可以恢复应用
全局错误处理器适合:
- 记录;
- 上报;
- 避免错误完全无声;
- 在特定边界提供降级界面。
它不能保证:
- 当前对象状态已经一致;
- 未完成事务可以自动回滚;
- 页面仍然存在;
- 所有并发任务都已停止;
- 数据已经保存成功。
恢复必须在拥有足够业务上下文的局部层完成。全局边界不知道“这个请求失败后应该重试一次还是退出登录”,因此无法替代领域层决策。
九、一个完整的 Flutter 入口示例
下面示例同时配置 Flutter 框架错误、平台调度错误和 Zone 兜底,并保留栈轨迹:
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
final stackTrace = details.stack ?? StackTrace.empty;
reportError(
details.exception,
stackTrace,
source: 'flutter-framework',
);
};
PlatformDispatcher.instance.onError = (
Object error,
StackTrace stackTrace,
) {
reportError(
error,
stackTrace,
source: 'platform-dispatcher',
);
return true;
};
runZonedGuarded(
() {
runApp(const ExampleApp());
},
(Object error, StackTrace stackTrace) {
reportError(
error,
stackTrace,
source: 'zone',
);
},
);
}
void reportError(
Object error,
StackTrace stackTrace, {
required String source,
}) {
debugPrint('[$source] $error');
debugPrint('$stackTrace');
// 生产环境可在这里接入错误上报系统。
// 不要上传密码、访问令牌或未经处理的敏感业务数据。
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Error handling')),
body: Center(
child: ElevatedButton(
onPressed: () async {
try {
await Future<void>.delayed(
const Duration(milliseconds: 100),
);
throw const FormatException('Invalid server payload');
} on FormatException catch (error, stackTrace) {
reportError(
error,
stackTrace,
source: 'page-recovery',
);
}
},
child: const Text('Run'),
),
),
),
);
}
}
执行路径如下:
- 页面按钮启动异步操作;
await后抛出FormatException;- 局部
on FormatException捕获并处理; - 因为错误已被消费,所以不会再自动进入 Zone;
- 如果其他异步任务没有局部处理,则可能进入 Zone;
- 如果 Flutter 框架在构建或布局阶段报告错误,则进入
FlutterError.onError; - 某些平台调度路径上的未处理错误可能进入
PlatformDispatcher.instance.onError。
不同 Flutter 版本和运行平台的具体入口分工可能存在实现差异,因此生产应用应在目标平台、Debug/Profile/Release 构建中分别验证,而不是只在一个开发环境中测试。
十、规范保证、实现差异与工程建议
需要明确区分三类结论。
语言和库层面的保证
Dart 语言和核心库保证或定义了这些基本行为:
throw可以抛出对象;try/catch/finally用于异常控制流;async函数通过Future表达异步完成或失败;await可以重新引入 Future 错误;rethrow用于重新传播当前错误;StackTrace表示调用路径信息;- Zone 提供异步执行上下文和未处理错误边界;
assert是否执行取决于运行模式,不应作为发布环境必需校验。
常见实现和平台差异
以下内容不能假定为完全固定:
- 栈轨迹的文本格式;
- 发布构建中可见的函数名和栈帧;
- Flutter 不同入口对某类未处理错误的具体接管顺序;
- Web 编译后栈轨迹中的 JavaScript 帧;
- 原生插件将平台异常转换为何种 Dart 错误;
- 应用被系统终止后,Dart 层是否还有机会执行清理代码。
工程层面的建议
建议把错误处理分成三层:
- 局部恢复层:根据错误类型重试、提示、降级或回滚;
- 领域边界层:把外部错误转换成稳定的领域错误或结果类型;
- 全局诊断层:接收未处理错误,保存
Object、StackTrace和必要上下文。
这三层不能互相替代。局部层最了解业务,全局层最适合兜底,契约则负责规定两者之间应该传递什么。
结语
Exception 和 Error 是错误语义的起点:前者通常表示可预期的操作失败,后者通常表示程序或运行时状态问题。StackTrace 保存错误发生的执行证据,rethrow 则让错误在转换或记录时保留原始因果链。Future、Stream 和 Zone 决定错误如何跨越异步边界,而 Flutter 又在框架、平台调度和应用入口增加了不同的错误接收点。
真正可靠的错误处理建立在契约之上:
- 输入不满足前置条件时,明确拒绝并说明类型;
- 成功返回时,保证后置条件和状态不变量;
- 可恢复错误在拥有业务上下文的地方处理;
- 无法恢复的错误保留错误对象和栈轨迹;
- 全局入口只负责诊断和兜底,不伪装成业务恢复机制;
assert用于开发期假设,不能替代生产环境校验;- 跨平台和跨 isolate 时,通过稳定的数据契约传递错误,而不是依赖平台字符串。
当这些关系被明确后,错误处理就不再是“哪里加一个 catch”,而是一次从输入、执行、异步传播、诊断到恢复策略的完整设计。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Dart 泛型:类型参数、边界、协变、运行时类型和 API 设计
- 下一篇:Dart Record 与模式匹配:解构、switch、封闭建模和返回值
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论