🍹

Riverpod の AsyncValue をその構造から理解する

2023/10/19に公開

Flutter の Riverpod パッケージには AsyncValue というクラスが用意されています。

https://pub.dev/documentation/riverpod/latest/riverpod/AsyncValue-class.html

これは、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 は忘れて一般的な非同期処理(たとえばサーバーからのデータ取得)における状態の遷移をイメージしていきましょう。

目的のページが表示され、目的のデータを「はじめて」取得することを考えた場合、処理の流れは以下のイメージのようになります。

非同期処理の状態遷移のイメージ

とてもシンプルですね。「処理中」 に始まり、正常終了すれば 「データ取得完了」 、途中でエラーが発生すれば 「エラー発生」 です。

しかし、 状態管理は初回の取得のみでは終わらない 場合があります。この後、 もう一度データを取得し直したり、条件を変えて再取得するような場合がある ためです。

そのことを表したイメージが以下です。

2回目以降を考慮した非同期処理の状態遷移のイメージ

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 となっていますが、 AsyncDataAsyncError もこの値が true になる場合があります。

それが、2回目以降に「同じ条件で再取得」する場合です。

pull-to-refresh やボタンタップ等のタイミングで ref.invalidate(fetchArticlesProvider) を呼び出すと、 fetchArticlesProvider は全ての情報を破棄するのではなく、 最後に取得した内容は保持しつつ isLoading フラグを true にする ような挙動になっています。

つまり、前回の結果が「データ取得完了」であれば実体は AsyncData のまま、「エラー発生」であれば実体は AsyncError のまま isLoadingtrue にしてリビルドされるため、 AsyncLoading 以外の場合もこのブロックに入ってくるようになっています。

 bool skip;
 if (isRefreshing) {
   skip = skipLoadingOnRefresh;

続く 3 行がこちらです。

skip フラグを定義し、後続の処理で truefalse かを代入しています。

最初に判定するのが 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 となると理解すれば良さそうです。

この isRefreshingtrue でかつ引数に渡した skipLoadingOnRefreshtrue であれば skiptrue になる というわけです。


ちなみに、コードを読み進めると分かりますが、この skip フラグは loading() の呼び出しをスキップするかどうか」 を表すフラグとなります。つまり「リフレッシュ状態だけど loading() ではなく error()data() を呼び出してほしい、と言う場合にこのフラグを true にしてあげます。

ただし引数の受け取り方をみると、 skipLoadingOnRefresh はデフォルトで true のため、リフレッシュ時は loading() は呼ばれず、エラーがなければ data() が呼ばれるようになっています。

後述する skipLoadingOnReload も同じ考え方ですが、こちらはデフォルト値が false になっているので注意してください。


さて話を戻して、次の 2 行は isReloading == true かどうかの判定です。

} else if (isReloading) {
    skip = skipLoadingOnReload;

isReloadingisRefreshing とは別の状態で、 「条件を変えて取得し直し」 している場合に 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 なら skiptrue が、false なら skipfalse になる作りとなっています。


isRefreshingisReloading の判定のどれにも入らない場合、以下の通り 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 ですが、もし valuenull だった場合は例外が発生するような実装になっています。

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() のロジックを見返しながら考えると、 skipLoadingOnskipError のフラグの指定がデフォルト値のままの場合はシンプルな書き換えが可能なようですが、そうでない場合はサブクラスの型と呼ばれるべき関数にズレが出てくるため注意が必要です。

GitHub の issue をみると、さまざまなパターンを考慮したマイグレーション方法のドキュメント作成が検討されていますので、このページの完成を待つのが良さそうです。

https://github.com/rrousselGit/riverpod/issues/2715

AsyncValue オブジェクトを生成する側

ここまでは AsyncValue を利用する側を見てきましたので、次は AsyncValue を生成する側を見ていきましょう。つまり、Provider 側です。

AsyncValue を生成するための Provider を利用する

最初に注意したいのは、AsyncValue の生成は必ず AsyncValue を生成するための Provider で行う ということです。具体的な Provider の種類で言うと FutureProvider AsyncNotifierProvider StreamProvider StreamNotifierProvider の 4 つのみです。

逆にいうと、 StateNotifierProviderChangeNotifierProviderStateProvider などの現状では非推奨となっている 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,
      ),
    );
  }
}

前回の AsyncValuenull の場合(つまり初めての場合)はそのまま受け取った AsyncValue オブジェクトをセットする一方、そうでない場合は copyWithPrevious() で既存の AsyncValue オブジェクトとマージするような処理が書かれています。

copyWithPrevious() 自体が何をしているのかは各サブクラスの実装によりますが、基本的には「ロード中なら前回のエラーやデータを引き継ぐ」「データ取得完了なら引き継がずに最新の内容だけを含める」ようなことが行われています。[3]

一方で StateNotiferProvider などはこのような処理を全く行いませんので、アプリ開発者がコーディングして生成した AsyncValue オブジェクトがそのまま ref.watch() で渡ってしまうため、 copyWithPrevious() を適切に使ってドキュメント通りの挙動にするのはわれわれの責任 ということになってしまいます。

「適切に」を実現するには結局 AsyncNotifier 等の内部的な実装をすべて追って理解する必要が出てしまいますので、それをするくらいなら AsyncNotifierProviderFutureProvider での書き換えをするのが早くて安全といえるでしょう。

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 の扱い方を確認するサンプルアプリを公開していますので、何かの足しになれば嬉しいです。

https://github.com/chooyan-eng/asyncvalue_practice


脚注
  1. Riverpod 3 では AsyncValue クラスは sealed class となるとのことですので、状況に応じてこの他のサブクラスを定義するような扱い方は想定されていないと考えられます。 ↩︎

  2. 実際は state は setter になっているため、受け取った AsyncLoading と前回の AsyncValue をマージする処理が中で行われます。 ↩︎

  3. 実際は copyWithPrevious() にも isRefresh というフラグが引数で用意されていて、その真偽で挙動が変わるのでなかなか複雑です。 ↩︎

  4. トップページのサンプルコードはカウンターではなく HTTPS リクエストを伴うデータ取得処理になっていますし、 Why Riverpod ページも書かれているのはサーバーとの通信を伴う非同期な処理による状態管理の話がほとんどです。 ↩︎

GitHubで編集を提案

Discussion