Riverpod v2へのアップデートガイド

ogp

はじめに

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. Use Ref instead.
  • ProviderListener is removed. Used ref.listen instead. The Family 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

それでは NotifierStateNotifier などと何が異なるのでしょうか。 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;
  }
}

Notifierref プロパティを持っており、 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> を返すようにするだけです。このコードから生成された someStateHolderProviderAsyncValue<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 が導入されているので移行を検討しましょう
    • 関数・クラスにアノテーションを付けるだけなので、シンプルでわかりやすくなっています