我曾经以为自己在Flutter应用中处理错误的方式非常妥当。我在代码的各个地方都使用了try/catch语句,能够捕获异常、记录异常信息,并向用户显示错误提示。我觉得这样的做法很可靠。
但后来,我开始更仔细地观察应用程序在运行环境中的实际表现。有些错误根本没有被发现——有些函数确实可能会抛出异常,但类型系统并没有给出任何警告;整个代码库中,错误处理的方式也存在着不一致性:有些地方能够捕获异常,而有些地方则无法捕获。
团队里有一位初级开发人员添加了一个新的API接口,却完全忘记了使用try/catch语句进行异常处理。由于代码中没有任何提示说明“这个函数可能会出错”,因此在审查过程中也没有人发现这个问题。
从那时起,我开始认真地将错误处理视为一种架构层面的决策,而不仅仅是一种防御性的习惯。
这篇文章介绍了我在实际开发的Flutter应用中使用的几种错误处理方法——结果类型、密封类、Dart 3记录以及模式匹配技术——并说明了这些方法是如何共同作用,使得错误变得显而易见、无法被忽视的。
目录
为什么单独使用try/catch是不够的
try/catch语句确实有效,我并不是说它们没有用。在简单的情况下,使用这些语句完全没问题。但是,随着应用程序规模的扩大,如果仅仅依赖try/catch作为主要的错误处理机制,就会产生一系列只有在大规模应用环境中才会显现的问题。
问题在于异常的隐蔽性。
当一个函数可能会抛出异常时,它的函数签名中并不会有任何提示信息。看看这个例子:
Future getUser(String userId) async {
final response = await dio.get('/users/$userId');
return User.fromJson(response.data);
}
从这个函数的定义来看,它似乎总是会返回一个User对象。它的函数签名中没有任何迹象表明它会失败。因此,调用这个函数的开发人员根本不知道是否需要使用try/catch语句来处理可能出现的异常,除非他们阅读了该函数的实现代码,或者之前曾经遇到过类似的问题。
现在,请想象这个函数在你的应用程序中被调用了十次,分别位于不同的地方。有些开发者会记得处理可能出现的错误,而另一些则不会。编译器不会发出任何警告,也没有任何代码检查规则能够发现这些不一致之处。直到有用户报告程序崩溃,这些错误才会被暴露出来。
第二个问题是,异常具有“传染性”。
当一个函数抛出异常时,所有调用它的地方都必须处理这个异常。而那些再次调用该函数的代码也同样需要处理异常。这种错误处理的责任会像涟漪一样在你的整个代码库中扩散开来,而且往往会出现处理方式不一致的情况。有些代码层会默默地吞下这些异常,而另一些则会重新抛出它们。因此,理解这些异常在应用程序中的传播路径就变得非常困难。
第三个问题是,并非所有的错误都属于异常情况。
在移动应用中,网络请求失败并不是什么异常事件,这是意料之中的事情。将这种错误视为异常——即认为它是会中断程序正常运行的异常情况——是一种错误的思维方式。实际上,这是一种正常的后果,应该像处理其他普通结果一样来处理它。
这就是“Result类型”设计理念的核心所在:错误本质上是某种值,而不是会干扰程序正常运行的因素。
错误作为值:核心理念
这个理念很简单。一个函数不应该要么返回某个值,要么抛出异常,而应该始终返回一个值——不过这个值可以表示成功,也可以表示失败。
// 以前的写法可能会抛出异常,也可能不会
Future getUser(String userId);
// 现在的写法则是总是返回一个结果
Future> getUser(String userId);
这样的函数签名更加清晰明了。它明确告诉人们:“这个操作可能会成功,也可能会失败,而你必须同时准备处理这两种情况。”编译器会确保开发者必须处理这两种可能性。因此,根本不可能不小心忽略失败的情况。
这种设计模式来源于Rust和Kotlin等语言,在这些语言中,这类机制已经被内置到标准库中了。在Dart中,我们也是通过自己来实现这一点的——而借助Dart 3中的密封类和模式匹配功能,实现这一目标的代码变得更加简洁明了。
使用密封类构建Result类型
以下是我在实际开发中使用的Result类型示例:
// result.dart
// “密封”意味着所有可能的子类型都在这份文件中被明确定义了。
// 编译器会知道,只有两种可能的结果:成功或失败,没有其他情况。
sealed class Result {}
// 成功时,这个结果会包含我们想要获取的数据。
// T是类型参数——例如Result表示成功时返回一个User对象,
// 而Result>则表示成功时返回一个List对象。
class Success extends Result {
final T data;
const Success(this.data);
}
// 失败时,这个结果会包含一个AppError对象,用于说明出了什么问题。
// 我们使用带有类型信息的错误类,而不是原始的异常对象,
// 这样用户界面就可以根据错误的类型来做出相应的处理了。
class Failure extends Result {
final AppError error;
const Failure(this.error);
}
现在我们需要定义一些具体的错误类型。我们不再直接传递原始的异常信息,而是明确指定应用程序可能产生的各种错误:
// app_error.dart
// AppError是一个密封类——应用程序可能产生的所有错误类型都定义在这里。这样就可以确保不会出现未被处理的错误类型。
sealed class AppError {}
// 没有互联网连接
class NoInternetError extends AppError {}
// 服务器返回了错误响应
class ServerError extends AppError {
final int statusCode;
final String message;
const ServerError({required this.statusCode, required this.message});
}
// 数据返回的格式不符合预期
class ParseError extends AppError {
final String message;
const ParseError(this.message);
}
// 发生了我们没有预料到的意外情况
class UnknownError extends AppError {
final String message;
const UnknownError(this.message);
}
现在让我们在某个仓库类中使用这些错误类型:
// post_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:dio/dio.dart';
import 'result.dart';
import 'app_error.dart';
import 'post.dart';
class PostRepository {
final Dio _dio;
PostRepository(this._dio);
Future
try {
final response = await _dio.get(
'https://jsonplaceholder.typicode.com/posts',
);
// 将响应数据解析成Post对象列表。
// 我们在这里使用了额外的try/catch块,因为数据解析过程可能会出错——
// 即使网络请求本身成功,解析结果也可能不符合预期。
try {
final List
final posts = data
.map((json) => Post.fromJson(json as Map
// 调用者收到的是一个Result对象,而不是原始的列表,因此他们需要检查操作是否成功。
return Success/posts);
} catch (e) {
return Failure(ParseError('无法解析帖子数据:$e'));
}
} on DioException catch (e) {
// 将Dio抛出的异常类型转换为我们自定义的AppError类型。
// 这样就可以确保其他代码部分不会受到Dio特定异常类型的影响。
if (e.type == DioExceptionType.connectionError) {
return Failure(NoInternetError());
}
return Failure(
ServerError(
statusCode: e.response?.statusCode ?? 0,
message: e.message ?? '服务器错误',
),
);
} catch (e) {
// 捕捉所有其他意外情况
return Failure(UnknownError(e.toString()));
}
}
}
请注意发生了哪些变化。函数签名 `Future` 现在已经变得清晰明了了。任何调用 `getPosts()` 方法的人都知道自己将会得到一个 `Result` 对象——他们不能再假装这个方法总是能够成功执行。而且,所有的 `try/catch` 代码都完全被包含在仓库内部,没有任何信息会泄露给调用者。
Dart 3中的记录及其作用
在讨论模式匹配之前,有必要先了解一下Dart 3中的记录,因为它们与Result类型配合使用非常方便。
记录是一种轻量级的、匿名的对象,它可以将多个值组合在一起,而无需定义完整的类。可以把它看作是从函数中返回多个值的快捷方式。
// 在使用记录之前,你需要一个类或Map来返回多个值
// 例如:
Map getUserInfo() {
return {'name': 'Nicholas', 'age': 28};
// 这种方式没有类型安全性,'age'可以是任何类型;
}
// 而使用记录后,具有类型安全性,也不需要定义类
(String name, int age) getUserInfo() {
return ('Nicholas', 28);
// 编译器知道name是String类型,age是int类型;
}
当你需要同时返回一个值以及一些元数据时,记录在错误处理中会非常有用:
// 这个函数返回一篇帖子及其获取时间戳
Future getPostWithTimestamp(
String postId,
) async {
try {
final response = await _dio.get('/posts/$postId');
final post = Post.fromJson(response.data);
// 使用记录(post, DateTime.now())来组合这两个值,
// 这样就不需要额外的包装类了
return Success((post, DateTime.now()));
} catch (e) {
return Failure(UnknownError(e.toString()));
}
}
使用方式如下:
final result = await repository.getPostWithTimestamp('1');
switch (result) {
case Success(:final data):
// 直接在模式匹配中提取数据
final (post, fetchedAt) = data;
print('获取到帖子:\({post.title},获取时间为 \)fetchedAt');
case Failure(:final error):
print('失败原因:$error');
}
}
虽然记录对于Result类型来说并不是必需的,但它们消除了为了组合两三个值而专门创建小辅助类的需要。我在那些需要同时返回数据、分页索引或缓存元数据的仓库方法中,经常使用记录。
错误处理中的模式匹配
在这里,所有的技术元素都得到了应用。密封类加上模式匹配机制,使得编译器强制你处理所有可能的结果情况,因此你不可能无意中忽略失败的情况。
final result = await repository.getPosts();
switch (result) {
// 通过字段名称进行模式匹配,可以直接从Success结果中提取数据
case Success(:final data):
print('获取到了${data.length}篇帖子');
case Failure(:final error):
// 根据错误类型进行相应的处理,以向用户显示合适的提示信息
switch (error) {
case NoInternetError():
print('没有互联网连接,请检查网络连接');
case ServerError(:final statusCode, :final message):
print('服务器错误:码为 \(statusCode\),消息为 \)message\');
case ParseError(:final message):
print('在解析数据时出现了问题:$message');
case UnknownError(:final message):
print('发生了未知错误:$message');
}
}
这两种switch语句都能覆盖所有情况。如果你添加了一个新的Result子类型,却忘记在这里对其进行处理,就会导致编译错误;同样地,如果你添加了一个新的AppError子类型而忽略对其的处理,也会出现编译错误。编译器实际上起到了质量控制的作用。
你也可以使用when扩展模式来使代码更简洁:
// 这个辅助扩展模式使得Result类型更易于被使用
extension ResultExtension on Result {
// 如果结果是Success,就执行onSuccess方法;
// 如果结果是Failure,就执行onFailures方法。
R when({
required R Function(T data) onSuccess,
required R Function(AppError error) onFailure,
}) {
return switch (this) {
Success(:final data) => onSuccess(data),
Failure(:final error) => onFailure(error),
};
}
// 如果结果是Success,就返回数据;否则返回null。
T? getOrNull() => switch (this) {
Success(:final data) => data,
Failure() => null,
};
// 如果结果是Success,就返回true;否则返回false。
bool get isSuccess => this is Success this is Failure;
}
使用方式会变得非常简洁:
final result = await repository.getPosts();
final posts = result.when(
onSuccess: (data) => data,
onFailure: (error) => [],
);
将这一技术应用到实际的Bloc功能中
让我们把所有这些组件整合成一个完整的Bloc。我们会使用在上一个环节中构建的posts功能,并将其升级为使用Result类型。
现在,状态类采用了密封类的形式:
// post_state.dart
sealed class PostState {}
class PostInitial extends PostState {}
class PostLoading extends PostState {}
// Success状态直接包含帖子数据
class PostLoaded extends PostState {
final List posts;
const PostLoaded(thisposts);
}
// Error状态会携带一个具体的AppError类型,而不仅仅是字符串。
// 这意味着用户界面可以根据错误类型来做出不同的显示——比如显示“没有网络连接”的提示,或者“服务器出现错误”的提示,又或者是“请重试”的提示。
class PostError extends PostState {
final AppError error;
const PostError(this.error);
}
Bloc的结构如下:
// post_bloc.dart
class PostBloc extends Bloc {
final PostRepository _repository;
PostBloc(this._repository) : super(PostInitial()) {
on(_onLoadPosts);
}
Future _onLoadPosts(
LoadPosts event,
Emitter emit,
) async {
emit(PostLoading());
// getPosts()现在返回Result>
// 我们可以直接对返回的结果进行类型匹配——
// 这里不需要使用try/catch语句,因为repository已经处理好了所有的错误情况,并将它们封装在了Failure对象中。
final result = await _repository.getPosts();
switch (result) {
case Success(:final data):
emit(PostLoaded(data));
case Failure(:final error):
emit(PostError(error));
}
}
}
请注意,`Bloc`中完全没有使用`try/catch`结构。错误处理工作是由相应的代码库来完成的,`Bloc`只是读取处理结果并输出相应状态而已。这种设计简洁明了,每一层都只负责完成一项具体的任务。
关于用户界面:
// post_screen.dart
BlocBuilder
builder: (context, state) {
return switch (state) {
PostInitial() => const Center(
child: Text('点击按钮以加载文章'),
),
PostLoading() => const Center(
child: CircularProgressIndicator(),
),
PostLoaded(:final posts) => ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
leading: Text(${post.id}'),
title: Text(post.title),
subtitle: Text(post.body),
);
},
),
// 根据错误类型显示相应的提示信息,这是`try/catch`结构无法实现的——
// 它能够提供结构化、类型明确的错误信息,从而帮助用户界面做出更合适的响应。
PostError(:final error) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
switch (error) {
NoInternetError() => '没有网络连接,请检查您的网络连接。'
ServerError(:final statusCode) => '服务器出现错误(代码为${statusCode}),请重新尝试。'
ParseError() => '发生了一些问题,请重新尝试。'
UnknownError() => '发生了未知错误。'
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read
},
child: Text('重新尝试'),
),
],
),
);
},
};
},
)
现在,用户界面会根据不同的错误类型显示不同的提示信息。没有网络连接的用户会看到与遇到服务器错误的用户不同的提示信息。这样的设计比那种笼统的“发生了问题”提示要好得多,因为这种处理方式是基于结构化、类型明确的错误信息来实现的。
何时采用这种设计方法合适,何时则不合适
在这里,我想坦诚地说,因为我见过一些开发者以“良好的架构设计”为名,对一些简单的事情进行过度设计。
在以下情况下应使用`Result`类型:
-
当函数的失败方式有多种不同类型,且调用者需要针对这些不同的情况采取不同的处理措施时
-
当你正在构建一个被多个功能模块所依赖的代码库或服务层时
-
当你所在的团队中,不一致的错误处理方式会引发严重问题时
-
当某个功能涉及金钱、用户数据或其他任何一旦出现故障就会带来危险的情况时
在以下情况下应使用 try/catch 结构:
-
当这是一个简单的一次性操作,且仅涉及一个小型功能时。
-
无论出现什么错误,错误处理方式都相同:只是显示一条消息或记录日志而已。
-
当你正在进行原型开发或项目处于早期阶段,且系统架构仍在不断变化时。
-
当增加的复杂性与代码量的规模不成正比时。
“结果类型”模式确实会为代码结构增添一定的复杂性,这一点不可否认。不过,简单的 try/catch 结构所消耗的代码量更少。其缺点在于:try/catch 结构的存在并不会强制调用者必须处理错误;而“结果类型”则通过类型系统来确保调用者必须处理错误。
对于那些服务于真实用户、且有多名开发人员参与开发的正式应用程序来说,这种明确的错误处理机制确实值得为此增加额外的代码量。但对于你独自开发的个人项目而言,这样的设计可能就有些过度了。
端到端示例
下面是一个将所有相关组件整合在一起的完整示例。你可以将其复制到一个新的 Flutter 项目中并运行它。
文件夹结构:
lib/
core/
result.dart
app_error.dart
models/
post.dart
data/
post_repository.dart
bloc/
post_bloc.dart
post_event.dart
post_state.dart
ui/
post_screen.dart
main.dart
result.dart:
sealed class Result {}
class Success extends Result {
final T data;
const Success(this.data);
}
class Failure extends Result {
final AppError error;
const Failure(this.error);
}
extension ResultExtension on Result {
R when({
required R Function(T data) onSuccess,
required R Function(AppError error) onFailure,
}) {
return switch (this) {
Success(:final data) => onSuccess(data),
Failure(:final error) => onFailure(error),
};
}
}
app_error.dart:
sealed class AppError {}
class NoInternetError extends AppError {}
class ServerError extends AppError {
final int statusCode;
final String message;
const ServerError({required this.statusCode, required this.message});
}
class ParseError extends AppError {
final String message;
const ParseError(this.message);
}
class UnknownError extends AppError {
final String message;
const UnknownError(this.message);
}
post.dart:
class Post {
final int id;
final String title;
final String body;
final int userId;
const Post({
required this.id,
required this.title,
required this.body,
required this.userId,
});
factory PostfromJson(Map json) {
return Post(
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
userId: json['userId'] as int,
);
}
}
post_repository.dart:
import 'package:dio/dio.dart';
import '../core/result.dart';
import '../core/app_error.dart';
import '../models/post.dart';
class PostRepository {
final Dio _dio;
PostRepository(this._dio);
Future〈Result〈List〈Post〉>>> getPosts() async {
try {
final response = await _dio.get(
'https://jsonplaceholder.typicode.com/posts',
);
try {
final List〈dynamic〉 data = response.data as List〈dynamic〉;;
final posts = data
.map((json) => Post.fromJson(json as Map〈String, dynamic〉))
.ToList();
return Successposts);
} catch (e) {
return Failure(ParseError('无法解析帖子内容: $e'));
}
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionError) {
return Failure(NoInternetError());
}
return Failure(
ServerError(
statusCode: e.response?.statusCode ?? 0,
message: e.message ?? '服务器错误',
),
);
} catch (e) {
return Failure(UnknownError(e.toString()));
}
}
}
post_event.dart:
sealed class PostEvent {}
class LoadPosts extends PostEvent {}
post_state.dart:
import '../core/app_error.dart';
import '../models/post.dart';
sealed class PostState {}
class PostInitial extends PostState {}
class PostLoading extends PostState {}
class PostLoaded extends PostState {
final List〈Post〉 posts;
const PostLoaded(this/posts);
}
class PostError extends PostState {
final AppError error;
const PostError(this.error);
}
post_bloc.dart:
import 'package:flutterBloc/flutter Bloc.dart';
import '../core/result.dart';
import '../data/post_repository.dart';
import 'post_event.dart';
import 'post_state.dart';
class PostBloc extends Bloc〈PostEvent, PostState〉 {
final PostRepository _repository;
PostBloc(this._repository) : super(PostInitial()) {
on〈LoadPosts〉(_onLoadPosts);
}
Future〈void〉 _onLoadPosts(
LoadPosts event,
Emitter〈PostState〉 emit,
) async {
emit(PostLoading());
final result = await _repository.getPosts();
switch (result) {
case Success(:final data):
emit(PostLoaded(data));
case Failure(:final error):
emit(PostError(error));
}
}
}
post_screen.dart:
import 'package:flutter/material.dart';
import 'packageflutter_bloc/flutterBloc.dart';
import '../bloc/post Bloc.dart';
import '../bloc/post_event.dart';
import '../bloc/post_state.dart';
import '../core/app_error.dart';
class PostScreen extends StatelessWidget {
const PostScreen({super.key});
String _errorMessage(AppError error) {
return switch (error) {
NoInternetError() =>
'没有互联网连接。请检查您的网络连接。',
ServerError(:final statusCode) =>
'服务器错误(状态码:$statusCode)。请重试。',
ParseError() => '出现了一些问题。请重试。',
UnknownError() => '发生了未知错误。',
};
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('帖子'),
body: BlocBuilder〈PostBloc, PostState〉>(
builder: (context, state) {
return switch (state) {
PostInitial() => const Center(
child: Text('点击按钮加载帖子'),
),
PostLoading() => const Center(
child: CircularProgressIndicator(),
),
PostLoaded(:final posts) => ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
leading: Text(${post.id}'),
title: Text(post.title),
subtitle: Text(post.body),
);
},
),
PostError(:final error) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_errorMessage(error)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read〈PostBloc〉>().add(LoadPosts());
},
child: const Text('重试'),
),
],
),
),
};
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read〈PostBloc〉>().add(LoadPosts()),
child: const IconIcons.download),
),
);
}
}
main.dart:
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'packageflutter_bloc/flutterBloc.dart';
import 'bloc/postBloc.dart';
import 'data/post_repository.dart';
import 'ui/post_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '结果类型演示',
home: BlocProvider(
create: (_) => PostBloc(PostRepository(Dio)),
child: const PostScreen(),
),
);
}
}
最后的思考
这一切都不是为了显得自己多么聪明,也不是为了盲目遵循某种模式。其目的在于让错误能够被清晰地看到。
将“try/catch”作为唯一的错误处理手段,根本的问题在于它会将失败的可能性隐藏在看似正常的函数签名背后。而结果类型则能在类型系统中揭示这种可能性,这样编译器就能帮助你以一致的方式处理这些错误。
密封类、带类型的错误信息、模式匹配以及Dart 3中的记录功能结合起来,能够构建出一个这样的系统:
-
函数会明确说明自己能够返回什么结果
-
每种错误类型都会被明确地处理
-
每当添加新的错误类型时,所有无法处理这种错误的代码都会自动出错
-
用户界面能够为不同的错误显示相应的提示信息
我真希望自己当初能用这种方式来开发第一个正式发布的应用程序。这样就能避免花费大量时间去排查那些无声无息的故障以及不一致的错误状态了。
如果你已经熟悉“try/catch”的用法,并且想要进一步提升自己的错误处理能力,那么可以从简单的地方开始尝试。先在某个数据存储结构中添加结果类型,看看效果如何。一旦你体验到了这种清晰性,这种设计模式就会自然而然地被应用到更多的地方去。


