🕌
[Flutter] Exception Handling方法の検討: try~catch, Result Type, AsyncValue
背景
以下のような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のように)
/// 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と同じ。
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