はじめに
Flutterの設計を学んでいて、調べたところ、素晴らしい設計に関する記事がたくさんありました。
ただ、エラーハンドリングについても書かれている記事は見つかりませんでした。
Flutterを使ってアプリを開発していて、エラーハンドリングも考慮すると結構コードが複雑になることがあります。
どうやったらスッキリ書けるかなと考えてみたので、コード例を記事にしようかと思います。
こうやって書いてるとか、もっといい方法あれば共有していただきたいです!
前提
まず例にするアプリは、サーバーと通信を行い、データの取得や追加、更新、削除などを行うことを想定しています。
アプリ側ではAPIを使ってサーバーと通信を行い、取得したデータを表示したり、エラーがあったらそれを表示したりというような役割を前提とします。
あと、アーキテクチャに関しては、以下を参考にしています。
業務では、MVVMやクリーンアーキテクチャを採用しているのですが、クライアント側のアプリとして開発をするという前提なら、無駄にコードが多くなる感じがしています。
例えば、画面ごとにViewModelとページの状態(ローディングやエラーなど)を定義していると、以下のような点が使いにくかったです。
- 単純なデータを取得して表示するという画面なのに、ViewModelとページの状態の定義が必要
- 別々の画面で同じAPIを使う場合でも、ViewModelとページの状態の定義が別なので、同じような処理を書かなければいけない
上記の理由から、クライアント側のアプリとしては、MVVMやクリーンアーキテクチャを踏襲するよりも、Reactを参考にしつつ、Riverpodの使い方を考えていくのが良いのではないかと考えています。
ロジックをアプリ側に持たせる場合のアーキテクチャについては下記の記事で書いたので参考になれば幸いです。
実装する機能
実装する機能は以下の二つです。
- お知らせ一覧を取得して表示する
- タスク一覧を取得して表示する
サンプルとしてのアプリなので、それぞれの機能に特に意味はないです。
タスク一覧に関しては、簡単なフィルタリングも実装します。
コード例
下記の部分に分け、コード例を書いていきます。
- API部分
- UI部分
API部分
サンプルなので、APIを使用する実装を模擬したものですが、API部分はFutureProviderを使って実装します。
// エラー状態かどうかを制御するためのprovider
final notificationsErrorProvider = StateProvider((ref) => true);
// お知らせ一覧のFutureProvider
final notificationsProvider = FutureProvider<List<String>>(
(ref) async {
// 実際のアプリではこの部分にAPIを使ったデータ取得処理を実装する
// サンプルなので、APIを模擬した実装をしている
await Future.delayed(
const Duration(
seconds: 1,
),
);
if (ref.watch(notificationsErrorProvider)) {
throw Exception("notifications error");
}
return [
"notification1",
"notification2",
];
},
);
// タスク一覧取得のエラーを擬似的に発生させるためのprovider
final tasksErrorProvider = StateProvider((ref) => true);
// GetTasksRequestParamは、タスク一覧を取得するAPIのクエリパラメータを模したもの
// 定義内容についてはこの後に記載。
final tasksProvider = FutureProvider.family<List<Task>, GetTasksRequestParam?>(
(ref, arg) async {
await Future.delayed(
const Duration(
seconds: 1,
),
);
if (ref.watch(tasksErrorProvider)) {
throw Exception("tasks error");
}
if (arg == null) {
return taskDataList;
}
return taskDataList
.where((element) => element.deadLine.isAfter(arg.deadLine))
.toList();
},
);
// タスクのデータクラス
class Task {
final id;
final String name;
final DateTime deadLine;
Task(this.id, this.name, this.deadLine);
}
// タスク一覧のソースデータ
// 実際のアプリだと、バックエンドのDBに入っている想定
final taskDataList = [
Task("0001", "1", DateTime(2023, 9, 1)),
Task("0002", "2", DateTime(2023, 9, 2)),
Task("0003", "3", DateTime(2023, 9, 3)),
Task("0004", "4", DateTime(2023, 9, 4)),
Task("0005", "5", DateTime(2023, 9, 5)),
Task("0006", "6", DateTime(2023, 9, 6)),
];
タスク一覧はフィルタリングできるように、.familyを使ってます。
.familyを使う際の注意点
.familyの引数には以下の二種類を渡すのが推奨されています。
- プリミティブ型(bool/int/double/String)
- 「==」「hashCode」を継承したimmutableオブジェクト
上記の理由から、フィルタリングのパラメータの定義は下記のようにfreezedを使ってます。
@freezed
class GetTasksRequestParam with _$GetTasksRequestParam {
const GetTasksRequestParam._();
@JsonSerializable(fieldRename: FieldRename.snake)
const factory GetTasksRequestParam({
required String name,
@JsonKey(fromJson: fromDateTimeJson, toJson: toDateTimeJson)
required DateTime deadLine,
}) = _GetTasksRequestParam;
factory GetTasksRequestParam.fromJson(Map<String, Object?> json) =>
_$GetTasksRequestParamFromJson(json);
}
String toDateTimeJson(DateTime date) {
return return "${date.year}-${date.month}-${date.day}";
}
DateTime fromDateTimeJson(String date) {
final array = date.split('-');
final year = int.parse(array[0]);
final month = int.parse(array[1]);
final day = int.parse(array[2]);
return DateTime(
year,
month,
day,
);
}
FutureProviederのキャッシュについて
FutureProviderは一度実行したらキャッシュされるので、2回目の参照はキャッシュしたデータがリターンされます。
再読み込みしたい場合はref.invalidateを使い、無効化する必要があります。
その他には、FutureProvider.autoDispose>のようにautoDisposeを使うと参照がなくなったら破棄されます。
特定の画面のみで使用するAPIを使う場合はautoDisposeをつけておくのがいいかもしれません。
逆に、一回取得したものを使い回す場合はautoDisposeをつけずキャッシュしたものを取得するのが効率的です。
また、一定間隔で再取得したいという要件の場合は以下の記事のようにタイマーを使えば実現できそうです。
この辺りの話もユースケースごとに記事にしてみたいと思います。
FutureProviderとinvalidateの機能を整理してみた記事は過去に書いてますので興味ある方はご覧ください。
UI部分
UI部分の実装は、デザインの要求に合わせて変わると思います。
一応、二つのユースケースでコードを書いてみました。
- 画面全体でローディングとエラーをハンドリングする
- APIごとにローディングとエラーをハンドリングする
それぞれ説明します。
<画面全体でローディングとエラーをハンドリングする>
ある画面でAとBのAPIからデータを取得して表示すると仮定して、
AとBのAPIを実行中はローディング表示とし、AとBのAPIが成功したらデータを表示するというパターンです。
AとBのAPIを実行してどちらかがエラーになった場合は、エラーダイアログを表示することにします。
<APIごとにローディングとエラーをハンドリングする>
上記と同じくAとBのAPIからデータを取得して表示すると仮定します。
異なる点は、AとBそれぞれの表示領域でローディングとエラーをハンドリングするということです。
どういうことかというと、AのAPI実行中は、Aを表示する部分をローディング表示、BのAPI実行中はBを表示する部分をローディング表示にします。そして、AのAPIが終わればAの表示部分にAを表示、BのAPIが終わればBの表示部分にBを表示します。エラーも同じく、AのAPIがエラーならAの表示部分にAのエラーを表示、BのAPIがエラーならBの表示部分にBのエラーを表示します。
AとBで非同期かつ別々の表示となります。
画面全体でローディングとエラーをハンドリングする
ある画面でAとBのAPIからデータを取得して表示すると仮定して、
AとBのAPIを実行中はローディング表示とし、AとBのAPIが成功したらデータを表示するというパターンです。
AとBのAPIを実行してどちらかがエラーになった場合は、エラーダイアログを表示することにします。
またAとBのAPIの両方がエラーになった場合は、早かった方のエラーを表示し、遅かった方は無視します。
この辺は要件によって変わってくると思います。
表示画面のコード
class ErrorHandlingPage extends HookConsumerWidget {
const ErrorHandlingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// タスク一覧取得時のクエリパラメータ
final param = useState<GetTasksRequestParam?>(null);
// お知らせ一覧を取得
final notifications = ref.watch(notificationsProvider);
// タスク一覧を取得
final tasks = ref.watch(tasksProvider(param.value));
// FutureProviderの状態を監視し、変更時エラーがあればダイアログを表示する
final listener = FutureProviderListener();
listener.listen(
futureProviders: [
notificationsProvider,
tasksProvider(param.value),
],
context: context,
ref: ref,
);
final isLoading = notifications.isLoading || tasks.isLoading;
return Scaffold(
appBar: AppBar(
title: const Text('error handling page'),
),
body: SafeArea(
child: Center(
child: isLoading
? const CircularProgressIndicator()
: Column(
children: [
// お知らせ一覧取得をエラー状態を切り替えるボタン
ElevatedButton(
onPressed: () {
ref.read(notificationsErrorProvider.notifier).state =
!ref.read(notificationsErrorProvider);
},
child: const Text("お知らせ一覧のエラー状態切り替え"),
),
// お知らせ一覧再読み込み
ElevatedButton(
onPressed: () {
ref.invalidate(notificationsProvider);
},
child: const Text("お知らせ一覧の再読み込み"),
),
// タスク一覧取得のエラー状態を切り替えるボタン
ElevatedButton(
onPressed: () {
ref.read(tasksErrorProvider.notifier).state =
!ref.read(tasksErrorProvider);
},
child: const Text("タスク一覧のエラー状態切り替え"),
),
// タスク一覧再読み込み
ElevatedButton(
onPressed: () {
ref.invalidate(tasksProvider);
},
child: const Text("タスク一覧の再読み込み"),
),
// 日付選択ボタン
ElevatedButton(
onPressed: () async {
final selectedDateTime = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(3000),
);
if (selectedDateTime == null) {
param.value = null;
return;
}
param.value = GetTasksRequestParam(
name: "",
deadLine: selectedDateTime,
);
},
child: Text(
param.value?.deadLine.toDate() ?? "期限日を選択",
),
),
// お知らせ一覧を表示
notifications.hasValue
? ListView.builder(
shrinkWrap: true,
itemCount: notifications.value!.length,
itemBuilder: (context, index) {
return Text(
notifications.value![index],
style: const TextStyle(
fontSize: 20,
),
);
},
)
: Container(),
const SizedBox(
height: 16,
),
// タスク一覧を表示
tasks.hasValue
? ListView.builder(
shrinkWrap: true,
itemCount: tasks.value!.length,
itemBuilder: (context, index) {
return Text(
tasks.value![index].name,
style: const TextStyle(
fontSize: 20,
),
);
})
: Container(),
],
),
),
),
);
}
}
タスク一覧取得時のクエリパラメータを指定するため、HookConsumerWidget
としています。
エラーハンドリング部分は後述しますが、FutureProviderListener
というクラスを作成し、そこでエラーハンドリングを実行しています。
タスク一覧取得とお知らせ一覧取得はエラーになるように初期値を設定しています。
表示すると、タスク一覧取得の方を早く終わるようにしているので、タスク一覧のエラーのみが表示され、お知らせ一覧取得のエラーは表示されません。(Retryは何も指定してないので、何も起こらないです)
それぞれのエラー状態を切り替えると、正常に読み込まれます。
エラーハンドリング部分
エラーハンドリング部分のコードは下記です。
// エラーダイアログの表示は一つだけにしたいのでグローバルで変数を定義して制御
bool showingErrorDialog = false;
class FutureProviderListener {
void listen({
required List<FutureProvider> futureProviders,
required BuildContext context,
required WidgetRef ref,
VoidCallback? retryOnError,
}) {
for (var provider in futureProviders) {
ref.listen(provider, (previous, next) {
handleError(
previous: previous,
next: next,
context: context,
ref: ref,
);
});
}
}
void handleError({
required AsyncValue? previous,
required AsyncValue next,
required BuildContext context,
required WidgetRef ref,
VoidCallback? onRetry,
}) {
if (previous == next) {
return;
}
if (next.isLoading) {
return;
}
if (showingErrorDialog) {
return;
}
// hasValidationExceptionはAsyncValueのExtension(詳細は後述)
// バリデーションの例外かどうかを判定
if (next.hasValidationException() && !next.isLoading) {
showingErrorDialog = true;
showAdaptiveDialog(
context: context,
builder: (dialogContext) {
return AlertDialog.adaptive(
title: const Text("ValidationError"),
content: Text(next.error.toString()),
actions: [
TextButton(
child: const Text("OK"),
onPressed: () {
showingErrorDialog = false;
Navigator.of(dialogContext).pop();
},
),
],
);
},
);
return;
}
// エラーかどうかを判定
if (next.hasError && !next.isLoading) {
showingErrorDialog = true;
showAdaptiveDialog(
context: context,
builder: (dialogContext) {
return AlertDialog.adaptive(
title: const Text("Error"),
content: Text(next.error.toString()),
actions: [
TextButton(
child: const Text("Retry"),
onPressed: () {
showingErrorDialog = false;
onRetry?.call();
Navigator.of(dialogContext).pop();
},
),
],
);
},
);
}
}
}
class ValidationException implements Exception {
final String message;
ValidationException({
required this.message,
});
}
extension AsyncValueExtension on AsyncValue {
bool hasValidationException() {
if (!hasError) {
return false;
}
return error is ValidationException;
}
ValidationException? validationException() {
if (hasValidationException()) {
return error as ValidationException;
}
return null;
}
}
ざっくり説明すると、FutureProvider
をlisten
し、エラーがあればダイアログを表示しているだけです。
本記事の例では、APIの実行はFutureProviderを使うのでFutureProviderを扱ってますが、StreamProviderを使いたいというケースでは、StreamProviderListenerというクラスを別途定義するのかなと思ってます。
エラーの判定なのですが以下の二つで判定しています。
- next.hasValidationException()
- next.hasError
next.hasValidationException()はAsyncValueのExtensionとして定義していて、AsyncValueのerrorが独自で定義したValidationException
であればtrueとなります。
こちらのエラーの定義も、プロジェクトの要件に合わせて増えていく想定です。
APIのエラーとして、UnauthorizedErrorやUnexpectedErrorなども返ってくるとするなら、それに合わせてエラークラスとAsyncValueのExtensionの定義を追加するイメージです。
APIごとにローディングとエラーをハンドリングする
ある画面でAとBのAPIからデータを取得して表示すると仮定して、
AのAPIの実行状態(成功/ローディング/エラー)とBのAPIの実行状態をそれぞれで描画するイメージです。
APIの実行状態をWidgetごとに管理するのでスッキリコードが書けて個人的にはこちらが好きです。
class ErrorHandlingPage extends HookConsumerWidget {
const ErrorHandlingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// タスク一覧取得時のクエリパラメータ
final param = useState<GetTasksRequestParam?>(null);
return Scaffold(
appBar: AppBar(
title: const Text('error handling page'),
),
body: SafeArea(
child: Center(
child: Column(
children: [
// お知らせ一覧取得をエラー状態を切り替えるボタン
ElevatedButton(
onPressed: () {
ref.read(notificationsErrorProvider.notifier).state =
!ref.read(notificationsErrorProvider);
},
child: const Text("お知らせ一覧のエラー状態切り替え"),
),
// お知らせ一覧再読み込み
ElevatedButton(
onPressed: () {
ref.invalidate(notificationsProvider);
},
child: const Text("お知らせ一覧の再読み込み"),
),
// タスク一覧取得のエラー状態を切り替えるボタン
ElevatedButton(
onPressed: () {
ref.read(tasksErrorProvider.notifier).state =
!ref.read(tasksErrorProvider);
},
child: const Text("タスク一覧のエラー状態切り替え"),
),
// タスク一覧再読み込み
ElevatedButton(
onPressed: () {
ref.invalidate(tasksProvider);
},
child: const Text("タスク一覧の再読み込み"),
),
// 日付選択ボタン
ElevatedButton(
onPressed: () async {
final selectedDateTime = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(3000),
);
if (selectedDateTime == null) {
param.value = null;
return;
}
param.value = GetTasksRequestParam(
name: "",
deadLine: selectedDateTime,
);
},
child: Text(
param.value?.deadLine.toDate() ?? "期限日を選択",
),
),
// お知らせ一覧を表示
const NotificationListView(),
const SizedBox(
height: 16,
),
// タスク一覧を表示
TaskListView(
param: param.value,
),
],
),
),
),
);
}
}
画面のコードはほとんど変わってないですが、
ErrorHandlingPageではお知らせ一覧とタスク一覧のFutureProviderを監視してない点が異なります。
それぞれの監視は、NotificationListViewとTaskListViewで行っています。
class NotificationListView extends ConsumerWidget {
const NotificationListView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// お知らせ一覧を取得
final notifications = ref.watch(notificationsProvider);
if (notifications.isLoading) {
return const CircularProgressIndicator();
}
if (notifications.hasError) {
return Column(
children: [
Text(
notifications.error.toString(),
),
IconButton(
onPressed: () {
ref.invalidate(notificationsProvider);
},
icon: const Icon(
Icons.refresh,
),
),
],
);
}
final notificationList = notifications.value ?? [];
return ListView.builder(
shrinkWrap: true,
itemCount: notificationList.length,
itemBuilder: (context, index) {
return Text(
notificationList[index],
style: const TextStyle(
fontSize: 20,
),
);
},
);
}
}
class TaskListView extends ConsumerWidget {
final GetTasksRequestParam? param;
const TaskListView({super.key, required this.param});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(tasksProvider(param));
if (tasks.isLoading) {
return const CircularProgressIndicator();
}
if (tasks.hasError) {
return Column(
children: [
Text(
tasks.error.toString(),
),
IconButton(
onPressed: () {
ref.invalidate(tasksProvider);
},
icon: const Icon(
Icons.refresh,
),
),
],
);
}
final taskList = tasks.value ?? [];
return ListView.builder(
shrinkWrap: true,
itemCount: taskList.length,
itemBuilder: (context, index) {
return Text(
taskList[index].name,
style: const TextStyle(
fontSize: 20,
),
);
},
);
}
}
これは余談なのですが、
FutureProviderを扱う際、よくwhenで各状態毎にWidgetを返す実装例を見かけますが、こうした書き方だと、ref.invalidate(tasksProvider);
を実行してもローディング表示になりませんでした。
final tasks = ref.watch(tasksProvider(param));
return tasks.when(
data: (taskList) {
return ListView.builder(
shrinkWrap: true,
itemCount: taskList.length,
itemBuilder: (context, index) {
return Text(
taskList[index].name,
style: const TextStyle(
fontSize: 20,
),
);
},
);
},
error: (e, _) {
return Column(
children: [
Text(
e.toString(),
),
IconButton(
onPressed: () {
ref.invalidate(tasksProvider);
},
icon: const Icon(
Icons.refresh,
),
),
],
);
},
loading: () => const CircularProgressIndicator(),
);
終わりに
ダラダラと長くなり、コードの記述も多いので読みにくく申し訳ありません。
また別の形式にした方が良いのかもと思いつつ、一旦投稿しました。
わかりにくい箇所や、こう書いている、こう書いた方がいいのでは?とか色々意見お聞きしたいです。