🎯

サンプルアプリから学ぶ実践的なRiverpodの使い方

2023/04/09に公開

はじめに

本記事では Riverpod v2 の pub.dev のクライアントサンプルアプリと youtube に上がっているFlutter Vikings 2022 のライブコーディングから学んだ実践的な Riverpod v2 の使い方をまとめられればと思っています。もしお時間がある方は動画を視聴されることをオススメします。

動画の冒頭では Riverpod v2 と同時にリリースされた Riverpod generator の導入の経緯について Remi さんが語られていたので、まずはそこに触れたいと思います。

Riverpod には、ProviderStateNotifierProviderFutureProviderなど、様々な種類のプロバイダーが存在しています。このようにプロバイダーの種類が多いため、初学者にとってはどのプロバイダーを使えばよいかがわかりにくいという問題がありました。この問題に対して、そもそもプロバイダーの選択を不要とする革新的なシンタックスの検討が進められ、「Riverpod generator」が導入されたようです。

ちなみに公式のWhy use code generation with Riverpod?でも導入の背景が説明がされているので併せて確認してみてください。

この記事では、サンプルアプリにフォーカスするため、基本的な説明については省略します。基礎知識を学びたい方は、以下の記事が非常に良くまとまっているので、ぜひ読んでみてください。
https://tech.enechain.com/migrate-to-riverpod-v2-79284a0b176b

サンプルアプリのリーディング

それでは pub.dev のクライアントサンプルアプリを読んでいきましょう。
https://github.com/rrousselGit/riverpod/tree/master/examples/pub

アプリは以下の 2 つのページから成ります。

  • サーチページ
  • 詳細ページ

これらの機能を実現するためのテクニックについてまとめていきます。

サーチページ

サーチページで取り上げるのは以下の 3 つです。

  • ページネーションを考慮した無限スクロール
  • TextFieldに入れた言葉を元に検索
  • 上スクロールでの Refresh
ページネーションを考慮した無限スクロール

pub.dev のパッケージ一覧を保持する Family Provider を定義します。これは Refresh されない限り値を変更する必要がないため、変更可能な状態を保持しないプロバイダとして関数型で定義されます。


Future<List<Package>> fetchPackages(
  FetchPackagesRef ref, {
  required int page,
  String search = '',
}) async {
// 略
}

Family を利用して、下にスクロールされた際に適切なタイミングで新しくプロバイダを生成し、無限スクロールを実現しています。

child: ListView.custom(
  padding: const EdgeInsets.only(top: 30),
  childrenDelegate: SliverChildBuilderDelegate((context, index) {
    // APIの仕様上定まっているpackageListの最大の要素数
    final pageSize = searchController.text.isEmpty
        ? packagesPackageSize
        : searchPageSize;

    final page = index ~/ pageSize + 1;
    final indexInPage = index % pageSize;
    final packageList = ref.watch(
      // Familyを使ってページごとの一意のプロバイダを生成
      fetchPackagesProvider(
        page: page,
        search: searchController.text,
      ),
    );

    return packageList.when(
      error: (err, stack) => Text('Error $err'),
      loading: () => const PackageItemShimmer(),
      data: (packages) {
        // packages[indexInPage]が存在しないのでアイテムを表示しない
        if (indexInPage >= packages.length) return null;

        final package = packages[indexInPage];

        return PackageItem(
          name: package.name,
          description: package.latest.pubspec.description,
          version: package.latest.version,
          onTap: () => Navigator.push(
            context,
            MaterialPageRoute<void>(
              builder: (context) {
                return PackageDetailPage(
                  packageName: package.name,
                );
              },
            ),
          ),
        );
      },
    );
  }),
),
TextField に入れた言葉を元に検索

ここでは Hooks を使ってTextEditingControllerを作成しListenableにすることで入力された文字列の変更を Listen して Widget をリビルドさせています。そして、外部パラメータとして Family に付与することでプロバイダを生成します。

final searchController = useTextEditingController();
useListenable(searchController); // searchControllerの変更をListenしてWidgetをリビルド

// 略

final packageList = ref.watch(
  fetchPackagesProvider(
    page: page,
    search: searchController.text,
  ),
);

このように検索フィールドの入力値をプロバイダに渡すのでメモリリークが心配になりますが@riverpodアノテーションでは、デフォルトでこちらの節で公式で推奨している.family.autoDisposeの併用になっているため問題になりません。

上スクロールでの Refresh

状態を破棄して更新をかける関数 invalidate を使うことで Refresh が実現されています。これによりキャッシュを破棄してpackagesを再取得することができます。再取得のFutureを返す関数をonRefreshに渡すことで再取得中に Indicator を表示し続けるようにしています。

child: RefreshIndicator(
  onRefresh: () {
    // 以前に取得したページを破棄
    ref.invalidate(fetchPackagesProvider);
    // ページが取得されるまで、RefreshIndicatorを継続して表示
    return ref.read(
      fetchPackagesProvider(page: 1, search: searchController.text)
          .future,
    );
  },
  child: ListView.custom(
    // 略
  )
),

詳細ページ

詳細ページで取り上げるのは以下の 3 つです。

  • パッケージの詳細の表示
  • Like 機能
  • ポーリングによるリアルタイム同期
パッケージの詳細の表示

パッケージの詳細を表示するのに、下記の 3 つのデータをProviderから取得しています。

  • package: パッケージの詳細データ
  • likedPackages: Like をしているパッケージの一覧データ
  • metrics: パッケージのメトリックスデータ
class PackageDetailPage extends ConsumerWidget {
  const PackageDetailPage({super.key, required this.packageName});

  final String packageName;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final package =
        ref.watch(fetchPackageDetailsProvider(packageName: packageName));

    final likedPackages = ref.watch(likedPackagesProvider);
    final isLiked = likedPackages.valueOrNull?.contains(packageName) ?? false;

    final metrics = ref.watch(packageMetricsProvider(packageName: packageName));

    return Scaffold(
      appBar: const PubAppbar(),
      body: package.when(
        error: (err, stack) => Text('Error2 $err'),
        loading: () => const Center(child: CircularProgressIndicator()),
        data: (package) {
          return RefreshIndicator(
            onRefresh: () {
              return Future.wait([
                ref.refresh(
                  packageMetricsProvider(packageName: packageName).future,
                ),
                ref.refresh(
                  fetchPackageDetailsProvider(packageName: packageName).future,
                ),
              ]);
            },
            child: metrics.when(
              error: (err, stack) => Text('Error $err'),
              loading: () => const Center(child: CircularProgressIndicator()),
              data: (metrics) {
                // 略

個人的には 余計な層を挟まずに 3 つのProviderからそれぞれのデータをConsumerWidget内で取得しているところが Riverpod っぽいところだと感じています。また、.whenを入れ子構造として使用することで 2 つの非同期処理をハンドリングしている点もポイントだと思います。

Like/Unlike 機能

Like(以下 Unlike 省略) 機能を実装するにあたって、下記が必要です。

  • Like 押下時に Icon が塗りつぶされる → likedPackagesProviderのリフレッシュ
  • Like 押下時に Like 数に反映される → packageMetricsProviderのリフレッシュ

クラスのプロバイダーとして定義されているPackageMetricslikeメソッドを用意し、この中で like をリクエストすると同時に上記 2 つのプロバイダのリフレッシュを行なっています。

Future<void> like() async {
  // likeをリクエスト
  await ref.read(pubRepositoryProvider).like(packageName: packageName);

  // packageMetricsProviderのリフレッシュ
  ref.invalidateSelf();

  // likedPackagesProviderのリフレッシュ
  ref.invalidate(likedPackagesProvider);
}

こちらのlikeメソッドはonPressedメソッドの中で呼ばれますが、この際packageMetricsProvider.notifierをつけてクラスのインスタンス自体にアクセスすることで呼び出されます。

floatingActionButton: FloatingActionButton(
  onPressed: () async {
    // .notifier をつけることでクラスのインスタンス自体にアクセスできる
    // ちなみに、.notifier をつけない場合はAsyncValueにアクセス
    final packageLikes = ref.read(
      packageMetricsProvider(packageName: packageName).notifier,
    );

    if (isLiked) {
      await packageLikes.unlike();
    } else {
      await packageLikes.like();
    }
  },
  child: isLiked
      ? const Icon(Icons.favorite)
      : const Icon(Icons.favorite_border),
),
ポーリングによるリアルタイム同期

たとえば、アプリを操作中に Web 版で Like が押された場合には、リアルタイム同期によりメトリックスの最新値を反映させる必要があります。このサンプルアプリではポーリングによりリアルタイム同期が実現されています。そこでもinvalidateが使用されています。

Future<PackageMetricsScore> build({required String packageName}) async {
  await Future.delayed(Duration(seconds: 10));
  final metrics = await ref
      .watch(pubRepositoryProvider)
      .getPackageMetrics(packageName: packageName);

  // 5秒後にリフレッシュ。再度 build メソッドが呼ばれて metrics を取得。
  // これが繰り返されてポーリングが実現される
  final timer = Timer(const Duration(seconds: 5), () => ref.invalidateSelf());

  ref.onDispose(timer.cancel);

  return metrics;
}

さいごに

サンプルアプリを通じて、Riverpod の基本的な機能と実践的な使い方を学ぶことができました。特にこちらの記事で monos さんが言及されている通りref.invalidateを用いたテクニックを知ることができました。

今後も Riverpod の開発が進む中で新しい機能や改善が加わる可能性があるため、公式ドキュメントやコミュニティの情報を追いかけていこうと思います。

参考文献

https://www.youtube.com/watch?v=CzHt_uwmlXM&t=22s

https://tech.enechain.com/migrate-to-riverpod-v2-79284a0b176b

https://zenn.dev/k9i/articles/2159e248505f60

https://zenn.dev/riscait/books/flutter-riverpod-practical-introduction/viewer/riverpod-generator

GitHubで編集を提案

Discussion