はじめに
UI層でtry-catch構文を使うことで、コードの読みづらさを感じたことはありませんか?
何らかの処理に対してエラーハンドリングが必要なのは当然です。
しかし、実行処理と例外処理が同じ場所に混在していると、肝心のロジックが見えにくくなったり、どこから例外処理が始まっているのか分かりづらくなってしまうことがあります。
特にアプリが複雑になってくると、「正常系だけを追いたい」「例外処理だけを把握したい」といったケースも出てきます。
そうしたときに、処理を役割ごとに分けて書くことは、コードの可読性や保守性を高める大きな助けになります。
そこで本記事では、Flutterアプリにおいて、riverpod
の AsyncValue.guard
と ref.listen
を活用することで、実行処理と例外処理を明確に分離する方法を解説していきます。
記事の対象者
- FlutterでRiverpodを使って状態管理をしている人
- 画面側での
try-catch
の多さに悩んでいる人 - 実行処理と例外処理を分離したいと考えている人
- アプリの規模が大きくなってきて、可読性や保守性を改善したいと感じている人
-
AsyncValue.guard
やref.listen
の具体的な活用方法を知りたい人
記事を執筆時点での筆者の環境
[✓] Flutter (Channel stable, 3.27.1, on macOS 15.1 24B2082 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.96.2)
サンプルプロジェクト
画面中央にユーザー作成のボタンがあり、タップすると処理を実行し、結果をスナックバーで表示するシンプルなサンプルです。
- レイヤードアーキテクチャで実装しています
- 依存性の注入にriverpodを採用しています
- riverpodはコード生成を使用しています
参考にしているアーキテクチャーの記事
try-catch構文を使った実行処理と例外処理
まず、一般的な実行処理と例外処理の例を見ていきたいと思います。
data層
まず、大元の処理はdata層にあるUserRepositoryのcreateメソッドを呼び出します。
class UserRepository {
UserRepository(this.ref);
final Ref ref;
/// 新規作成
Future<void> create(User user) async {
try {
await Future<void>.delayed(const Duration(milliseconds: 500));
// 検証のためにわざと例外をthrowする
_throwException();
// ユーザーを作成する処理
logger.d('create user: $user');
} on DuplicateUserNameException catch (e) {
throw DuplicateUserNameException(e.message);
} on ServerErrorException catch (e) {
throw ServerErrorException(e.message);
} on Exception catch (e) {
throw Exception('An unexpected error occurred: $e');
}
}
}
_throwException();
の中ではランダムに3つの exception
をスローするようにしています。
ここは本来はユーザーを作成する様々な処理をしていると読み替えてください。
presentation層のViewModel
data層のメソッドをUI側で呼び出すブリッジ役としてViewModelを経由します。
@riverpod
class ExampleViewModel extends _$ExampleViewModel {
@override
void build() {}
Future<void> createUser(User user) =>
ref.read(userRepositoryProvider).create(user);
Future<void> updateUser() async {
// ...
}
Future<void> deleteUser() async {
// ...
}
}
presentation層の画面で実行処理と例外処理を行う
try-catch構文を使って定義していきます。
ここではボタンをタップしたメソッドの処理のみを抜粋しておきます。
/// ユーザー作成ボタンを押したときの処理
Future<void> _onCreateUserButtonPressed(
BuildContext context,
WidgetRef ref,
User user,
) async {
// 失敗の可能性がある処理を実行
try {
// ユーザー作成処理を実行
await ref.read(exampleViewModelProvider.notifier).createUser(user);
if (!context.mounted) return;
// 成功時の処理
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully')),
);
//エラー発生時の処理 1
} on DuplicateUserNameException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
//エラー発生時の処理 2
} on ServerErrorException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
//エラー発生時の処理 3
} on Exception catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
class ExampleScreen extends ConsumerWidget {
const ExampleScreen({super.key});
// 入力されたユーザーと仮定
static const _user = User(
id: '1',
name: 'test',
email: 'test@example.com',
);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => _onCreateUserButtonPressed(context, ref, _user),
child: const Text('Create User'),
),
),
);
}
}
try-catch構文は try{}
に失敗が想定される処理をかき、失敗した場合を on Exception (e){}
内に書いていきます。
この例題だとボタンをタップした場合に呼び出すViewModelのメソッドが一つだけなこと、想定される例外が少ないのでシンプルです。
ただ、最初に述べたように実行処理と例外処理が一緒に書かれています。
コードリーディングする際に、正常系だけ追いたい場合と例外処理だけ追いたい場合があると思います。
なので、必要な処理は必要な単位で書かれていると個人的には読みやすいと考えています。
また、例えば一つの処理の中で複数のViewModelのメソッドを呼ぶ場合は複数のtry-catch説が出てきてさらに読みづらくなっていきます。
// 複数のViewModelのメソッドを呼ぶ場合
Future<void> _onMultipleActionsPressed(
BuildContext context,
WidgetRef ref,
) async {
// 一つ目の実行処理と例外処理
try {
await ref.read(exampleViewModelProvider.notifier).createUser(/* user */);
} on DuplicateUserNameException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Create error: $e')),
);
return;
} on Exception catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Unexpected error during create: $e')),
);
return;
}
// 二つ目の実行処理と例外処理
try {
await ref.read(exampleViewModelProvider.notifier).updateUser(/* user */);
} on ServerErrorException catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Update error: $e')),
);
return;
} on Exception catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Unexpected error during update: $e')),
);
return;
}
}
この場合、さらに内部の処理をプライベートメソッドで分けるなど工夫はできますが、いずれにせよ読みづらさは変わりません。
個人的には一番の読みづらさの原因は トランザクション {}
が複数あることだとは思っています。
AsyncValue.guardとref.listenの実行処理と例外処理
次に AsyncValue.guard
と ref.listen
を使った例を見ていきます。
この実装は以下の記事を参考にさせていただきました。
data層は以前と変わりません。違うのはdata層のメソッドをUI側で呼び出すブリッジ役です。
まずはそこから見ていきましょう。
use_case層
先の例ではdata層を呼び出す場合はViewModelを経由していました。
今回は呼び出すメソッドを一つだけにしたいので、use_case層を用意しました。
そこに各々のメソッドを呼び出す Executor
クラスを定義します。
ViewModelと同じくriverpodの Notifier
で定義しています。
@riverpod
class CreateUserExecutor extends _$CreateUserExecutor {
@override
Future<void> build() async {}
Future<void> call(User user) async =>
state = await AsyncValue.guard(() async {
await ref.read(userRepositoryProvider).create(user);
});
}
CreateUserExecutor
はriverpodの AutoDisposeAsyncNotifier
です。
つまり状態としては非同期であり、購読者がいなくなったら自動的に破棄される、というクラスです。
そして、実行する処理は一つだけであり、実行内容はクラス名で自明なのでメソッド名は call
としました。
ここで登場するのが AsyncValue.guard
です。
call
の中では単純に自分の状態(state
)に AsyncValue.guard
でラップしたdata層の create(...)
メソッドを代入しています。
ちょっと見慣れない人にはパッとみると戸惑うかもしれません。
意味としてはcreate(...)
メソッドを実行してみて、その結果を状態として返すことをしています。
メソッドが正常終了した場合は state
が AsyncData
として帰ります。逆にエラーだった場合は state
が AsyncError
になります。
AsyncValue.guard
の内部実装を見ると単純で、内部では処理をtry-catchしています。
要はtry-catchのラッパーということです。
// AsyncValue.guardの内部実装
static Future<AsyncValue<T>> guard<T>(
Future<T> Function() future, [
bool Function(Object)? test,
]) async {
try {
return AsyncValue.data(await future());
} catch (err, stack) {
if (test == null) {
return AsyncValue.error(err, stack);
}
if (test(err)) {
return AsyncValue.error(err, stack);
}
Error.throwWithStackTrace(err, stack);
}
}
presentasion層の画面で実行処理を行う
まず、実行処理と例外処理を別々のメソッドとして実装します。
実行処理は以下のように定義します。
/// ユーザー作成ボタンを押したときの処理
Future<void> _onCreateUserButtonPressed(
BuildContext context,
WidgetRef ref,
User user,
) async {
// ユーザー作成処理を実行
await ref.read(createUserExecutorProvider.notifier).call(user);
// stateのエラー状態を取得
final hasError = ref.read(createUserExecutorProvider).hasError;
// エラーの場合は中断
if (hasError || !context.mounted) return;
// 成功時の処理
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully')),
);
}
実行処理はViewModelの代わりに専用のExecutorクラスが実行します。
ちょっと特殊なのはref.read(createUserExecutorProvider).hasError;
で createUserExecutorProvider
がエラー状態ではないかどうかをチェックしている点です。
createUserExecutorProvider
は CreateUserExecutor
の状態です。
ここでもしもエラーだった場合は単純にreturnして処理を中断するだけにしています。
ここが最大の相違点にしてメリットだと感じています。
要はエラーかどうかは確認するけど、エラーだった場合は別で行うので、ここでは正常系の処理だけに集中できるというわけです。
presentasion層の画面で例外処理を行う
次に例外処理についてですが、先ほどもあげた実行の結果の状態は createUserExecutorProvider
を監視すれば状態を追うことができます。
そこで登場するのが状態を監視する ref.listen
です。
ref.listen
は Widgetのビルドメソッド内で実行しますが、直接定義すると煩雑になってしまうのでプライベートメソッドにまとめます。
/// ユーザー作成処理のエラーをハンドリングする
void _createUserExceptionHandler(
BuildContext context,
WidgetRef ref,
) {
// createUserExecutorProviderの状態を監視
ref.listen(
createUserExecutorProvider,
(_, next) {
// ローディング中でエラーが発生していない場合は何もしない
if (next.isLoading && next.hasError == false) return;
// エラーが発生している場合はハンドリングする
if (next.error case final exception? when exception is Exception) {
switch (exception) {
case DuplicateUserNameException():
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Duplicate user name')),
);
case ServerErrorException():
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Server error')),
);
default:
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('An unexpected error occurred')),
);
}
}
},
);
}
少し工夫が必要なのは単純に監視するとローディング中でも反応してしまうので、反応する場合をまずはハンドリングする必要があります。
それが次の if (next.isLoading && next.hasError == false) return;
の部分です。
また、 AsyncValue
の error
の型は Object?
なのでnullを剥がしつつ Exception
として取り出す必要があります。
あとはswitch式で Exception
毎にハンドリングすることができます。
2番目のメリットはハンドリングをswitch式でかけることにあります。
個人的には先にも述べた on Exception (e){}
を繋げていくよりも可読性が高いと考えています。
画面の全体像
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
// 入力されたユーザーと仮定
static const _user = User(
id: '1',
name: 'test',
email: 'test@example.com',
);
@override
Widget build(BuildContext context, WidgetRef ref) {
// ユーザー作成処理のエラーを監視
_createUserExceptionHandler(context, ref);
return Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => _onCreateUserButtonPressed(context, ref, _user),
child: const Text('Create User'),
),
),
);
}
}
AsyncValue.guardとref.listenでの実装のメリデメ
ここまでの内容をまとめてこの書き方のメリットをまとめると以下になると考えています。
【メリット】
- 実行処理と例外処理を別々に書ける
- 実行処理は本来の正常系に集中できる
- 例外処理はcatch説特有のトランザクションの連鎖ではなく、switch式でスマートに書ける
【デメリット】
ではデメリットはなんでしょうか。私としては以下と考えています。
- riverpodに完全依存
- 学習コスト
- 例外処理の実装忘れ
- コード量の増加
1. riverpodに完全依存
当然ですが、今回の実装はriverpodの機能をフルに活用しています。
まずはチームでその合意を取る必要があるでしょう。
また、万が一riverpodの開発、メンテナンスが終了した場合は自分たちでメンテするか別の手段に移行する必要があります。
2. 学習コスト
先にも述べたriverpodに完全依存にもかかってきますが、何はともあれriverpodの知識が必要になってきます。
また、一般的には実行処理と例外処理はtry-catch構文で書くのが王道であるのは変わりません。
よってこの使い方を理解する必要があります。
書いていて私自身も思うのですが、決して単純な作りではないとは思います😅
3. 例外処理の実装忘れ
try-catch構文と違って実行と例外の処理を別々に定義しているので、例外処理をビルドメソッドに書き忘れる場合がありそうです。そこはテストで担保できるかと考えていますが、いずれにせよ考慮事項は増えることに変わりはありません。
4. コード量の増加
プライベートメソッドとして二つに分けたことも相まって、コード量は増えています。
try-catch構文での実装は32行に対して、AsyncValue
の場合は52行です。
終わりに
本記事では、riverpodを使ったアプリ開発における、実行処理と例外処理の分離について紹介しました。
UI層でのtry-catch
構文は簡便である一方で、複数の処理が絡むと複雑になり、可読性や保守性を損ねる原因になります。
それに対してAsyncValue.guard
とref.listen
を組み合わせたアプローチでは、正常系のロジックと例外処理を明確に分離でき、特に大規模な画面や処理の多い画面において真価を発揮します。
もちろん、導入には学習コストや設計ルールの統一といった課題も伴いますが、「読みやすく、安全なUIコードを書く」という観点では非常に有効な選択肢だと私は感じています。
この記事が、Flutterアプリケーションの設計やエラーハンドリングの方針を見直すきっかけになれば幸いです。