8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Flutter でスマートに非同期処理のエラーハンドリングをする方法

Last updated at Posted at 2023-08-24

こんにちは。
本業では Android アプリのネイティブエンジニアをやっているものです。
以前は副業で Flutter を沢山書いていたのですが、最近は趣味で Flutter をよく書いています。

ところで、皆さんはエラーハンドリングに、悩んでませんか。

私はネイティブでも禿げるくらい悩みました(育毛の宣伝をしそうな雰囲気を醸し始めてしまっていますが、しません)
試行錯誤した末、 ioschedこのリポジトリのハンドリングが一番気に入っています。
iosched は超有名なお手本アプリです( NowInAndroid の一つ前)

結論として、 Flutter (というか Dart )においてもこのエラーハンドリングをしたいと考えました。
理由としては、この方法だとエラーハンドリングを強制しやすいからです。

元のソースコードを知らない人にとっては意味不明だと思うので、最初に iosched の非同期処理のエラーハンドリングの方法から解説します。
肝となっているのは CoroutineUseCaseResult です。

CoroutineUseCase
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
}
Result
sealed class Result<out R> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

Result に関しては若干省略しています。

上記の二つでどうエラーハンドリングするかというと

  1. HogeUseCase で abstract の UseCase を継承して execute() を override する
  2. HogeUseCase を invoke して Result 型を返す

です。
abstract の UseCase を見てみると、非同期スレッド内で吐かれた例外を catch して Result.Error 型で返しています。
execute() に実装したコードを実行して try catch して Result 型でラップして返してくれるという動きをします。
ViewModel 側に常に UseCase をインジェクトしていればまずハンドリングが漏れないはずです。
Repository だと沢山メソッドがあってこうはできないので、やはり UseCase は必要だなと感じました。

ちょっと長くなってしまったのですが、 Dart においてもぜひこのハンドリングをしてみたいと思います!

Result 型を作る

1. freezed を入れる

pubspec.yaml
dependencies:
  build_runner: ^2.4.6
  flutter:
    sdk: flutter
  freezed: ^2.4.2
  freezed_annotation: ^2.4.1

コアとなるコードが外部のライブラリに依存してしまうのは不安ですが

  1. Dart package であること( Flutter への依存ではないだけかなりマシ)
  2. いずれ Kotlin のように data class が導入されるであろうという希望的観測

を根拠に導入します。

2. freezed を使って Result 型を作る

result.dart
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 で自動生成も忘れずに。
ちなみに errorexception を分けたのは、 Dart が ErrorException を区別しているからです。
また、ResultErrorResultException というちょっとダサい命名にした理由は、クラス名が ExceptionError とコンフリクトするからです。

早速 Result 型ができました。
使い方は後で説明します。

UseCase を作る

usecase.dart
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 を作ってみます。

get_divided_number_usecase.dart
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 を投げます。
使い方の確認も兼ねて簡単なテストを書きます。
確認したいことは下記の二点です。

  1. GetDividedNumberUseCase を invoke したときにきちんと Result 型を返してくれるのか
  2. 非同期スレッドから例外やエラーを投げたときに正しく catch できているか

わかりやすくするために 雑なテスト シンプルなテストを書きました

unit_test.dart
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 チックに書けるなんて・・・と深く感動しました。
今回のサンプルリポジトリです。

皆さんもスマートなエラーハンドリング方法を思い付いたらぜひ共有してください!
ご覧いただきありがとうございました。

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?