🏺

【Flutter】AsyncValueを楽に扱う方法

2024/03/25に公開
4

RiverpodのAsyncValueを使うことで、開発者は非同期処理を簡単に管理できます。しかし、現状として開発者がAsyncValueの扱い方に悩んでしまったり、手を焼いてしまっているケースも少なくありません。

この記事では、AsyncValueの取り扱いをシンプルにする方法を紹介し、開発者がAsyncValueを使用する際に抱えてしまいがちな冗長性や複雑性を解消することを目指します。

AsyncValueを管理する共通Widgetを作る

loadingとerrorの時に返すWidgetが大体同じ場合、それらをデフォルトで返すWidgetを用意して、AsyncValue.dataの時のWidgetのみを必要とする構造を持つWidgetを用意した方が楽に済みます。

他にもloadingからdataに移った時のフェードなど、状態管理と連動する細かい表示の設定も、この共通Widgetの内部に組み込めば各所で実装することなく全体に適応できます。

/// 共通Widgetの一例
/// これはあくまでサンプルで、実際の共通クラスの具体的な内容は
/// 各プロジェクトに沿ったものを各自で実装することを推奨します
/// この場合エラー時もローディング時もデフォルトではSizedBox.shrink()
class AsyncValueSwitcher<T> extends StatelessWidget {
  const AsyncValueSwitcher({
    super.key,
    required this.asyncValue,
    required this.onData,
    this.onError,
    this.onLoading,
    this.skipLoadingOnReload = true,
    this.skipLoadingOnRefresh = true,
    this.skipError = false,
    this.duration = const Duration(milliseconds: 300),
  });

  final AsyncValue<T> asyncValue;
  final Widget Function(T data) onData;
  final Widget? onLoading;
  final Widget Function(Object, StackTrace)? onError;
  final bool skipLoadingOnReload;
  final bool skipLoadingOnRefresh;
  final bool skipError;
  final Duration duration;

  
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      duration: duration,
      //TODO: switchに移行
      child: asyncValue.when(
        skipLoadingOnReload: skipLoadingOnReload,
        skipLoadingOnRefresh: skipLoadingOnRefresh,
        skipError: skipError,
        data: (data) => KeyedSubtree(
          key: const ValueKey('onData'),
          child: onData(data),
        ),
        error: (e, s) => KeyedSubtree(
          key: const ValueKey('onError'),
          child: onError?.call(e, s) ?? const SizedBox.shrink(),
        ),
        loading: () => KeyedSubtree(
          key: const ValueKey('onLoading'),
          child: onLoading ?? const SizedBox.shrink(),
        ),
      ),
    );
  }
}

使用例

final futureProvider = FutureProvider(
  (ref) => Future.delayed(const Duration(milliseconds: 300), () => 'A'),
);

class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(futureProvider);
    return Scaffold(
      body: AsyncValueSwitcher(
        asyncValue: state,
        onData: (state) {
          return Center(
            child: Text('result: $state'),
          );
        },
      ),
    );
  }
}

複数のAsyncValueを単一のAsyncValueとして扱う

AsyncValueが2個以上になってしまうと、上記のAsyncValueSwitcherは役に立たなくなってしまいます。また、仮に共通クラスを使っていなかったとしても複数のAsyncValueの管理はある程度複雑化してしまいます。

そこで、AsyncValue<T1>()とAsyncValue<T2>()をAsyncValue<(T1, T2)>()に変換するような関数を用意することでそれらを単一のAsyncValueとして扱うようにします。

そのようなライブラリとしてasync_value_groupがあります。ただし、このライブラリは複数のAsyncValueをまとめるのにカスタムクラスを用いており、ここに関してはRecordとして扱った方が楽なので、その部分だけ改変したコードを今回は例として紹介します。
朗報🎉 本記事公開直後にasync_value_groupがアップデートされ、Record型で使えるようになりました

class AsyncValueGroup {
  static AsyncValue<(T1, T2)> group2<T1, T2>(
    AsyncValue<T1> t1,
    AsyncValue<T2> t2,
  ) {
    if (t1 is AsyncLoading || t2 is AsyncLoading) {
      return const AsyncLoading();
    }
    try {
      return AsyncData((t1.value as T1, t2.value as T2));
    } catch (e, st) {
      return AsyncError(e, st);
    }
  }
  /// ここではgroup3以降は省略
}

この関数によって、以下のように複数のAsyncValueを単一のものとして扱うことができます。

final futureProviderA = FutureProvider(
  (ref) => Future.delayed(const Duration(milliseconds: 300), () => 'A'),
);

final futureProviderB = FutureProvider(
  (ref) => Future.delayed(const Duration(milliseconds: 200), () => 'B'),
);

class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final stateA = ref.watch(futureProviderA);
    final stateB = ref.watch(futureProviderB);
    return Scaffold(
      body: AsyncValueSwitcher(
        asyncValue: AsyncValueGroup.group2(stateA, stateB),
        onData: (group) {
          final a = group.$1;
          final b = group.$2;
          return Center(
            child: Text('result: $a, $b'),
          );
        },
      ),
    );
  }
}

AsyncValueGroup.groupNによって、複数のAsyncValueを扱う際の複雑性が隠蔽できるのでWidget側はだいぶスッキリした形で書けます。(詳細はasync_value_groupを作ったbannzaiさんの記事にて詳しく記述されています。)

他の方法として、複数のProviderを隠蔽した単一のProviderをWidgetに参照させることもありますが、

  • 各所でまとめるためのProviderを用意する必要があるため、記述量が多くなる
  • そのProviderをinvalidateしても元々参照してるProvider自体はinvalidateされないため、例外的な再検証をする必要がある

といった理由から、AsyncValueGroupのようにWidgetに見える形でまとめた方が管理が楽だと考えています。

おわりに

・AsyncValueを管理する共通クラスを作る
・複数のAsyncValueを単一のAsyncValueとして扱う
この2点によってどの画面も簡素で均質なコードになり、扱いが楽になると思います。この記事がAsyncValueの使い方について悩んでいた方の一助になれば幸いです。

今回の記事で紹介したコードは以下のリポジトリにて公開しているので、全文が気になる方はこちらを確認してください。
https://github.com/santa112358/async_value_practical_example
また、記事の内容についての意見や改善案、質問等などは広く歓迎しておりますので、何か思いついた方はお気軽にコメントしてください!

参考(@bannzaiさんの資料が大変参考になりました🙇)

bannzaiさんはこの記事をきっかけにasync_value_groupをアップデートしていただいたり、何から何までお世話になりました。記事の内容が参考になったという方は、bannzaiさんのリポジトリにスターをしていただけると幸いです⭐️
https://github.com/bannzai/async_value_group/
https://zenn.dev/bannzai/articles/9fc28c2dee312e#async_value_group
https://github.com/rrousselGit/riverpod/issues/67#issuecomment-667055836

Discussion

づだづだ

ValueKey で、それぞれのステータスのウィジェットの runtimeType が同じだった時の考慮されてるのもいいですね!!
参考にします!!