🕌

[Flutter] Exception Handling方法の検討: try~catch, Result Type, AsyncValue

2023/06/19に公開

背景

以下のようなMVVM構成でFlutterアプリを実装している。
APIをcallするとExceptionが発生しうるが、それをどこで、どのような実装方式でHandlingするべきかを検討した。

以下は基本となる実装例
riverpod_generatorを使っている

Repository

import 'package:riverpod_annotation/riverpod_annotation.dart';

class Repository {
  Repository({
    required this.api1,
    required this.api2,
  });

  final Api1 api1;
  final Api2 api2;

  Future<List<Item>> fetchItemList() async {
    final response = await api1.fetchItemList();
    return response.data.map((item) => Item(name: item.name));
  }
}


Repository repositoryProvider(RepositoryRef ref) {
  return Repository(
    api1: ref.read(api1Provider),
    api2: ref.read(api2Provider),
  );
}

ViewModel

import 'package:riverpod_annotation/riverpod_annotation.dart';


class ViewModel extends _$ViewModel {

  
  Future<List<Item>> build() async {
    final repository = ref.watch(repositoryProvider);
    final itemList = await repository.fetchItemList();
    return itemList;
  }
}

View

class View extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final itemListAsyncValue = ref.watch(viewModelProvider);
    return itemListAsyncValue.when(
      loading: () => const CircularProgressIndicator(),
      data: (itemList) => ListView.builder(
        itemCount: itemList.length,
        itemBuilder: (context, index) {
          final item = itemList[index];
          return Text(item.name);
        },
      ),
      error: (error, stackTrace) => Text(error.toString()),
    );
  }
}

選択肢

Repositoryでtry ~ catchする

class Repository {
  Repository({
    required this.api1,
    required this.api2,
  });

  final Api1 api1;
  final Api2 api2;

  Future<List<Item>> fetchItemList() async {
    try {
      final response = await api1.fetchItemList();
      return response.data.map((item) => Item(name: item.name));
    } on Exception {
      rethrow;
    }
  }
}

デメリット

fetchItemListをViewModelで利用する場合、ViewModel側でもtry ~ catchする必要がある。
しかし、「fetchItemListを使うときは必ずtry~catchが必要」というのはとても忘れやすい。
fetchItemListのコード定義を見にこないとExceptionをthrowするかわからない。

Repositoryからの返値に、Sealed Classの「Result」を使う

sealed classとは、switchする時に場合が尽くされているかチェックできるクラスのこと(Enumのように)
https://dart.dev/language/class-modifiers#sealed

/// Base Result class
sealed class Result<S> {
  const Result();
}

final class Success<S> extends Result<S> {
  const Success(this.value);
  final S value;
}

final class Failure<S> extends Result<S> {
  const Failure(this.exception);
  final Exception exception;
}
class Repository {
  Repository({
    required this.api1,
    required this.api2,
  });

  final Api1 api1;
  final Api2 api2;

  Future<Result<List<Item>, Exception>> fetchItemList() async {
    try {
      final response = await api1.fetchItemList();
      return Success(response.data.map((item) => Item(name: item.name)));
    } on Exception catch (exception) {
      return Failure(exception);
    }
  }
}

class ViewModel extends _$ViewModel {
  
  Future<List<Item>> build() async {
    final repository = ref.watch(repositoryProvider);
    final response = await repository.fetchItemList();
    switch (response) {
      case Success(value: final List<Item> itemList):
        return itemList;
      case Failure(exception: final Exception exception):
        return [];
    }
  }
}

メリット

  • Failureケースを明示的にハンドリングできる = try ~ catchの書き忘れのようなことが起こらない

デメリット

Resultを返す複数のメソッドをコールして、それらの返り値を統合する必要がある時に厄介。
コード引用

Future<Result<int, Exception>> function1() { ... }
Future<Result<int, Exception>> function2(int value) { ... }
Future<Result<int, Exception>> function3(int value) { ... }

Future<Result<int, Exception>> complexAsyncWork() async {
  // first async call
  final result1 = await function1();
  if (result1
    case Success(value: final value1)) {
      // second async call
      final result2 = await function2(value1);
      return switch (result2) {
        // third async call
        Success(value: final value2) => await function3(value2),
        Failure(exception: final _) => result2, // error
      };
    } else {
      return result1; // error
    }
}

この場合ならtry ~ catchの方が楽に書ける。

RepositoryではException Handlingせず、ViewModelでAsyncValue.guardを使う

Exceptionは伝播するので、RepositoryではHandlingせず、ViewModelでまとめる方針。

class Repository {
  Repository({
    required this.api1,
    required this.api2,
  });

  final Api1 api1;
  final Api2 api2;

  Future<List<Item>> fetchItemList() async {
    final response = await api1.fetchItemList();
    return Success(response.data.map((item) => Item(name: item.name)));
  }
}



class ViewModel extends _$ViewModel {
  
  Future<List<Item>> build() async {
    final repository = ref.watch(repositoryProvider);
    final itemList = await repository.fetchItemList();
    return itemList;
  }

  Future<void> addItem({required Item item}) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      final repository = ref.watch(repositoryProvider);
      final itemList = await repository.addItem(item: item);
      state = itemList;
    });
  }
}

AsyncValue.guardは実質try~catchと同じ。
https://pub.dev/documentation/async_value/latest/async_value/AsyncValue/guard.html

static Future<AsyncValue<T>> guard<T>(Future<T> Function() future) async {
  try {
    return AsyncValue.data(await future());
  } catch (err, stack) {
    return AsyncValue.error(err, stack);
  }
}

fpdartパッケージを使った関数型プログラミング

参考資料

Flutter Exception Handling with try/catch and the Result type
Functional Error Handling with Either and fpdart in Flutter: An Introduction
Handling errors in Flutter

Discussion