はじめに
enechain でソフトウェアエンジニアをしている@kkagurazakaです。
enechain が提供している eSquare というプロダクトは、電力を筆頭に、あらゆる企業が様々なエネルギー商品を売り買いできるオンライントレーディングプラットフォームです。そして、そのモバイルアプリは Flutter で開発されています。
今回の記事では、eSquare のモバイルアプリで採用されており、Flutter 開発で広く使われている状態管理 & DI ライブラリである Riverpod について取り上げます。そんな Riverpod ですが、2022 年 10 月に初のメジャーバージョンアップである v2 がリリースされました 🎉
enechain のアプリも v2 に対応済みで、無事に運用できています。本記事ではこの Riverpod を v2 にアップデートするにあたり、参考となる情報をお伝えします。
ビルドを通すまで
実のところ、v2 では破壊的変更はもちろんあったものの、自前で Provider を実装したりするなどの深い使い方をしていない限りは影響はかなり少なくなっています。v2 での変更点は コチラ に列挙されていますが、その中では
AsyncError.stackTrace
is now a required positional parameter and non-nullable.- The
Reader
typedef is removed. UseRef
instead. ProviderListener
is removed. Usedref.listen
instead. TheFamily
type now has a single generic parameter instead of 3.
あたりの変更が引っかかりやすいところかと思います。これらについては代替手段がある API 変更なため、単に置き換えていけば多くの場合問題ないでしょう。
実際、我々のアプリでは修正が必要だったのは 1 番目の
AsyncError<T>
のコンストラクタでstackTrace
が named parameter から positional parameter に変更された
というものだけでした。
v2 時代の New Normal
それでは Riverpod v2 はこれら API breaking change のための些細なアップデートなのかというと、そんなことはありません。これまでの Riverpod とはまったく異なる新しいパラダイムが導入されています。
それが Riverpod generator です。Riverpod generator ではアノテーションを付与することでプロバイダーをコード生成させます。
Set up
まずは dependencies にいくつかのパッケージを追加する必要があります。
dependencies: flutter_riverpod: riverpod_annotation: # 追加! dev_dependencies: build_runner: # (未利用なら)追加! riverpod_generator: # 追加!
既に利用されている方も多いと思いますが、 build_runner は Dart でコード生成を実現するためのパッケージです。
How to use
基本
それではさっそくコード例を見ていきましょう。例えば次のような Provider.autoDispose
なプロバイダーの場合
final helloProvider = Provider.autoDispose((ref) => 'Hello world!');
以下のアノテーション付きの単なる関数で記述できます。
@riverpod String hello(HelloRef ref) => 'Hello world!';
この関数を書いたあと build_runner
を走らせると、次のようなコードが生成されます。
final helloProvider = AutoDisposeProvider<String>( hello, name: r'helloProvider', );
生成されたプロバイダーはもちろんこれまでと同様に利用できます。
final greet = ref.watch(helloProvider); // greet == 'Hello world!'
Auto dispose
生成された helloProvider
を見ると AutoDisposeProvider
になっていることがわかります。@riverpod
アノテーションは Provider.autoDispose
なプロバイダーになるため、インスタンスを生存させたい場合は
@Riverpod(keepAlive: true) String hello(HelloRef ref) => 'Hello world!';
のように @Riverpod
アノテーションの keepAlive
引数に true
を渡します。すると生成されるコードは
final helloProvider = Provider<String>( hello, name: r'helloProvider', );
のように通常の Provider
になります。
Family
Riverpod generator では Provider.family
の書き味が向上しています。これまでの書き方では複数のパラメータを family
に設定したい場合、それをまとめた Param
クラスを作成する必要がありました。
class Param { const Param({required this.id, required this.name}); final String id; final String name; } final withParamProvider = Provider.autoDispose.family<String, Param>((ref, param) { return "id: ${param.id}, name: ${param.name}"; });
一方で、generator の書き方では次のようになります。
@riverpod String withParam( WithParamRef ref, { required String id, required String name, }) => "id: $id, name: $name"; // Providerにパラメータを渡して呼び出す ref.watch(withParamProvider(id: 'id', name: 'name'));
渡したいパラメータが引数に追加されるという、非常に自然な記法になっています。また、 @riverpod
アノテーションを付与した関数が named parameter を受け付ければプロバイダーも named になり、positional parameter を受け付ける場合はプロバイダーも positional となります。
パラメータを複数にしたいシチュエーションはそこまで多くないとはいえ、わざわざクラスを作る手間を削減できるのは嬉しいところです。またパラメータが 1 つであったとしても「引数を追加したい場合は family
を使って……最初の型パラメータがプロバイダーが返す型で、2 番目がパラメータの型で……」と考えるより「この型がパラメータに必要だから引数に足したろ」のほうが認知的な負荷が低いでしょう。
Future
FutureProvider
についても非常にシンプルです。これまでの書き方は次のようでした。
final delayProvider = FutureProvider.autoDispose((ref) async { await Future.delayed(const Duration(milliseconds: 100)); return 'Future!'; });
Riverpod generator ではこうなります。
@riverpod Future<String> delay(DelayRef ref) async { await Future.delayed(const Duration(milliseconds: 100)); return 'Future!'; }
通常のプロバイダーと比較すると、戻り値の型が変わっているだけです。このように Riverpod generator では「この型の場合はこのプロバイダーを使う」というような知識は必要なく、単に提供 (provide) したいインスタンスを返す関数にアノテーションをつけるだけという哲学で設計されています。
そのため、例えばパラメータを受け取ってインスタンスを生存させたい場合は、これまで紹介した方法をそのまま使って
@Riverpod(keepAlive: true) Future<String> delay(DelayRef ref, int millis) async { await Future.delayed(Duration(milliseconds: millis)); return 'Future!'; }
と書けます。
状態操作可能なプロバイダー群
これまで紹介してきたプロバイダーは、いったん値が確定してしまうと (refresh を除き) その値を変更できないものでした。それでは StateProvider
StateNotifierProvider
ChangeNotifierProvider
といった変更可能な状態を保持するプロバイダーはどう書けばよいのかと疑問に思われていることでしょう。
Riverpod generator では、このユースケースはすべてクラスへのアノテーション付与で対応します。具体的な例を見てみましょう。次のコードはサンプルでよく出てくるカウンターの StateNotifierProvider
です。
final counterProvider = StateNotifierProvider((ref) => Counter()); class Counter extends StateNotifier<int> { Counter() : super(0); void increment() => state++; }
Riverpod generator では次のように書きます。
@Riverpod(keepAlive: true) class Counter extends _$Counter { @override int build() => 0; void increment() => state++; }
build()
は初期状態を提供するメソッドです。関数にアノテーションを付けた時と同様、 counterProvider
がコード生成されます。使い方は StateNotifierProvider
とまったく一緒で、 ref.watch(counterProvider)
でカウントの値が取得でき、 ref.read(counterProvider.notifier).increment()
でインクリメントさせられます。
生成されたプロバイダーの実装がどうなっているかというと次のとおりです。
final counterProvider = NotifierProvider<Counter, int>( Counter.new, name: r'counterProvider', ); abstract class _$Counter extends Notifier<int> { @override int build(); }
何やら Notifier
なる見慣れないクラスが存在しています。これは Riverpod v2 で新規に導入された StateNotifier
ChangeNotifier
を代替するクラスです。
Notifier
それでは Notifier
は StateNotifier
などと何が異なるのでしょうか。 StateNotifier
は Riverpod と同一の作者が作成した、状態変更を通知するステートホルダーを提供するパッケージです。Riverpod とは別パッケージのため、独立して動作するように設計されています。一方で Notifier
は Riverpod 組み込みのクラスであるため、より親和性が高く設計されています。次の例を見てみましょう。
@riverpod class SomeStateHolder extends _$SomeStateHolder { late SomeRepository _repository; @override int build({required int initial}) { _repository = ref.watch(someRepositoryProvider); return initial; } }
Notifier
は ref
プロパティを持っており、 build
メソッド内で他プロバイダーを watch
できます。また、アノテーション付き関数と同様に build
メソッドに引数を追加すると family
なプロバイダーが生成されます。また、初期値が非同期に定まる状態についても簡単に取り扱えます。
@riverpod class SomeStateHolder extends _$SomeStateHolder { late SomeRepository _repository; @override Future<int> build() async { _repository = ref.watch(someRepositoryProvider); return await _repository.getCount(); } }
build
メソッドが Future<T>
を返すようにするだけです。このコードから生成された someStateHolderProvider
は AsyncValue<int>
型を状態に持つプロバイダーになります。
Stream
さて、これまで紹介する中で意図してスルーしていたプロバイダーがあります。それが StreamProvider
です。このプロバイダーは記事執筆時点ではサポートされていません。
@riverpod Stream<int> increment(IncrementRef ref) async* { for (var i = 0; i < 3; i++) { await Future.delayed(const Duration(milliseconds: 100)); yield i; } }
ちなみにビルド&コード生成は通ってしまう点に注意してください。Stream<int>
を保持する、単なる Provider として扱われます。
もちろん Riverpod の作者もこの点は認識しており、この issueにて現在未サポートなプロバイダーの generator 対応状況をトラッキングできます。
Riverpod generator に移行すべきか
これまで紹介したように、Riverpod generator 時代の書き方はこれまでのプロバイダーの定義方法とはかなり異なります。移行には一定のコストがかかりますが、それでも移行すべきでしょうか?
これはあくまで筆者の考えですが、移行すべきでしょう。
Riverpod v2 は現在ドキュメントは準備中のステータスであり、プレビュー版がこちらから参照できます。そしてこの記事を執筆している 2023 年 1 月頭現在、トップページに記載されている使い方のコード例は Riverpod generator を使ったものになっています。また、Riverpod generator の README には
This project is entirely optional. But if you don't mind code generation, definitely consider using it over the default Riverpod syntax.
(筆者訳) Riverpod generator は完全にオプショナルな機能です。しかしながら、コード生成のアプローチが気に食わないのでなければ、まず通常の記法より Riverpod generator を使うことを検討してください。
という記載もあります。その他にも、Riverpod generator は StateNotifier
ChangeNotifier
もサポート予定ですが、作者はあくまで Notifier
への移行のための中間的な選択肢として捉えている節が見受けられます。そのため、Riverpod generator 移行と同時に StateNotifier
脱却を進めるのもオススメです。
まとめ
- Riverpod v2 はビルドを通すだけなら簡単にアップデートできます
- 新記法である Riverpod generator が導入されているので移行を検討しましょう
- 関数・クラスにアノテーションを付けるだけなので、シンプルでわかりやすくなっています