使用 try/catch 和结果类型的 Flutter 异常处理

2,035 阅读5分钟

当我们运行一个Flutter应用程序时,很多事情都可能出错。

用户可能会输入错误的信息,网络请求可能会失败,或者我们可能在某个地方犯了一个程序员错误,我们的应用程序就会崩溃。

异常处理是处理我们代码中这些潜在错误的一种方式,因此我们的应用程序可以优雅地从中恢复。

本文将回顾Dart和Flutter中异常处理的基础知识(使用trycatch ),并探讨Result 类型如何帮助我们利用类型系统来更明确地处理错误。

而在接下来的文章中,我们将解决更复杂的用例,即我们需要连续地运行多个异步调用。

准备好了吗?让我们开始吧!

Dart和Flutter中的异常处理 基础知识

作为一个例子,这里有一个简单的Dart函数,我们可以用它来从一个IP地址获取一个位置。

// get the location for a given IP using the http package
Future<Location> getLocationFromIP(String ipAddress) async {
  try {
    final uri = Uri.parse('http://ip-api.com/json/$ipAddress');
    final response = await http.get(uri); 
    switch (response.statusCode) {
      case 200:
        final data = json.decode(response.body);
        return Location.fromMap(data);
      default:
        throw Exception(response.reasonPhrase);
    }
  } on SocketException catch (_) {
    // make it explicit that a SocketException will be thrown if the network connection fails
    rethrow;
  }
}

在上面的代码中,我们使用http包向外部API发出一个GET请求。

如果请求成功,我们将响应体解析为JSON,并返回一个Location 对象。

但如果请求失败(例如,如果IP地址无效或API停机),我们会抛出一个异常。

我们还将代码包裹在一个try/catch 块中,以便在出现网络连接错误时,捕捉任何可能发生的SocketExceptions。

如果我们想使用我们的函数,我们可以像这样简单地调用它。

final location = await getLocationFromIP('122.1.4.122');
print(location);

但要注意的是!如果这个函数抛出,我们就会得到一个未处理的异常

为了解决这个问题,我们需要把它包在一个try/catch 块中。

try {
  final location = await getLocationFromIP('122.1.4.122');
  print(location);
} catch (e) {
  // TODO: handle exception, for example by showing an alert to the user
}

现在我们的代码更加健壮了。但是,我们太容易忘记首先添加try/catch 块。

这是因为我们的函数的签名并没有明确指出它可以抛出一个异常。

Future<Location> getLocationFromIP(String ipAddress)

事实上,发现该函数是否抛出的唯一方法是阅读其文档和实现。

如果我们有一个庞大的代码库,就更难弄清楚哪些函数可能会抛出,哪些不会。

用结果类型改进异常处理

我们真正想要的是一种方法,明确指出函数可以返回一个结果,可以成功,也可以是错误。

KotlinSwift等语言使用被称为密封类(Kotlin)或带有关联值的枚举(Swift)的语言特性定义了他们自己的Result 类型。

但在Dart中,这些功能是不可用的,我们没有一个内置的Result 类型。

而如果我们愿意,我们可以使用抽象类泛型建立我们自己的类型。

或者我们可以让我们的生活变得简单,使用multiple_result包,它给我们一个 Result类型,我们可以用它来指定SuccessError 类型。

作为multiple_result的替代方案,你可以使用fpdartdartz等包,它们有一个等同的类型,叫做Either 。还有一个来自Dart团队的官方async包。所有这些包使用的语法略有不同,但概念是相同的。

下面是我们如何转换我们之前的例子来使用它。

// 1. change the return type
Future<Result<Exception, Location>> getLocationFromIP(String ipAddress) async {
  try {
    final uri = Uri.parse('http://ip-api.com/json/$ipAddress');
    final response = await http.get(uri);
    switch (response.statusCode) {
      case 200:
        final data = json.decode(response.body);
        // 2. return Success with the desired value
        return Success(Location.fromMap(data));
      default:
        // 3. return Error with the desired exception
        return Error(Exception(response.reasonPhrase));
    }
  } catch (e) { // catch all exceptions (not just SocketException)
    // 4. return Error here too
    return Error(e);
  }
}

现在我们的函数签名准确地告诉我们这个函数是做什么的。

  • 如果出了问题,返回一个Exception
  • 如果一切顺利,则返回一个成功值,其中包括结果Location 对象。

因此,我们可以像这样更新我们的调用代码。

final result = await getLocationFromIP('122.1.4.122');

而如果我们想处理这个结果,我们可以使用模式匹配when 方法。

result.when(
  (exception) => print(exception), // TODO: Handle exception
  (location) => print(location), // TODO: Do something with location
);

迫使我们明确地处理错误情况,因为省略它将是一个编译器错误。

通过使用带有模式匹配的Result 类型,我们可以利用Dart类型系统的优势,确保我们总是处理错误。

结果类型 优点

下面是我们到目前为止学到的东西。

  • Result 类型让我们在Dart的函数或方法的签名明确声明成功和错误的类型。
  • 我们可以在调用代码中使用模式匹配来确保我们明确地处理这两种情况。

这些都是很大的好处,因为它们使我们的代码更健壮,更不容易出错。

因此,我们可以继续使用Result ,无处不在,对吗?

一个刚刚发现结果类型的新手开发者

在我们对整个代码库进行重构之前,让我们再深入挖掘一下,弄清楚什么时候使用Result 可能不是一个好主意。

当结果类型不能很好地工作时

下面是一个方法的例子,它在内部调用了其他几个异步方法。

Future<void> placeOrder() async {
  try {
    final uid = authRepository.currentUser!.uid;
    // first await call
    final cart = await cartRepository.fetchCart(uid);
    final order = Order.fromCart(userId: uid, cart: cart);
    // second await call
    await ordersRepository.addOrder(uid, order);
    // third await call
    await cartRepository.setCart(uid, const Cart());
  } catch (e) {
    // TODO: Handle exceptions from any of the methods above
  }
}

如果上面的任何一个方法抛出了一个异常,我们可以在一个地方捕获它,并根据需要处理它。

但是,假设我们将上面的每个方法转换为返回一个Future<Result>

在这种情况下,placeOrder() 方法将看起来像这样。

Future<Result<Exception, void>> placeOrder() async {
  final uid = authRepository.currentUser!.uid;
    // first await call
  final result = await cartRepository.fetchCart(uid);
  if (result.isSuccess()) {
    final order = Order.fromCart(userId: uid, cart: result.getSuccess());
    // second await call
    final result = await ordersRepository.addOrder(uid, order);
    if (result.isSuccess()) {
      // third call (await not needed if we return the result)
      return cartRepository.setCart(uid, const Cart());
    } else {
      return result.getError()!;
    }
  } else {
    return result.getError()!;
  }
}

这段代码更难读,因为我们必须手动解开每个Result 对象,并编写大量的控制流逻辑来处理所有的成功/错误情况。

相比之下,使用try/catch 使得连续调用多个异步方法变得更加容易(只要这些方法本身抛出异常而不是返回一个Result

我们可以在多个异步调用中使用Result吗?

因此,当你考虑是否应该将一个方法转换为返回Future<Result> ,你可以问问自己,你是可能单独调用它,还是与其他异步函数一起调用。

但很难为你声明的每一个方法决定这一点。而且,即使一个方法今天被单独调用,将来也可能不再被单独调用。

我们真正想要的是一种方法来捕获由多个异步调用组成的异步计算的结果,并将其包裹在一个Future<Result>

而这将是我下一篇文章的主题,它将更详细地介绍功能错误处理

总结

让我们来总结一下到目前为止我们所学到的东西。

multiple_result包给了我们一个Result ,让我们在Dart的函数或方法的签名明确声明成功和错误类型。

// 1. change the return type
Future<Result<Exception, Location>> getLocationFromIP(String ipAddress) async {
  try {
    final uri = Uri.parse('http://ip-api.com/json/$ipAddress');
    final response = await http.get(uri);
    switch (response.statusCode) {
      case 200:
        final data = json.decode(response.body);
        // 2. return Success with the desired value
        return Success(Location.fromMap(data));
      default:
        // 3. return Error with the desired exception
        return Error(Exception(response.reasonPhrase));
    }
  } on SocketException catch (e) {
    // 4. return Error here too
    return Error(e);
  }
}

而且我们可以在调用代码中使用模式匹配来确保我们明确地处理这两种情况。

// Use like this:
final result = await getLocationFromIP('122.1.4.122');
result.when(
  (exception) => print(exception), // TODO: Handle exception
  (location) => print(location), // TODO: Do something with location
);

然而,我们有一个开放的问题,如果我们必须连续调用多个异步函数,如何使用Result

我将在接下来关于功能错误处理的文章中介绍这个问题(以及更多)。