Riverpod の AsyncValue をその構造から理解する
Flutter の Riverpod パッケージには AsyncValue
というクラスが用意されています。
これは、state の生成に非同期な処理を伴う場合に Widget 側(もしくは別の Provider)に渡されるオブジェクトで、 その処理が現在どのような状態なのか を細かく保持している作りになっています。
例えば
Future<List<Articles>> fetchArticles() async {
// 記事一覧をデータベースから取得する処理
return await request();
}
という、List<Article>
型の記事一覧データをデータベースから取得するための Provider があった場合、Widget 側でこの Provider を ref.watch(fetchArticlesProvider)
すると
class ArticleListPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<List<Article>> value = ref.watch(fetchArticlesProvider);
// ... 一覧画面を作る処理
}
}
というように、AsyncValue<List<Article>>
型のオブジェクトが返却されます。
この AsyncValue
オブジェクトには、データ取得のような非同期な処理が
- 処理中
- データ取得完了
- 処理中にエラーが発生
のどの状態なのかの情報を持っていて、使う側はたとえば .when()
メソッドを利用してそれぞれの場合に応じた処理が書けるようになっています。
class ArticleListPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(fetchArticlesProvider);
return value.when(
loading: () => const CircularProgressIndicator(),
data: (data) => _ArticleListView(data),
error: (error, stacktrace) => _ErrorView(error),
);
}
}
Widget の build()
メソッドが呼ばれた時点で「処理中」であれば when()
の戻り値は loading
に渡した関数を実行した結果 return されたものとなります。
その後処理が完了したタイミングで、リビルドによってもう一度 Widget の build()
メソッドが呼び出され、今度は .when()
の戻り値が data
に渡した関数を実行した結果 return されたものになります。
上記のコードでは .when()
メソッドが返却するオブジェクトをそのまま build()
メソッドで return する Widget として利用しているため、結果としてリビルドごとに返却される Widget が切り替わり、ユーザーに「ロード中のくるくる」と「取得完了後の一覧画面」という UI 上の変化を伝えることができる、という仕組みです。
ここまでは AsyncValue
の基本的な使い方ということで良いと思いますが、これを実際のアプリ開発で扱おうとするともう少し話は複雑になってきます。
この記事では、そんな AsyncValue
がどのような内容を保持しているのか、それをわれわれアプリ開発者がどう扱えばよいのか について考えていきたいと思います。
なお、この記事の公開時点での Riverpod のバージョンは 2.4.3 です。
また、この記事では調査不足の都合上状態の生成が Future
の場合のみについて記述し、Stream
の場合の話は割愛しますので、ご了承ください。
3 つのサブクラスと 3 つのフィールド
ドキュメントやコードを見ると、AsyncValue
自体は abstract
なクラスで、それを継承したサブクラスとして AsyncData
AsyncError
AsyncLoading
という 3 つのクラスが定義されているのがわかります。[1]
名前の通り、この 3 つのクラスが先述の 3 つの状態を表すクラスのように見えますが、実は 「非同期な処理をする」ときに考慮すべき状況はその 3 つで完結するほど単純ではありません。
そのことが見てとれるのが AsyncValue
が持っている(つまり 3 つのサブクラスも持っている) value
isLoading
error
という 3 つのフィールドです。
これらの親クラスの持つフィールドは AsyncLoading
のような各サブクラスも持つことができますので、たとえば「 isLoading: true
である AsyncData
」や「 value
に値を持つ AsyncLoading
」といった複合的な状態が表現できるようになっています。
どのような場合にこの「複合的な状態」が必要になるのでしょうか。
非同期処理の状態の遷移
ここで、AsyncValue
は忘れて一般的な非同期処理(たとえばサーバーからのデータ取得)における状態の遷移をイメージしていきましょう。
目的のページが表示され、目的のデータを「はじめて」取得することを考えた場合、処理の流れは以下のイメージのようになります。
とてもシンプルですね。「処理中」 に始まり、正常終了すれば 「データ取得完了」 、途中でエラーが発生すれば 「エラー発生」 です。
しかし、 状態管理は初回の取得のみでは終わらない 場合があります。この後、 もう一度データを取得し直したり、条件を変えて再取得するような場合がある ためです。
そのことを表したイメージが以下です。
1 度処理が完了してデータが取得できた(もしくはエラーが発生した)としても、アプリの要件はそこで終わりではありません。
たとえば pull-to-refresh のように条件を変えずにもう一度同じ処理を実行してデータを再取得することもあれば、カテゴリなどの条件を変更して別のデータを取得し直すこともあるでしょう。
2 回目以降の処理中に画面上に何を表示しておきたいのか、というのも検討する必要があります。
「ローディング中」として初回のロード画面と同じものを表示するのか、それとも取得済みのデータはそのままに、裏で取得処理だけを行うのか、途中でエラーが発生した場合はエラーのみを表示するのか、前回取得できたものを表示するのか、など、さまざまなパターンが考えられるでしょう。
そして、 UI = f(State)
の式で表されるように Flutter では 実現したい UI のパターンの数だけ、それを実現するためのデータを「状態」としてどこかに管理しなければなりません。 そこで AsyncValue
です。
AsyncValue の扱い方
このように非同期処理を伴う状態管理にはさまざまな状況が考えられ、それを UI で表現するためには Dart コード上でいくつものフィールドが必要になるわけですが、そのような さまざまな状況を汎用的なひとつのオブジェクトで表現できるようにしたものが AsyncValue
である ということになります。
では、それを踏まえて使い方を考えていきましょう。
AsyncValue オブジェクトを利用する側
まずは「利用する側」、つまり Widget などで ref.watch
する場合について考えていきましょう。
おそらく多くの場合、AsyncValue
オブジェクトを取得した後にするのは when()
メソッドを使ってそれぞれの場合に表示したい Widget をコーディングすることではないでしょうか。
class ArticleListPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(fetchArticlesProvider);
return value.when(
loading: () => const CircularProgressIndicator(),
data: (data) => _ArticleListView(data),
error: (error, stacktrace) => _ErrorView(error),
);
}
}
when()
は正確に言えば loading
data
error
それぞれの引数に渡した関数の中からひとつだけを選んで呼び出してくれるメソッド です。
このときどの関数を呼び出すかは AsyncValue
オブジェクトの内容(と追加で渡したフラグの内容)によって決まり、実装自体は以下の通りシンプルな条件分岐のコードで成り立っています。
R when<R>({
bool skipLoadingOnReload = false,
bool skipLoadingOnRefresh = true,
bool skipError = false,
required R Function(T data) data,
required R Function(Object error, StackTrace stackTrace) error,
required R Function() loading,
}) {
if (isLoading) {
bool skip;
if (isRefreshing) {
skip = skipLoadingOnRefresh;
} else if (isReloading) {
skip = skipLoadingOnReload;
} else {
skip = false;
}
if (!skip) return loading();
}
if (hasError && (!hasValue || !skipError)) {
return error(this.error!, stackTrace!);
}
return data(requireValue);
}
「 AsyncValue
の変化を検知する」というような難しい処理は行なっておらず、 単純に条件分岐でどの関数を呼び出すべきかを選んでいるだけ であることが見てとれるかと思います。
この条件分岐は大きくわけて 3 つのブロックで構成されています。loading()
を呼び出すべきかどうか、 error()
を呼び出すべきかどうか、それとも data()
を呼び出すのか、それぞれに分けてコードを見てみましょう。
loading()
を呼び出すべきかどうか
まずは「処理中であるかどうか」の判定が行われます。
ここまで説明した通り、「処理中であるかどうか」は「実体が AsyncLoading
かどうか」では決まりません。では何をもって「処理中」と判断するのかを実装を見ながら確認していきましょう。
if (isLoading) {
まずは isLoading == true
であるかどうかです。AsyncLoading
は常にこのフィールドが true
となっていますが、 AsyncData
や AsyncError
もこの値が true
になる場合があります。
それが、2回目以降に「同じ条件で再取得」する場合です。
pull-to-refresh やボタンタップ等のタイミングで ref.invalidate(fetchArticlesProvider)
を呼び出すと、 fetchArticlesProvider
は全ての情報を破棄するのではなく、 最後に取得した内容は保持しつつ isLoading
フラグを true
にする ような挙動になっています。
つまり、前回の結果が「データ取得完了」であれば実体は AsyncData
のまま、「エラー発生」であれば実体は AsyncError
のまま isLoading
を true
にしてリビルドされるため、 AsyncLoading
以外の場合もこのブロックに入ってくるようになっています。
bool skip;
if (isRefreshing) {
skip = skipLoadingOnRefresh;
続く 3 行がこちらです。
skip
フラグを定義し、後続の処理で true
か false
かを代入しています。
最初に判定するのが isRefreshing == true
かどうかです。
isRefreshing
は実装を読むと以下のような getter になっていて
bool get isRefreshing =>
isLoading && (hasValue || hasError) && this is! AsyncLoading;
「実体が AsyncLoading
ではなく」「isLoading == true
で」「かつ前回取得済みのデータかエラーが存在する」という定義になっています。
つまり、先ほど説明した 「2回目以降に同じ条件で再取得した場合」 がまさにこの状況に当てはまります。
ドキュメントでも pull-to-refresh がこの状況の説明でよく用いられている通り、「 ref.invalidate()
や ref.refresh()
でリフレッシュする(つまり条件を変えずに再取得する)」場合に isRefreshing == true
となると理解すれば良さそうです。
この isRefreshing
が true
でかつ引数に渡した skipLoadingOnRefresh
が true
であれば skip
が true
になる というわけです。
ちなみに、コードを読み進めると分かりますが、この skip
フラグは 「loading()
の呼び出しをスキップするかどうか」 を表すフラグとなります。つまり「リフレッシュ状態だけど loading()
ではなく error()
や data()
を呼び出してほしい、と言う場合にこのフラグを true
にしてあげます。
ただし引数の受け取り方をみると、 skipLoadingOnRefresh
はデフォルトで true
のため、リフレッシュ時は loading()
は呼ばれず、エラーがなければ data()
が呼ばれるようになっています。
後述する skipLoadingOnReload
も同じ考え方ですが、こちらはデフォルト値が false
になっているので注意してください。
さて話を戻して、次の 2 行は isReloading == true
かどうかの判定です。
} else if (isReloading) {
skip = skipLoadingOnReload;
isReloading
は isRefreshing
とは別の状態で、 「条件を変えて取得し直し」 している場合に true
となります。実装は以下の通りです。
bool get isReloading => (hasValue || hasError) && this is AsyncLoading;
(hasValue || hasError)
の部分は isRefreshing
と同じで「前回取得済みのデータかエラーが存在する」かどうかの判定です。それとは別に「実体が AsyncLoading
かどうか」が含まれる点が isRefreshing
との違いになります。
この状態になるのは、たとえばデータ取得を行う Provider が
Future<List<Articles>> fetchArticles() async {
final condition = await ref.watch(conditionProvider);
// 記事一覧をデータベースから取得する処理
return await request(condition);
}
というように別の Provider を watch()
している状況で、この conditionProvider
に変化があって fetchArticles()
が再度呼ばれた場合です。
また、Notifier の任意のメソッドで state = AsyncLoading()
と state に直接 AsyncLoading
を代入した場合も同様です。[2]
このような状況では isReloading == true
となり、引数の skipLoadingOnReload
の値が true
なら skip
に true
が、false
なら skip
も false
になる作りとなっています。
isRefreshing
と isReloading
の判定のどれにも入らない場合、以下の通り skip
は強制的に false
になります。
} else {
skip = false;
}
if (!skip) return loading();
skip == false
の場合は最後の if(!skip)
の判定に該当するため loading()
が呼び出されてその戻り値が .when()
自体の戻り値となる、という流れです。
error()
を呼び出すべきかどうか
loading()
は分岐が複雑でしたが、 error()
の判定は比較的シンプルです。
if (hasError && (!hasValue || !skipError)) {
return error(this.error!, stackTrace!);
}
hasError
はその名の通り「error
フィールドに値があるかどうか」で決まります。
bool get hasError => error != null;
実体が AsyncError
の場合だけでなく「前回の結果がエラーで reloading
refreshing
中」の場合もエラーが引き継がれて hasError == true
となります。
つまり「エラーの情報があり」かつ「データがまだなかったり引数の skipError
がデフォルトの false
だったり」する場合にこの error()
が呼び出されます。
data()
を呼び出すべきかどうか
「処理中」でもなく「エラー発生」もしていない場合は「データ取得完了」とみなされ、data()
が呼ばれます。
return data(requireValue);
requireValue
は「取得済みのデータ」を表す value
をキャストしてとってくるだけの getter ですが、もし value
が null
だった場合は例外が発生するような実装になっています。
T get requireValue {
if (hasValue) return value as T;
if (hasError) {
throwErrorWithCombinedStackTrace(error!, stackTrace!);
}
throw StateError(
'Tried to call `requireValue` on an `AsyncValue` that has no value: $this',
);
}
というわけで .when()
の実装を読んでみましたが、これだけでだいぶ長くなってしまいましたね。
いろいろ説明はしたものの、基本的には多くの状況で直感どおりの関数が呼び出されるようには作られている認識です。ただし、もし意図しないタイミングで loading()
や error()
が呼び出されるような場合は、この判定の流れと引数の内容を見直してみると良いでしょう。
他にも when()
の派生の maybeWhen()
や whenOrNull()
などがありますが、基本的には内部的に when()
を呼び出しているだけで、データがなかった場合や引数の関数が渡されなかった場合の挙動が変わるだけですので、 when()
が理解できている限りは問題ないと思います。
ひとつだけ、 whenData()
という似たメソッドがありますが、これは少し用途が違いますのでご注意ください。(詳しくは割愛します)
Dart3 の switch-case への書き換え
when()
の挙動は Dart3 で登場した switch-case で書き換えられるという話があります。
// Before
asyncValue.when(
data: (value) => print(value),
error: (error, stack) => print('Error $error'),
loading: () => print('loading'),
);
// After
switch (asyncValue) {
case AsyncData(:final value): print(data);
case AsyncError(:final error): print('Error $error');
case _: print('loading');
}
ただし、ここまで見てきた通り、when()
の挙動は単純にサブクラスの型だけで決まるわけではなく、オブジェクトが持つ内容や引数のフラグによって変わり得るため機械的に簡単に変換ができるほど話は単純ではなさそうです。
when()
のロジックを見返しながら考えると、 skipLoadingOn
や skipError
のフラグの指定がデフォルト値のままの場合はシンプルな書き換えが可能なようですが、そうでない場合はサブクラスの型と呼ばれるべき関数にズレが出てくるため注意が必要です。
GitHub の issue をみると、さまざまなパターンを考慮したマイグレーション方法のドキュメント作成が検討されていますので、このページの完成を待つのが良さそうです。
AsyncValue オブジェクトを生成する側
ここまでは AsyncValue
を利用する側を見てきましたので、次は AsyncValue
を生成する側を見ていきましょう。つまり、Provider 側です。
AsyncValue を生成するための Provider を利用する
最初に注意したいのは、AsyncValue
の生成は必ず AsyncValue を生成するための Provider で行う ということです。具体的な Provider の種類で言うと FutureProvider
AsyncNotifierProvider
StreamProvider
StreamNotifierProvider
の 4 つのみです。
逆にいうと、 StateNotifierProvider
や ChangeNotifierProvider
、 StateProvider
などの現状では非推奨となっている Provider の中で自作した AsyncValue
オブジェクトを state
に代入するのは避けた方が良さそうです。
なぜかというと、 上記 4 つの Provider には AsyncValue
オブジェクトを状況に応じた適切な内容で生成する処理が内部的に行われる 一方、 その他の非推奨な Provider では state
に代入した AsyncValue
オブジェクトがそのまま Widget 側に渡される ため、「2 回目だから isRefreshing == true
」のような状況に応じた内容にならず、実装次第で 扱い方が公式ドキュメントの想定する標準的なものから変わってしまう ためです。
たとえば AsyncValue
を内部的に生成する処理の一部である asyncTransition()
というメソッドをみると以下のようになっています。
void asyncTransition(
AsyncValue<T> newState, {
required bool seamless,
}) {
final previous = getState()?.requireState;
if (previous == null) {
setState(newState);
} else {
setState(
newState.copyWithPrevious(
previous,
isRefresh: seamless,
),
);
}
}
前回の AsyncValue
が null
の場合(つまり初めての場合)はそのまま受け取った AsyncValue
オブジェクトをセットする一方、そうでない場合は copyWithPrevious()
で既存の AsyncValue
オブジェクトとマージするような処理が書かれています。
copyWithPrevious()
自体が何をしているのかは各サブクラスの実装によりますが、基本的には「ロード中なら前回のエラーやデータを引き継ぐ」「データ取得完了なら引き継がずに最新の内容だけを含める」ようなことが行われています。[3]
一方で StateNotiferProvider
などはこのような処理を全く行いませんので、アプリ開発者がコーディングして生成した AsyncValue
オブジェクトがそのまま ref.watch()
で渡ってしまうため、 copyWithPrevious()
を適切に使ってドキュメント通りの挙動にするのはわれわれの責任 ということになってしまいます。
「適切に」を実現するには結局 AsyncNotifier
等の内部的な実装をすべて追って理解する必要が出てしまいますので、それをするくらいなら AsyncNotifierProvider
や FutureProvider
での書き換えをするのが早くて安全といえるでしょう。
state 代入するオブジェクト
先述の通り、AsyncNotifier
を使っている限り state に代入した AsyncValue
オブジェクトはそのまま Widget に届くのではなく 内部的に適切な内容にマージされた上で Widget に届く 作りになっていますので、あまり余計なことを考える必要はなさそうです。
たとえば、「処理中」の状態にしたければ AsyncLoading()
で生成したオブジェクトを state
に代入すれば良いでしょう。データが取得できたら AsyncData()
に詰めて state
に代入します。
Future<void> fetchExtraArticles() async {
// ロード中にする
state = AsyncLoading();
// 何らかの処理
final List<Article> extra = await fetchExtra();
state = AsyncData([...state, ...extra]);
}
このようにすることで、state に代入した AsyncLoading
オブジェクトと前回の AsyncValue
オブジェクトがマージされた AsyncValue
オブジェクトが生成されてリビルドが発生し、Widget 側では適切な内容で UI が更新されます。
処理が完了してデータが取得できた場合も、シンプルに AsyncData
オブジェクトを生成して(引数には最終的なデータを渡して) state
に代入すれば、あとは内部で適切な AsyncValue
オブジェクトを生成してくれます。
「前回の状態を引き継いでリフレッシュかリロードかを判別して」というような小難しいことは中でよろしくやってくれることを頭に入れつつ、state
へ手作業で AsyncValue
オブジェクトを代入したい場合は必要最小限の情報のみを代入すれば良さそうです。
ちなみに、エラーが発生した場合は AsyncError
を代入したい、という場合は AsyncValue.guard()
が便利です。
state = await AsyncValue.guard(
() async {
final List<Article> extra = await fetchExtra();
return [...state, ...extra];
},
);
AsyncValue.guard()
に Future な処理を渡すことで、成功した場合は return した値を持った AsyncData
オブジェクトが、エラーが発生した場合はその情報を持った AsyncError
オブジェクトが生成されるため、あとはそれを state
にセットするだけでやはり適切な内容にマージされた AsyncValue
オブジェクトが UI に渡せる仕組みになっています。
まとめ
以上、 AsyncValue
クラスの構造とそのオブジェクトがどのように生成されるのか、そしてそれをどのように扱えるのかを実装を見ながら考えてみました。
Riverpod は公式ドキュメントを見ればわかる[4] 通り「非同期な処理を伴う状態管理」に伴う課題解決に重点をおいているパッケージです。
AsyncValue
とそれを生成する内部処理は「非同期な処理を伴う状態管理」において一般的に発生しうる課題を先回りして解決するためのものですので、なるべくその仕組みに正しく乗っかった実装を心がけることで考慮漏れを回避したり「みんなと同じ」方法で解決できるメリットが享受できるのではと思います。
逆に、「みんなと同じ」方法が目の前のアプリの要件に明らかに合わない場合はこの仕組み(さらには Riverpod パッケージ)の利用自体を検討し直した方が良いかもしれません。
AsyncValue
を理解することは Riverpod
の理解の半分以上を占めていると言っても過言ではないと思いますので、ドキュメントやソースコード、挙動を見ながら可能な限り理解を深めるのが良さそうです。
AsyncValue
の扱い方を確認するサンプルアプリを公開していますので、何かの足しになれば嬉しいです。
-
Riverpod 3 では
AsyncValue
クラスはsealed class
となるとのことですので、状況に応じてこの他のサブクラスを定義するような扱い方は想定されていないと考えられます。 ↩︎ -
実際は state は setter になっているため、受け取った
AsyncLoading
と前回のAsyncValue
をマージする処理が中で行われます。 ↩︎ -
実際は
copyWithPrevious()
にもisRefresh
というフラグが引数で用意されていて、その真偽で挙動が変わるのでなかなか複雑です。 ↩︎ -
トップページのサンプルコードはカウンターではなく HTTPS リクエストを伴うデータ取得処理になっていますし、 Why Riverpod ページも書かれているのはサーバーとの通信を伴う非同期な処理による状態管理の話がほとんどです。 ↩︎
Discussion