Riverpod v2のAsyncValueを理解する
CHANGELOG
- 2022.11.23
-
v2.1.0
に追従し関連箇所を修正 -
copyWithPrevious
の対象となる Provider を明記 - copyWithPreviousの挙動を細かくハンドリングする セクションを追加
-
- 2022.11.06
-
v2.1.0
のリリースに伴いv2.0.x
を元に執筆している記事である旨を明記
-
- 2022.10.30
- 9月末の v2.0.0安定版提供に伴い関連箇所を一部修正
- とくに
.future
.stream
の削除による関連セクションの削除 -
Provider<AsyncValue<T>>
を使わずFutureProvider
StreamProvider
をそのまま返却する書き方に変更
はじめに
Riverpod、非常に便利で強力な状態管理パッケージですね。
私が最初に触り始めたのは約2年前頃の v0.6.0
ですが、そこから v1のリリース・v2のプレリリースと着々と進化を遂げているのが分かります。
そんな有用パッケージですが、ドキュメント 自体は用意されているものの(数ヶ月前日本語訳も提供 されましたね👏)、その内容は Provider の種類や使い分けや修飾詞に留まっており、少しまだ物足りなさを感じます。Flutter のドキュメントが手厚すぎるがゆえに相対的にそう見えてしまっているだけかもしれません。
今回は、上記のドキュメントにはそこまで手厚く記載されてはいないものの、Riverpod を扱う上で重要な AsyncValue
について触れていきます。
AsyncValue
は FutureProvider
/StreamProvider
/AsyncNotifierProvider
を扱う際に欠かせないクラスで、Riverpod の作者もこれら Provider には長期的に大きな計画を立てていると言及しており、益々利用機会は増えてくるのだと思います。
And to be fair, I have a more long-term vision of Riverpod.
I have big plans for FutureProvider, while StateNotifierProvider is going to become less useful over time.
https://twitter.com/remi_rousselet/status/1537563116220952578
対象の読者
- Riverpod のシンタックスや Provider の種類など基本的なことがかる方
- StateNotifierProvider で MVVM 的に実装してきたが機能単位で切り離したいと考えている方
- AsyncValue の使い方や振る舞いの理解に自信がない方
AsyncValueクラス
改めて、AsyncValue は Future や Stream などの非同期処理で発生する loading/error 状態を簡単にハンドリングできるクラスです。
ソースコード上は common.dart に集約されています。
コードの通り、AsyncValue は sealed クラスとなっており、data
・loading
・error
の3つが factory で定義されています。
const factory AsyncValue.data(T value) = AsyncData<T>;
const factory AsyncValue.loading() = AsyncLoading<T>;
const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) = AsyncError<T>;
他にも、try~catch
を省く guard()
メソッドや、isLoading
プロパティなどが用意されています。
直前の状態を維持するcopyWithPreviousメソッド
AsyncValue は3つの状態を持ちますが、以下の Provider のみ状態が変化する際にデフォルトで直前の値を維持する性質があります。いずれも非同期処理を扱う Provider です。
- FutureProvider
- StreamProvider
- AsyncNotifeirProvider
元々、StateNotifierProvider
も copyWithPrevious
の性質を持っていたのですが、v2から上記3つに限定されました。基本的にこの性質は、非同期処理におけるユーザーインタフェースを向上させる点に効果を発揮するため、その役割を明確にしたのだと思います。
FutureProvider, StreamProvider and AsyncNotifierProvider now preserve the previous data/error when going back to loading. This is done by allowing AsyncLoading to optionally contain a value/error.
https://pub.dev/packages/riverpod/changelog#210
たとえば、初回でのデータ取得が成功した後、一時的なサーバエラーなどでその後の取得に失敗した場合には AsyncError
が返ってきますが、初回で取得したデータはそのまま保持しています。これによりユーザーはエラーが発生してしまった場合でも、直前のコンテンツはそのまま閲覧でき、利用体験を維持できます(エラー内容を全面的にユーザーに伝えることもでき、開発者側で自由にハンドリングが可能です)。
これを実現しているのが copyWithPrevious
メソッドで、各継承先クラスで override されているのが分かります。
詳細はソースコードを見ると良いと思いますが、まとめると以下の表となります。
注意なのは、AsyncLoading
クラスは初回読み込み時にしか返却されないという点です。
クラス | 挙動 |
---|---|
AsyncData |
前の状態に関係なく最新の AsyncData で上書き |
AsyncLoading |
前の状態に isLoading: true に変えて返却。初回読み込み時のみ AsyncLoading クラスが返る。 |
AsyncError |
最新のエラー情報は返しつつ前の状態にデータがあれば維持 |
こちらにソースコードを抜粋して記載しました。
copyWithPrevious の該当ソースコード
class AsyncData<T> extends AsyncValue<T> {
// 前の状態(previous)に関係なく問答無用でAsyncDataで上書きしていることが分かります。
// 正常にデータ取得ができた場合は直前のエラー情報などは不要という点で納得です。
AsyncData<T> copyWithPrevious(AsyncValue<T> previous) {
return this;
}
}
class AsyncLoading<T> extends AsyncValue<T> {
// AsyncLoadingは前の状態に`isLoading: true`を付与して返却していることがわかります。
// 前の状態が成功している場合は`AsyncData(isLoading: true)`
// 前の状態が失敗している場合は`AsyncError(isLoading: true)`
// 初回読み込み時のみAsyncLoadingを返します。
AsyncValue<T> copyWithPrevious(AsyncValue<T> previous) {
return previous.map(
data: (d) => AsyncData._(
d.value,
isLoading: true,
error: d.error,
stackTrace: d.stackTrace,
),
error: (e) => AsyncError._(
e.error,
isLoading: true,
value: e.valueOrNull,
stackTrace: e.stackTrace,
hasValue: e.hasValue,
),
loading: (_) => this,
);
}
}
class AsyncError<T> extends AsyncValue<T> {
// 最新のエラー内容やスタックトレースは返しつつ、
// 前の値の存在有無やそのデータがある場合は保持していることがわかります。
// これによりAsyncErrorでもpreviousのデータを表示することが可能になっています。
AsyncError<T> copyWithPrevious(AsyncValue<T> previous) {
return AsyncError._(
error,
stackTrace: stackTrace,
isLoading: isLoading,
value: previous.valueOrNull,
hasValue: previous.hasValue,
);
}
}
isRefreshing
2.0.0-dev.0 から導入されている機能で、初回表示後のデータ更新の際に、読み込み中でも前のデータやエラーを UI としてそのまま表示できます。
After a provider has emitted an AsyncValue.data or AsyncValue.error, that provider will no longer emit an AsyncValue.loading.Instead, it will re-emit the latest value, but with the property AsyncValue.isRefreshing to true.
実装は単純で、AsyncData
や AsyncError
の「value
や error
が存在する状態」から再読み込みした時に true
と評価されることが分かります。
bool get isRefreshing {
return isLoading && (hasValue || hasError);
}
Riverpod のリポジトリに isRefreshing
のテスト があるので、これを元に挙動を理解できます。型判定の部分のみ追加してあります。
// ref. https://github.com/rrousselGit/riverpod/blob/2ed9fa0091ff1d0c3d0d37d89a3d5de06c8105d7/packages/riverpod/test/framework/async_value_test.dart#L76
test('isRefreshing', () {
// AsyncLoading
expect(const AsyncLoading<int>().isRefreshing, false);
final previousIsLoading = const AsyncLoading<int>().copyWithPrevious(
const AsyncLoading(),
);
expect(previousIsLoading.isRefreshing, false);
expect(previousIsLoading, isA<AsyncLoading<int>>());
// AsyncData
expect(const AsyncData<int>(42).isRefreshing, false);
final previousIsData = const AsyncLoading<int>().copyWithPrevious(
const AsyncData<int>(42),
);
expect(previousIsData.isRefreshing, true);
expect(previousIsData, isA<AsyncData<int>>());
// AsyncError
expect(const AsyncError<int>('err').isRefreshing, false);
final previousIsError = const AsyncLoading<int>().copyWithPrevious(
const AsyncError<int>('err'),
);
expect(previousIsError.isRefreshing, true);
expect(previousIsError, isA<AsyncError<int>>());
});
具体例
よくある一般的な API でのデータ取得の動きとして、「初回のデータ取得後にリフレッシュした場合の挙動」を以下の図にまとめました。
要点は「(3)リフレッシュ」時に AsyncLoading ではなく AsyncData(isLoading: true)となっている点です。前述の copyWithPrevious
でも述べましたが、AsyncLoading の copyWithPrevious
は「前の状態に isLoading: true
として返却」の挙動となるため、こういったケースで isRefereshing: true
となります。AsyncError
からリフレッシュする時もほぼ一緒の挙動となります。
test('初回のデータ取得後にリフレッシュした場合', () {
// ①初回読み込み
const loading = AsyncLoading<int>();
expect(loading.isLoading, isTrue);
expect(loading.isRefreshing, isFalse, reason: 'previousがないのでfalse');
expect(loading, isA<AsyncLoading<int>>());
// ②データ取得成功
final initialValue = const AsyncData(1).copyWithPrevious(loading);
expect(initialValue.value, 1);
expect(initialValue.hasValue, isTrue);
expect(initialValue.isLoading, isFalse);
expect(initialValue.isRefreshing, isFalse);
expect(initialValue, isA<AsyncData<int>>());
// ③リフレッシュ(Pull-to-Refreshなどで更新)
final refreshing = const AsyncLoading<int>().copyWithPrevious(initialValue);
expect(refreshing.hasValue, isTrue, reason: 'initialValueの1があるのでtrue');
expect(refreshing.isLoading, isTrue);
expect(refreshing.isRefreshing, isTrue, reason: 'hasValueがtrueなのでtrue');
expect(refreshing, isA<AsyncData<int>>(), reason: 'previousがAsyncDataのため');
// ④更新完了
final value = const AsyncData<int>(2).copyWithPrevious(refreshing);
expect(value.value, 2);
expect(value.hasValue, isTrue);
expect(value.isLoading, isFalse);
expect(value.isRefreshing, isFalse);
expect(value, isA<AsyncData<int>>());
});
これによってユーザー目線では、(2)で取得した値が表示されたまま、裏で(3)のリフレッシュ処理が行われ、(4)の取得完了と同時にパキッと値が切り替わる形になります。
逆に、リフレッシュと初回読み込みの UI を統一したい(ex. 読込中は常にインジケータを表示させたい)といった場合には、AsyncData
,AsyncError
でも isLoading: true
となるので、普通に isLoading
判定だけすれば良いですね。
値の取り出しと便利なシンタックス
AsyncValue
には他にもさまざまなメソッドや拡張が用意されています。前述した isRefereshing
も extension で用意されている getter の1つです。テストコードが async_value_test.dart
に書かれているのでこちらを動かしてみるのもオススメです。今回はその中で、個人的にとくによく使うものをピックアップして記載します。
when・whenOrNull・mayBeWhen
もっとも馴染みのある extension が when
だと思います。他の記事でもたくさん言及されておりますが、data/loading/error の3状態に応じて表示を切り替えることができます。
.when
の特徴
-
AsyncValue
を取り出す際のもっともスタンダード(主観)な書き方 - 3状態に応じて表示を切り替えることができる
- 丁寧なハンドリングが不要な場面ではやや冗長となる側面がある
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(someValueProvider).when(
loading: CircularProgressIndicator.new,
error: (error, stacktrace) => Text(error.toString()),
data: (value) {
// AsyncDataの値
return Text('$value');
},
);
}
前述の copyWithPrevious
の通り、2回目以降の読み込みは AsyncLoading
クラスにはならないので、上記の実装ではインジケータは表示されません。初回・2回目以降関係なくインジケータを表示したい場合は、isRefreshing
(isLoading
でも同様)の分岐を追加してあげる必要がありますね。
ついでに whenOrNull
と mayBeWhen
も紹介します。名前からも分かりますが、when
を基準にユースケースによって取り扱いしやすくした extension となっています。
.mayBeWhen
の特徴
- ハンドリングしたい状態のみの実装で済む
- 他の状態は
orElse()
でまとめることができる
error 状態をとくに考慮する必要ない場面などで利用できますが、後述の .value
で済むケースが多いのであまり出番はありません。
ref.watch(someValueProvider).maybeWhen(
// loading/error時にはインジケータが表示される
orElse: CircularProgressIndicator.new,
data: (value) {
return Text('$value');
},
);
.whenOrNull
の特徴
-
.mayBeWhen
のorElse()
の代わりにnull
を返す
ref.watch(someValueProvider).maybeWhen(
// loading/error時にはnull
data: (value) {
return Text('$value');
},
);
whenData
whenData
は、AsyncData
を加工した後に AsyncValue
を返却する動きをします。
そのため、上記の when
系のメソッドとは異なり build メソッドで Widgetをそのまま返却できず、Provider 内で AsyncData を加工する用途で用意されているのだと思います。
.whenData
の特徴
- 必要に応じて
AsyncData
のみ加工したい場合に利用 - 加工処理で exception を throw した場合は
AsyncError
となって返却される -
AsyncLoading
やAsyncError
はwhenData
の加工後の型で返却される- 型が変わらない場合は見た目上そのまま返却されるように見える
whenData の該当ソースコード
/// Shorthand for [when] to handle only the `data` case.
///
/// For loading/error cases, creates a new [AsyncValue] with the corresponding
/// generic type while preserving the error/stacktrace.
AsyncValue<R> whenData<R>(R Function(T value) cb) {
return map(
data: (d) {
try {
return AsyncData._(
cb(d.value),
isLoading: d.isLoading,
error: d.error,
stackTrace: d.stackTrace,
);
} catch (err, stack) {
return AsyncError._(
err,
stackTrace: stack,
isLoading: d.isLoading,
value: null,
hasValue: false,
);
}
},
error: (e) => AsyncError._(
e.error,
stackTrace: e.stackTrace,
isLoading: e.isLoading,
value: null,
hasValue: false,
),
loading: (l) => AsyncLoading<R>(),
);
}
whenData の挙動のテストコード
test('transforms data if any', () {
expect(
const AsyncValue.data(42).whenData((value) => '$value'),
const AsyncData<String>('42'),
);
expect(
const AsyncLoading<int>().whenData((value) => '$value'),
const AsyncLoading<String>(),
);
expect(
const AsyncError<int>(21).whenData((value) => '$value'),
const AsyncError<String>(21),
);
});
test('catches errors in data transformer and return AsyncError', () {
expect(
const AsyncValue.data(42).whenData<int>(
// ignore: only_throw_errors
(value) => throw '42',
),
isA<AsyncError<int>>()
.having((e) => e.error, 'error', '42')
.having((e) => e.stackTrace, 'stackTrace', isNotNull),
);
});
whenData
を使って値の加工・型の変更する Provider の例が以下になります。最終的に Widget側で取り扱いやすいように加工する Provider を挟むと、UI 側にロジックが介在せずスッキリしますね。
final countProvider = StreamProvider<int>((ref) => Stream.value(42));
// v2.0.0-dev.9 以前
// `whenData`を使って`Provider<AsyncValue<T>>`に変換して返す書き方をしていた
final doubleCountLabelProvider = Provider<AsyncValue<String>>((ref) {
return ref.watch(countProvider).whenData((count) => '${count * 2}');
});
// v2.0.0 以降
// `.future`,`.stream`が廃止されたことで`Provider<AsyncValue<T>>`が
// StreamProvider/FutureProviderの劣化版になってしまい利用価値が無くなったためそのまま変換する
final doubleCountLabelProvider = StreamProvider<String>((ref) {
return ref.watch(countProvider.stream).map((count) => '${count * 2}');
});
// 省略...
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(doubleCountLabelProvider).when(
data: Text.new, // -> 84
error: (error, _) => Text(error.toString()),
loading: CircularProgressIndicator.new,
);
}
value・valueOrNull
前述の when
系メソッドは状態に応じて丁寧に表示の切り分けができる一方で、そこまで丁寧に分岐が必要ではないケースもあります。たとえば Firestore のリアルタイムリスナーでの取得では、瞬時にデータが流れてくるためインジケータを表示するまでもありません。また、優秀なキャシュ機構によってエラーとなるケースもありません(あるとすれば index の貼り忘れやセキュリティルールで弾かれるなどですが、いずれも開発段階で気づけるもので、そのために分岐をするのは単に冗長なコードになってしまいます)。あるいは、単に build メソッドでのネストブロックが気になる開発者もいるかもしれません。
value
や valueOrNull
は AsyncValue から直接値を参照できるもので、上記のような各状態のハンドリングが不要なケースで使用します。
.value
の特徴
- AsyncLoading では
null
が返る - AsyncError では exception が throw される
- 再評価時(isRefreshing: true)の時に exception が発生した場合は前で取得した値が返る
error状態ではexceptionがthrowされる
こちらは元々throw されずに value
が返ってくる仕様だったのですが、こちらのイシュー で議論され、2.0.0-dev.6 で、初回読み込み時のみ exception が throw されるようになりました。
2回目以降の読み込み時は、エラーが発生したのか一見気づきにくい問題がありますが、大抵のケースで予期せぬエラーが生じるのは初回読み込み時とコメントされている通りで納得ですね。この変更に伴い、既存の AsyncError 時にも value を常に返す extension として valueOrNull
が追加されました。そのため、.valueOrNull
は .value
と比べて以下の違いがあるのみです。
.valueOrNull
の特徴
- 基本的には
.value
とほぼ一緒 -
.value
との違いは AsyncError でも常にvalue
が返る点
T? get valueOrNull {
if (hasValue) return value;
return null;
}
各Provider間での受け渡し例
N 番煎じで恐縮ですが、mono さん起票のイシューコメントに書き換え例が記載されており、これを見るのが一番わかりやすいです(毎度ありがとうございます🙏)。
2.0.0-dev.9までの書き方例
// ①StreamProvider
final initialProvider = StreamProvider<int>((ref) => Stream.value(1));
// ②StreamProvider -> FutureProvider
final twiceAfterSecondProvider = FutureProvider<int>((ref) async {
final value = await ref.watch(initialProvider.future); // -> 1
return Future<int>.delayed(const Duration(seconds: 1), () {
// 1 -> 2
return value * 2;
});
});
// ③FutureProvider -> Provider<AsyncValue>
final twiceProvider = Provider<AsyncValue<int>>((ref) {
final value = ref.watch(twiceAfterSecondProvider);
return value.whenData((value) {
// 2 -> 4
return value * 2;
});
});
// ④Provider<AsyncValue> -> StreamProvider
final moreTwiceProvider = StreamProvider<int>((ref) {
// 4 -> 8
return ref.watch(twiceProvider.stream).map((e) => e * 2);
});
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(moreTwiceProvider).value; // -> 8
}
copyWithPreviousの挙動を細かくハンドリングする
直前の状態を維持するcopyWithPreviousメソッド で紹介した非同期 Provider では、とくに何も指定しなければデフォルトで copyWithPrevious
の挙動となりますが、v2.1.0
からさまざまな要件に対応できるよう細かいハンドリングができるようになりました。
Added AsyncValue.when(skipLoadingOnReload: bool, skipLoadingOnRefresh: bool, skipError: bool) flags to give fine control over whether the UI should show loading or data/error cases.
https://pub.dev/packages/riverpod/changelog#210
具体的には、値の取り出しと便利なシンタックス で紹介した .when
などのシンタックス利用時に、以下の bool プロパティを指定できます。デフォルト値と挙動はドキュメントから参照しました。
boolプロパティ | デフォルト | 挙動 |
---|---|---|
skipLoadingOnReload |
false |
ref.watch でのリロード時(再評価時)に AsyncLoading 状態にするか否か |
skipLoadingOnRefresh |
true |
ref.refresh でのリフレッシュ時に AsyncLoading 状態にするか否か |
skipError |
false | 直前の value が存在する場合に、エラーではなくdataを呼び出すか否か |
「とくに何も指定しなければデフォルトで copyWithPrevious
の挙動となりますが」と前述した通り、何も指定しない場合は copyWithPrevious
が働き、リフレッシュ操作などしてもいわゆる「Loading 状態(=AsyncLoading クラス)」にはなりません。ん? となった方は isRefreshing を再度ご参照ください。
一方で、ref.watch
によりプロバイダが再評価される場合は一度 AsyncLoading を経由します(ドキュメントではこの挙動をリフレッシュと対比してリロードと表現しています)。
例を以下に示します。数値を返す someProvider
は doubleEnabledProvider
を watch しています。この場合、doubleEnabledProvider
の値が更新されると、someProvider
が再評価される(=someProvider
のコールバック内が呼ばれる)ため、AsyncLoading クラスを経由後に結果が得られます。
// 数値を2倍にするか否かを管理するプロバイダ
// `someProvider`からwatchされている
final doubleEnabledProvider = StateProvider((ref) => false);
final someProvider = FutureProvider.autoDispose((ref) async {
await Future<void>.delayed(const Duration(seconds: 2));
// `doubleEnabledProvider`が更新されると、参照元である`someProvider`が再評価される
// つまり、`doubleEnabledProvider`が更新される度にAsyncLoadingクラスを経由する
return Future.value(42 * (ref.watch(doubleEnabledProvider) ? 2 : 1));
});
Widget build(BuildContext context, WidgetRef ref) {
// 再評価されるとAsyncLoading, つまりCircularProgressIndicatorのクルクル表示になります
// 対してリフレッシュ操作では表示がパキッと切り替わります。
return ref.watch(someProvider).when(
// skipLoadingOnReload: false,
// skipLoadingOnRefresh: true,
// skipError: false,
loading: CircularProgressIndicator.new,
data: (data) => Text('data: $data'),
error: (error, stackTrace) => Text('error: $error'),
);
}
それぞれの違いをまとめると以下となります。
## Reloading
- 契機: `doubleEnabledProvider`が更新された時(再評価)
- 状態変化: AsyncData → AsyncLoding(isReloading:true) → AsyncData
## Refresh
- 契機: pull-to-refresh操作などで`ref.refresh`によってリフレッシュされた時
- 状態変化: AsyncData → AsyncData(isRefreshing:true) → AsyncData
以上がデフォルトの挙動で、これらを細かくハンドリングできるのが本セクション冒頭で紹介した bool プロパティとなります。たとえば、「リフレッシュ操作時でも AsyncLoading クラスを経由して copyWithPrevious の振る舞いをせずに一から取得したい」といった要望も skipLoadingOnRefresh
を false にすることで簡単に満たせます。
本セクションのプロパティはあくまでオプショナルなので、ややこしいと感じた方はデフォルトの挙動に従うという選択もありです。ちなみに、個人的にも基本はデフォルト値に従う方針です(デフォルトがユーザー体験的にもっとも自然な挙動になっていると思うのが理由です)。
まとめ
今回は Riverpod(v2)の AsyncValue を題材に、前の値を合成するその挙動や isRefreshing
、便利なシンタックスなどを取り扱ってきました。個人的に、昔は StateNotifierProvider
を使って MVVM っぽい作りで実装していた都合で AsyncValue
を取り扱う機会が正直あまりなかったのですが、疎結合的に切り離して実装するようになってからは FutureProvider/StreamProvider を利用する機会が増え AsyncValue
の使いやすさに気づき始めました。
なんとなく「非同期処理の状態をハンドリングしてくれるクラス」程度に思っていた AsyncValue
ですが、執筆にあたって色々調べてみると、前の値の合成やエラーハンドリングの措置、各種 extension など、開発者にとって扱いやすいケアがたくさんなされていることに気づきました。
今回は、上記のドキュメントにはそこまで手厚く記載されてはいないものの、
冒頭にこのように記載しましたが、API ドキュメントはじめとくにテストコードが丁寧に書かれており、これらを読むなり実際に手元で動かしたりするなど、動かしてみるともっとも理解が進みました。
執筆時点ではまだ Prerelease 版でしたが、安定版提供されて落ち着いてきましたね。これから本格的に Riverpod を使い始める方や v2へ以降する方などの参考になれば嬉しいです。
Discussion