サンプルアプリから学ぶ実践的なRiverpodの使い方
はじめに
本記事では Riverpod v2 の pub.dev のクライアントサンプルアプリと youtube に上がっているFlutter Vikings 2022 のライブコーディングから学んだ実践的な Riverpod v2 の使い方をまとめられればと思っています。もしお時間がある方は動画を視聴されることをオススメします。
動画の冒頭では Riverpod v2 と同時にリリースされた Riverpod generator の導入の経緯について Remi さんが語られていたので、まずはそこに触れたいと思います。
Riverpod には、Provider
、StateNotifierProvider
、FutureProvider
など、様々な種類のプロバイダーが存在しています。このようにプロバイダーの種類が多いため、初学者にとってはどのプロバイダーを使えばよいかがわかりにくいという問題がありました。この問題に対して、そもそもプロバイダーの選択を不要とする革新的なシンタックスの検討が進められ、「Riverpod generator」が導入されたようです。
ちなみに公式のWhy use code generation with Riverpod?でも導入の背景が説明がされているので併せて確認してみてください。
この記事では、サンプルアプリにフォーカスするため、基本的な説明については省略します。基礎知識を学びたい方は、以下の記事が非常に良くまとまっているので、ぜひ読んでみてください。
サンプルアプリのリーディング
それでは pub.dev のクライアントサンプルアプリを読んでいきましょう。
アプリは以下の 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
のリフレッシュ
クラスのプロバイダーとして定義されているPackageMetrics
にlike
メソッドを用意し、この中で 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 の開発が進む中で新しい機能や改善が加わる可能性があるため、公式ドキュメントやコミュニティの情報を追いかけていこうと思います。
参考文献
Discussion