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

Dart 错误处理:Exception、Error、StackTrace、Zone 和契约

Dart 的错误处理不是单一的 try/catch 问题。一个完整的错误路径至少包含五个问题:

  1. 错误对象是什么:Exception 还是 Error
  2. 错误发生在哪里:StackTrace 如何记录调用路径?
  3. 错误何时传播:同步调用、FutureStream 和未等待的异步任务有什么差异?
  4. 错误由谁接收:局部 catchZone、Flutter 框架还是平台入口?
  5. 调用者和实现者之间的契约是什么:哪些输入合法,失败时返回什么,哪些错误必须暴露?

如果只记住“捕获异常然后打印日志”,通常无法解释下面这些现象:

  • 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. ExceptionError 的语义区别

ExceptionError 都可以被抛出和捕获,但它们表达的设计意图不同。

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. rethrowthrow 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);
  }
}

这里的因果顺序是:

  1. parseAgeAsync 返回一个尚未成功完成的 Future<int>
  2. int.parse 抛出 FormatException
  3. Future<int> 以错误完成;
  4. await 重新把该错误引入当前异步函数的异常控制流;
  5. 外层 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);
  },
);

错误进入 listenonError,而不是由创建 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);
    },
  );
}

代码的执行过程是:

  1. runZonedGuarded 创建一个带错误处理器的子 Zone;
  2. 回调在该 Zone 内运行;
  3. Future.delayed 创建的异步任务继承这个 Zone;
  4. 回调抛出错误且没有局部 catch
  5. 错误沿 Zone 的未处理错误边界传播;
  6. 第二个回调接收错误对象和栈轨迹。

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/catchFlutterError.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 语言层面的 throwcatchFutureStream 和 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')

这个公式的直觉是:

  1. 调用者负责提供满足 P 的输入;
  2. 实现者负责在 P 成立时完成操作;
  3. 成功返回时,实现者必须保证 Q
  4. 无论操作前后,对象的公开状态都不能破坏 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.idUser.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[日志与监控]

关键路径是:

  1. 外部输入先经过 Repository 或数据边界;
  2. 可预期的失败转换为稳定的领域错误;
  3. Controller 或 Widget 根据领域错误决定重试、提示或显示空状态;
  4. 未被处理的错误进入全局边界,用于诊断和兜底;
  5. 全局边界不应假装完成了业务恢复。

例如:

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'),
          ),
        ),
      ),
    );
  }
}

执行路径如下:

  1. 页面按钮启动异步操作;
  2. await 后抛出 FormatException
  3. 局部 on FormatException 捕获并处理;
  4. 因为错误已被消费,所以不会再自动进入 Zone;
  5. 如果其他异步任务没有局部处理,则可能进入 Zone;
  6. 如果 Flutter 框架在构建或布局阶段报告错误,则进入 FlutterError.onError
  7. 某些平台调度路径上的未处理错误可能进入 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 层是否还有机会执行清理代码。

工程层面的建议

建议把错误处理分成三层:

  1. 局部恢复层:根据错误类型重试、提示、降级或回滚;
  2. 领域边界层:把外部错误转换成稳定的领域错误或结果类型;
  3. 全局诊断层:接收未处理错误,保存 ObjectStackTrace 和必要上下文。

这三层不能互相替代。局部层最了解业务,全局层最适合兜底,契约则负责规定两者之间应该传递什么。


结语

ExceptionError 是错误语义的起点:前者通常表示可预期的操作失败,后者通常表示程序或运行时状态问题。StackTrace 保存错误发生的执行证据,rethrow 则让错误在转换或记录时保留原始因果链。FutureStreamZone 决定错误如何跨越异步边界,而 Flutter 又在框架、平台调度和应用入口增加了不同的错误接收点。

真正可靠的错误处理建立在契约之上:

  • 输入不满足前置条件时,明确拒绝并说明类型;
  • 成功返回时,保证后置条件和状态不变量;
  • 可恢复错误在拥有业务上下文的地方处理;
  • 无法恢复的错误保留错误对象和栈轨迹;
  • 全局入口只负责诊断和兜底,不伪装成业务恢复机制;
  • assert 用于开发期假设,不能替代生产环境校验;
  • 跨平台和跨 isolate 时,通过稳定的数据契约传递错误,而不是依赖平台字符串。

当这些关系被明确后,错误处理就不再是“哪里加一个 catch”,而是一次从输入、执行、异步传播、诊断到恢复策略的完整设计。


系列导航与关联阅读

官方资料

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