こんにちは。
本業では Android アプリのネイティブエンジニアをやっているものです。
以前は副業で Flutter を沢山書いていたのですが、最近は趣味で Flutter をよく書いています。
ところで、皆さんはエラーハンドリングに、悩んでませんか。
私はネイティブでも禿げるくらい悩みました(育毛の宣伝をしそうな雰囲気を醸し始めてしまっていますが、しません)
試行錯誤した末、 iosched
のこのリポジトリのハンドリングが一番気に入っています。
※ iosched
は超有名なお手本アプリです( NowInAndroid
の一つ前)
結論として、 Flutter (というか Dart )においてもこのエラーハンドリングをしたいと考えました。
理由としては、この方法だとエラーハンドリングを強制しやすいからです。
元のソースコードを知らない人にとっては意味不明だと思うので、最初に iosched
の非同期処理のエラーハンドリングの方法から解説します。
肝となっているのは CoroutineUseCase
と Result
です。
package com.google.samples.apps.iosched.shared.domain
import com.google.samples.apps.iosched.shared.result.Result
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* Executes business logic synchronously or asynchronously using Coroutines.
*/
abstract class UseCase<in P, R>(private val coroutineDispatcher: CoroutineDispatcher) {
/** Executes the use case asynchronously and returns a [Result].
*
* @return a [Result].
*
* @param parameters the input parameters to run the use case with
*/
suspend operator fun invoke(parameters: P): Result<R> {
return try {
// Moving all use case's executions to the injected dispatcher
// In production code, this is usually the Default dispatcher (background thread)
// In tests, this becomes a TestCoroutineDispatcher
withContext(coroutineDispatcher) {
execute(parameters).let {
Result.Success(it)
}
}
} catch (e: Exception) {
Timber.d(e)
Result.Error(e)
}
}
/**
* Override this to set the code to be executed.
*/
@Throws(RuntimeException::class)
protected abstract suspend fun execute(parameters: P): R
}
sealed class Result<out R> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
}
※ Result
に関しては若干省略しています。
上記の二つでどうエラーハンドリングするかというと
-
HogeUseCase
で abstract のUseCase
を継承してexecute()
を override する -
HogeUseCase
を invoke してResult
型を返す
です。
abstract の UseCase
を見てみると、非同期スレッド内で吐かれた例外を catch して Result.Error
型で返しています。
execute()
に実装したコードを実行して try catch して Result
型でラップして返してくれるという動きをします。
ViewModel 側に常に UseCase をインジェクトしていればまずハンドリングが漏れないはずです。
Repository だと沢山メソッドがあってこうはできないので、やはり UseCase は必要だなと感じました。
ちょっと長くなってしまったのですが、 Dart においてもぜひこのハンドリングをしてみたいと思います!
Result 型を作る
1. freezed を入れる
dependencies:
build_runner: ^2.4.6
flutter:
sdk: flutter
freezed: ^2.4.2
freezed_annotation: ^2.4.1
コアとなるコードが外部のライブラリに依存してしまうのは不安ですが
- Dart package であること( Flutter への依存ではないだけかなりマシ)
- いずれ Kotlin のように data class が導入されるであろうという希望的観測
を根拠に導入します。
2. freezed を使って Result 型を作る
import 'package:freezed_annotation/freezed_annotation.dart';
part 'result.freezed.dart';
/// dart run build_runner watch --delete-conflicting-outputs
@freezed
abstract class Result<T> with _$Result<T> {
const factory Result.success(T value) = Success<T>;
const factory Result.error(Error error) = ResultError<T>;
const factory Result.exception(Exception exception) = ResultException<T>;
}
part をつけ忘れないようにご注意ください。
あと、 dart run build_runner watch --delete-conflicting-outputs
で自動生成も忘れずに。
ちなみに error
と exception
を分けたのは、 Dart が Error
と Exception
を区別しているからです。
また、ResultError
や ResultException
というちょっとダサい命名にした理由は、クラス名が Exception
や Error
とコンフリクトするからです。
早速 Result 型ができました。
使い方は後で説明します。
UseCase を作る
abstract class UseCase<I, O> {
Future<Result<O>> call(I params) async {
try {
final data = await execute(params);
return Success(data);
} on Error catch (e) {
return ResultError(e);
} on Exception catch (e) {
return ResultException(e);
}
}
Future<O> execute(I params);
}
ほとんどそのまま同じように作れましたね!
call()
は Kotlin でいうところの invoke()
です。
UseCase と Result 型を定義して一番嬉しいのは、コンパイルエラーをフル活用して Error や Exception のハンドリングを強制できるところです
try catch を都度書く方針にしてたら私は絶対忘れます(※堂々と宣言しましたが自慢ではないです)
それでは次節で実際の使い方を見てみましょう。
UseCase を使ってみる。
先ほど作った UseCase の使い方を見てみましょう。
試しに、「1を与えられた数で割った数値を返す。ただし、100超過の数は受け付けない」という UseCase を作ってみます。
class GetDividedNumberUseCase extends UseCase<int, double> {
@override
Future<double> execute(int params) async {
await Future.delayed(const Duration(seconds: 1));
if (params == 0) {
throw Exception("Don't divide number by zero. ");
}
if (params > 100) {
throw Error();
}
return 1 / params;
}
}
こんな感じです。
割る数が 0 のときは Exception を投げて、 割る数が 100超過のときは Error を投げます。
使い方の確認も兼ねて簡単なテストを書きます。
確認したいことは下記の二点です。
-
GetDividedNumberUseCase
を invoke したときにきちんとResult
型を返してくれるのか - 非同期スレッドから例外やエラーを投げたときに正しく catch できているか
わかりやすくするために 雑なテスト シンプルなテストを書きました
void main() {
test("Failed by zero division", () async {
final useCase = GetDividedNumberUseCase();
final result = await useCase(0);
result.when(
success: (data) {
assert(false);
},
error: (e) {
assert(false);
},
exception: (e) {
assert(true);
},
);
});
test("Divide one by two", () async {
final useCase = GetDividedNumberUseCase();
final result = await useCase(2);
result.when(
success: (data) {
expect(0.5, data); // 丸め誤差はここでは無視
},
error: (e) {
assert(false);
},
exception: (e) {
assert(false);
},
);
});
test("Failed by dividing one by more than 100", () async {
final useCase = GetDividedNumberUseCase();
final result = await useCase(101);
result.when(
success: (data) {
assert(false);
},
error: (e) {
assert(true);
},
exception: (e) {
assert(false);
},
);
});
}
通りました。
Result
型の使い方がめちゃくちゃかっこよくないですか!?
こんなに Kotlin チックに書けるなんて・・・と深く感動しました。
今回のサンプルリポジトリです。
皆さんもスマートなエラーハンドリング方法を思い付いたらぜひ共有してください!
ご覧いただきありがとうございました。