🌊

Riverpod v2のAsyncValueを理解する

2022/08/12に公開約16,600字

はじめに

Riverpod、非常に便利で強力な状態管理パッケージですね。
私が最初に触り始めたのは約2年前頃のv0.6.0ですが、そこからv1のリリース・v2のプレリリースと着々と進化を遂げているのが分かります。
そんな有用パッケージですが、ドキュメント自体は用意されているものの(数ヶ月前日本語訳も提供されましたね👏)、その内容はProviderの種類や使い分けや修飾詞に留まっており、少しまだ物足りなさを感じるのが正直なところです(Flutterのドキュメントが手厚すぎるが故に相対的にそう見えてしまっているだけだと思いつつ)。
今回は、上記のドキュメントにはそこまで手厚く記載されてはいないものの、Riverpodを扱う上で重要なAsyncValueについて触れていきます。
AsyncValueFutureProvider/StreamProviderを扱う上で欠かせないクラスで、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状態を簡単にハンドリングできるクラスです。

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

ソースコード上は common.dart に集約されています。

中身を見てもらうと分かりますが、AsyncValueはsealedクラスとなっており、データ、ローディング、エラーの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つの状態を持ちますが、FutureProvider/StreamProviderにおいてそれぞれの状態が変化する時には、前の値を維持する性質があります。
例えば、初回でのデータ取得が成功した後、一時的なサーバエラーなどでその後の取得に失敗した場合には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.

実装は単純で、AsyncDataAsyncErrorの「valueerrorが存在する状態」から再読み込みした時に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でのデータ取得の動きとして、「初回のデータ取得後にリフレッシュした場合の挙動」を以下の図にまとめました。

要点は「③リフレッシュ」時に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>>());
  });

これによってユーザー目線では、②で取得した値が表示されたまま、裏で③のリフレッシュ処理が行われ、④の取得完了と同時にパキッと値が切り替わる形になります。
逆に、リフレッシュと初回読み込みのUIを統一したい(ex. 読込中は常にインジケータを表示させたい)といった場合には、AsyncData,AsyncErrorでもisLoading: trueとなるので、普通にisLoading判定だけすれば良いですね。

値の取り出しと便利なシンタックス

AsyncValueには他にも様々なメソッドや拡張が用意されています。前述したisRefereshingもextensionで用意されているgetterの一つです。テストコードがasync_value_test.dartに書かれているのでこちらを動かしてみるのもおすすめです。今回はその中で、個人的に特によく使うものをピックアップして記載します。

https://pub.dev/documentation/riverpod/latest/riverpod/AsyncValueX.html

when・whenOrNull・mayBeWhen

最も馴染みのあるextensionがwhenだと思います。他の記事でもたくさん言及されておりますが、data/loading/errorの3状態に応じて表示を切り替えることができます。

.whenの特徴

  • AsyncValueを取り出す際の最もスタンダード(主観)な書き方
  • 3状態に応じて表示を切り替えることができる
  • 丁寧なハンドリングが不要な場面ではやや冗長となる側面がある
buildメソッドでの使用例

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回目以降関係なくインジケータを表示したい場合は、isRefreshingisLoadingでも同様)の分岐を追加してあげる必要がありますね。

ついでにwhenOrNullmayBeWhenも紹介します。名前からも分かりますが、whenを基準にユースケースによって取り扱いしやすくしたextensionとなっています。

.mayBeWhenの特徴

  • ハンドリングしたい状態のみの実装で済む
  • 他の状態はorElse()でまとめることができる

error状態を特に考慮する必要ない場面などで利用できますが、後述の.valueで済むケースが多いのであまり出番はありません。

データ取得時以外はインジケータを表示する例
ref.watch(someValueProvider).maybeWhen(
  // loading/error時にはインジケータが表示される
  orElse: CircularProgressIndicator.new,
  data: (value) {
    return Text('$value');
  },
);

.whenOrNullの特徴

  • .mayBeWhenorElse()の代わりにnullを返す
データ取得時以外は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となって返却される
  • AsyncLoadingAsyncErrorwhenDataの加工後の型で返却される
    • 型が変わらない場合は見た目上そのまま返却されるように見える
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の挙動のテストコード
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側にロジックが介在せずスッキリしますね。

whenDataを使ってProviderを加工する例
final countProvider = StreamProvider<int>((ref) => Stream.value(42));

// countを2倍にしてString型に変えるProvider
final doubleCountLabelProvider = Provider<AsyncValue<String>>((ref) {
  return ref.watch(countProvider).whenData((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メソッドでのネストブロックが気になる開発者もいるかもしれません。
valuevalueOrNullは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;
  }

Future/Streamへの変換も容易

AsyncValueasync_value_converters.dart内のextensionで定義されているように、.future.streamでそれぞれ簡単にFutureやStreamに変換することができます。機能としてはv1から提供はされていたものです。APIドキュメントはそれぞれ以下です。

future property

https://pub.dev/documentation/riverpod/latest/riverpod/AlwaysAliveAsyncProviderX/future.html

Why not use StreamProvider.stream.first instead?にも書かれていますが、.futureが用意されている理由は監視のタイミングによるバグを回避するものです。通常、初回のstream発行後に監視してしまうとstreamが流れて来ません。rxdart | Dart PackageパッケージのBehaviorSubject class - rx library - Dart APIで発行されたstreamであればその問題も解消できますが、streamの発行側での工夫が必要になります。また、パッケージ依存が前提という点もあまり良くありません。.futureであればそれらの問題が解消でき、監視タイミングに依らずに必ず最初のstreamを受け取ることができます。

stream property

https://pub.dev/documentation/riverpod/latest/riverpod/AlwaysAliveAsyncProviderX/stream.html

各Provider間での受け渡し例

StreamProviderやFutureProviderもそれぞれ.future.streamプロパティを持っているため、AsyncValueの.future,.streamプロパティを組み合わせると、これらProvider間のデータの受け渡しが非常にスムーズに行うことができます。
以下はサンプルのために適当に用意したProvider群ですがこういったことができます。

// ①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
}

https://twitter.com/_mono/status/1455110929155178499

まとめ

今回はRiverpod(v2)のAsyncValueを題材に、前の値を合成するその挙動やisRefreshing、便利なシンタックスなどを取り扱ってきました。個人的に、昔はStateNotifierProviderを使ってMVVMっぽい作りで実装していた都合でAsyncValueを取り扱う機会が正直あまりなかったのですが、疎結合的に切り離して実装するようになってからはFutureProvider/StreamProviderを利用する機会が増えAsyncValueの使いやすさに気づき始めました。
なんとなく「非同期処理の状態をハンドリングしてくれるクラス」程度に思っていたAsyncValueですが、執筆にあたって色々調べてみると、前の値の合成やエラーハンドリングの措置、各種extensionなど、開発者が扱いやすいケアがたくさんなされていることに気づきました。

今回は、上記のドキュメントにはそこまで手厚く記載されてはいないものの、

冒頭にこのように記載しましたが、APIドキュメントはじめ特にテストコードが丁寧に書かれており、これらを読むなり実際に手元で動かしたりするなりで動かしてみるのが最も理解が進みました。

まだPrerelease版なので今後変更が入るかもしれないですが、できるだけ変更に追従できるように本記事も更新し、これから本格的にRiverpodを使い始める方やv2へ以降する方などの参考になれば嬉しいです。

参考

Discussion

ログインするとコメントできます