Dart 提供了內建的錯誤處理機制, 能夠拋出與捕捉例外(exceptions)。

如同在 錯誤處理文件 中所提到, Dart 的例外屬於未處理例外(unhandled exceptions)。 這表示會拋出例外的方法不需要宣告這些例外, 而呼叫這些方法的程式碼也不強制要求一定要捕捉它們。

這可能導致例外沒有被妥善處理的情況。 在大型專案中, 開發者可能會忘記捕捉例外, 而應用程式的不同層或元件 也可能會拋出未被記錄的例外。 這會導致錯誤發生甚至應用程式崩潰。

在本指南中, 你將會了解這項限制, 以及如何透過 result(結果)模式來改善。

Flutter 應用程式中的錯誤流程

#

遵循 Flutter 架構指引 的應用程式 通常由 view model、repository、service 等組件構成。 當這些元件中的某個函式發生失敗時, 應該將錯誤資訊傳遞給呼叫端元件。

通常這會透過例外來完成。 舉例來說, 當 API client service 無法與遠端伺服器溝通時, 可能會拋出 HTTP Error Exception。 呼叫端元件(例如 Repository), 必須選擇要捕捉這個例外, 或者忽略它並讓呼叫的 view model 處理。

這可以從以下範例觀察到。請參考這些類別:

  • 一個 service,ApiClientService,負責對遠端服務進行 API 呼叫。
  • 一個 repository,UserProfileRepository, 提供由 ApiClientService 所提供的 UserProfile
  • 一個 view model,UserProfileViewModel,使用 UserProfileRepository

ApiClientService 包含一個方法 getUserProfile, 在特定情境下會拋出例外:

  • 當回應碼不是 200 時,該方法會拋出 HttpException
  • 若回應的 JSON 格式不正確,解析方法會拋出例外。
  • HTTP client 也可能因網路問題而拋出例外。

以下程式碼測試了多種可能發生的例外情境:

dart
class ApiClientService {
  // ···

  Future<UserProfile> getUserProfile() async {
    try {
      final request = await client.get(_host, _port, '/user');
      final response = await request.close();
      if (response.statusCode == 200) {
        final stringData = await response.transform(utf8.decoder).join();
        return UserProfile.fromJson(jsonDecode(stringData));
      } else {
        throw const HttpException('Invalid response');
      }
    } finally {
      client.close();
    }
  }
}

UserProfileRepository 不需要處理來自 ApiClientService 的例外狀況。
在這個範例中,它只會回傳 API Client 的值。

dart
class UserProfileRepository {
  // ···

  Future<UserProfile> getUserProfile() async {
    return await _apiClientService.getUserProfile();
  }
}

最後,UserProfileViewModel 應該捕捉所有例外並處理錯誤。

這可以透過將對 UserProfileRepository 的呼叫包裹在 try-catch 中來完成:

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···

  Future<void> load() async {
    try {
      _userProfile = await userProfileRepository.getUserProfile();
      notifyListeners();
    } on Exception catch (exception) {
      // handle exception
    }
  }
}

實際上,開發人員有時可能會忘記正確捕捉例外(exception),導致出現如下的程式碼。 這段程式碼可以編譯並執行,但如果發生前面提到的其中一種例外時,應用程式就會當機:

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···

  Future<void> load() async {
    _userProfile = await userProfileRepository.getUserProfile();
    notifyListeners();
  }
}

你可以嘗試透過撰寫ApiClientService的文件,來提醒可能會拋出的例外(exception)。然而,由於 view model 並未直接使用該 service,其他在此程式碼庫中工作的開發者可能會忽略這些資訊。

使用 result pattern

#

另一種替代拋出例外的方式,是將函式的輸出包裝在Result物件中。

當函式成功執行時,Result會包含回傳的值;但如果函式未能成功完成,Result物件則會包含錯誤資訊。

Result是一個sealed類別,可以繼承OkError類別。若要回傳成功的值,請使用Ok子類別;若要回傳捕獲到的錯誤,則使用Error子類別。

以下程式碼展示了一個簡化版的Result類別範例,僅用於展示。完整實作請參考本頁底部。

dart
/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
///
/// Use [Result.ok] to create a successful result with a value of type [T].
/// Use [Result.error] to create an error result with an [Exception].
sealed class Result<T> {
  const Result();

  /// Creates an instance of Result containing a value
  factory Result.ok(T value) => Ok(value);

  /// Create an instance of Result containing an error
  factory Result.error(Exception error) => Error(error);
}

/// Subclass of Result for values
final class Ok<T> extends Result<T> {
  const Ok(this.value);

  /// Returned value in result
  final T value;
}

/// Subclass of Result for errors
final class Error<T> extends Result<T> {
  const Error(this.error);

  /// Returned error in result
  final Exception error;
}

在這個範例中, Result 類別使用泛型型別 T 來表示任何回傳值, 這個值可以是像 String 這樣的 Dart 原始型別,或是 int,也可以是像 UserProfile 這樣的自訂類別。

建立 Result 物件

#

對於使用 Result 類別來回傳值的函式, 函式不再直接回傳值, 而是回傳一個包含該值的 Result 物件。

例如,在 ApiClientService 中, getUserProfile 會改為回傳 Result

dart
class ApiClientService {
  // ···

  Future<Result<UserProfile>> getUserProfile() async {
    // ···
  }
}

與其直接回傳 UserProfile
它會回傳一個包含 UserProfileResult 物件。

為了方便使用 Result 類別,
它包含了兩個具名建構函式:Result.okResult.error
可依照所需的輸出,使用它們來建立 Result
同時,捕捉程式碼中拋出的任何例外,
並將其包裝到 Result 物件中。

例如,下方的 getUserProfile() 方法
已經修改為使用 Result 類別:

dart
class ApiClientService {
  // ···

  Future<Result<UserProfile>> getUserProfile() async {
    try {
      final request = await client.get(_host, _port, '/user');
      final response = await request.close();
      if (response.statusCode == 200) {
        final stringData = await response.transform(utf8.decoder).join();
        return Result.ok(UserProfile.fromJson(jsonDecode(stringData)));
      } else {
        return const Result.error(HttpException('Invalid response'));
      }
    } on Exception catch (exception) {
      return Result.error(exception);
    } finally {
      client.close();
    }
  }
}

原本的 return 陳述式已被替換為使用 Result.ok 回傳值的陳述式。 throw HttpException() 也被替換為回傳 Result.error(HttpException()) 的陳述式,並將錯誤包裝在 Result 中。 此外,該方法也以 try-catch 區塊包裹,以捕捉由 Http client 或 JSON parser 所拋出的任何例外,並將其包裝成 Result.error

repository 類別同樣需要修改,現在不再直接回傳 UserProfile,而是回傳 Result<UserProfile>

dart
Future<Result<UserProfile>> getUserProfile() async {
  return await _apiClientService.getUserProfile();
}

解包 Result 物件

#

現在,view model 不再直接接收UserProfile
而是接收一個包含UserProfileResult

這會強制實作 view model 的開發者
必須先解包Result才能取得UserProfile
並能避免未捕捉的例外發生。

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···

  UserProfile? userProfile;

  Exception? error;

  Future<void> load() async {
    final result = await userProfileRepository.getUserProfile();
    switch (result) {
      case Ok<UserProfile>():
        userProfile = result.value;
      case Error<UserProfile>():
        error = result.error;
    }
    notifyListeners();
  }
}

Result 類別是透過 sealed 類別實作的,
這表示它只能是 OkError 其中一種類型。
這讓程式碼可以使用
switch result or expression 來判斷結果。

Ok<UserProfile> 的情況下,
可以透過 value 屬性取得值。

Error<UserProfile> 的情況下,
可以透過 error 屬性取得錯誤物件。

改善控制流程

#

將程式碼包裹在 try-catch 區塊中可以確保
拋出的例外會被捕捉,而不會傳遞到程式碼的其他部分。

請參考以下程式碼。

dart
class UserProfileRepository {
  // ···

  Future<UserProfile> getUserProfile() async {
    try {
      return await _apiClientService.getUserProfile();
    } catch (e) {
      try {
        return await _databaseService.createTemporaryUser();
      } catch (e) {
        throw Exception('Failed to get user profile');
      }
    }
  }
}

在此方法中,UserProfileRepository 會嘗試使用 ApiClientService 來取得 UserProfile
如果失敗,則會嘗試在 DatabaseService 中建立一個暫時性的使用者。

由於這兩個服務方法都可能失敗,
因此程式碼必須在這兩種情況下都捕捉例外(exceptions)。

這可以透過採用 Result 模式來改進:

dart
Future<Result<UserProfile>> getUserProfile() async {
  final apiResult = await _apiClientService.getUserProfile();
  if (apiResult is Ok) {
    return apiResult;
  }

  final databaseResult = await _databaseService.createTemporaryUser();
  if (databaseResult is Ok) {
    return databaseResult;
  }

  return Result.error(Exception('Failed to get user profile'));
}

在這段程式碼中,如果 Result 物件是 Ok 的實例,
則該函式會回傳該物件;
否則,會回傳 Result.Error

整合總結

#

在本指南中,你已經學會
如何使用 Result 類別來回傳結果值。

重點整理如下:

  • Result 類別會強制呼叫方法檢查錯誤,
    減少因未捕捉例外而產生的錯誤數量。
  • Result 類別相較於 try-catch 區塊,有助於改善控制流程。
  • Result 類別是 sealed,且只能回傳 OkError 的實例,
    讓程式碼可以透過 switch 敘述來解包它們。

下方可以看到完整的 Result 類別,
這是在 Compass App example
針對 Flutter architecture guidelines 所實作的版本。

dart
/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
///
/// Use [Result.ok] to create a successful result with a value of type [T].
/// Use [Result.error] to create an error result with an [Exception].
///
/// Evaluate the result using a switch statement:
/// ```dart
/// switch (result) {
///   case Ok(): {
///     print(result.value);
///   }
///   case Error(): {
///     print(result.error);
///   }
/// }
/// ```
sealed class Result<T> {
  const Result();

  /// Creates a successful [Result], completed with the specified [value].
  const factory Result.ok(T value) = Ok._;

  /// Creates an error [Result], completed with the specified [error].
  const factory Result.error(Exception error) = Error._;
}

/// A successful [Result] with a returned [value].
final class Ok<T> extends Result<T> {
  const Ok._(this.value);

  /// The returned value of this result.
  final T value;

  @override
  String toString() => 'Result<$T>.ok($value)';
}

/// An error [Result] with a resulting [error].
final class Error<T> extends Result<T> {
  const Error._(this.error);

  /// The resulting error of this result.
  final Exception error;

  @override
  String toString() => 'Result<$T>.error($error)';
}