💌

Flutterアプリケーション開発にRiverpodを僕が使う理由

2023/09/21に公開
5

はじめに

Flutterにおける状態管理の手法は、数多く存在します。
筆者は、その中でもRiverpodを好んで利用しています。最近はFlutterKaigi 2023の公式アプリでも採用しました。

https://riverpod.dev/

https://docs-v2.riverpod.dev/

このようにRiverpodを採用していると、「Riverpodの勉強方法はどのようにすればいいのか」とか、「便利さがよく理解できない」という声をよく聞きます。勉強方法に関しては、公式ドキュメントを読むのが一番です。とはいえ、急に公式ドキュメントを読むのはハードルが高いかもしれません。

本記事では、筆者がRiverpodを好んで使う理由をまとめます。「なぜRiverpodを便利だと思う人がいるのか」を知ることで、Riverpodへの関心を高めてもらえれば幸いです。

TL;DR

文章が長くなってしまったため、先に結論を簡潔に。書き始めた時には、1万字を超えるとは思っていませんでした…。
以下の4つのポイントが、筆者が感じるRiverpodのメリットです。

  • RiverpodはシンプルにLifting state upができる
  • Riverpodは適切なスコープで状態の共有と破棄ができる
  • Riverpodは簡単にDependency Injectionが実現できる
  • Riverpodはコード自動生成により、共有したい状態の形式をFunctionClassの書き方で簡単に実現できる

Flutterにおける状態管理

Flutterを利用したアプリケーション開発において、状態管理は常に議論されるトピックです。とはいえ、近年ではどの手法も洗練されてきています。どの手法を選んだとしても、大きな失敗はないはずです。
Flutterの公式ドキュメントには、状態管理手法を紹介する一覧があります。全てを動かして確認するのは難しい量の、さまざまな手法が紹介されています。

https://docs.flutter.dev/data-and-backend/state-mgmt/options

どのライブラリを選ぶかは、絶対的な正解はありません。おそらく、将来的にも正解は存在しないと思います。
ありきたりな表現になりますが、利用する人にとって、利用しやすいものを使うのがよいでしょう。

というのもの、どのライブラリを採用しても、Flutter Widgetに紐づくState(状態)の管理というテーマに取り組む必要があります。どのライブラリを選んでも本質的には違いがないはずです。
ライブラリの違いは、複数のWidgetを跨いだStateの管理の取り組みかたの違いになります。本質的な課題に対して、さまざまなアプローチがある、というのが筆者の理解です。

この前提を押さえた上で、これから「複数のWidgetを跨いだ状態のあり方や管理のされ方」について、各論を確認していきます。ここからが本題です。

Lifting state up

FlutterのテンプレートにあるCounter Appのようなシンプルなアプリケーションでは、StatefulWidgetによる状態管理で十分です。
Counter Appのような小さなアプリケーションでは、Riverpodなどのライブラリを利用する必要はありません。「1つの画面」や「1つのWidget」で管理するべき状態が完結しており、「複数のWidgetを跨いだ状態のあり方や管理のされ方」を考慮する必要がない、できないためです。


Flutterの公式ドキュメントでは、2つの画面で1つのcartを共有するアプリケーションを例に、Lifting state upという考え方を紹介しています。これは「2つのページにまたがる状態の管理」ケースを例にとって、Lifting state upの解説をしていると言えます。

Flutteのアプリケーションは、Widgetの組み合わせで構成されます。
これが何を意味するかといえば、公式サンプルで紹介されている「ページとページ」の関係性は、「ページ内の要素と要素」の関係性に置き換えて考えることができる、ということです。ページを構成するWidget同志の関係が、ページとページ内のWidgetの関係と(抽象的に考えると)同じということです。
このため、特にAndroid ViewやiOS UIKitのように、画面単位とその内部要素で議論が分かる分野と考え方が異なってきます。Flutterにおいては、それこそMaterialAppからTextまで、どのようなWidgetの間でも同じ状態管理の考え方を利用します。
つまりLifting state upは、Flutterアプリケーションの中ではどこでも利用される仕組みです。

https://docs.flutter.dev/data-and-backend/state-mgmt/simple#lifting-state-up

Why? In declarative frameworks like Flutter, if you want to change the UI, you have to rebuild it. There is no easy way to have MyCart.updateWith(somethingNew). In other words, it’s hard to imperatively change a widget from outside, by calling a method on it. And even if you could make this work, you would be fighting the framework instead of letting it help you.

なお、Lifting state upの考え方は、Reactのドキュメントにも認めることができます。
コアな考え方は参考にできると思うので、考え方がしっくりこない場合には、Reactの議論や解説を読んでみると良いかもしれません。

https://react.dev/learn/sharing-state-between-components

Sometimes, you want the state of two components to always change together. To do it, remove state from both of them, move it to their closest common parent, and then pass it down to them via props. This is known as lifting state up, and it’s one of the most common things you will do writing React code.


InheritedWidgetやProviderを利用することで、Flutterのフレームワークが用意した仕組みを利用したLifting state upを実現できます。InheritedWidgetやProviderを比べると、Providerを利用する方が実装しやすく、色々なミスを回避できます。このため、現実的にはProviderを採用することになるはずです。

https://pub.dev/packages/provider

A wrapper around InheritedWidget to make them easier to use and more reusable.

強調しておきたいことは、InheritedWidgetを利用すればLifting state upは実現可能である、と言う点です。Flutterというフレームワークそのものが、Lifting state upをサポートしていると言っても、過言ではありません。(たぶん。)

Lifting state upSingleton

もしかすると、Singletonによる状態管理の実現が気になるかもしれません。こちらは、(Androidで安定しきるか疑問はちょっとありますが、)Dartの言語機能を利用するだけで実現できる手法です。
しかし、筆者の知る限り、Singletonをアプリケーションの主要な状態管理に利用するケースはあまりありません。


アプリケーション全体で共有したいライブラリのインスタンス管理では、Singletonが頻出します。たとえばshared_preferencesfirebase_analyticsでは、それぞれのインスタンスをSingletonで管理しています。ここには列挙していないライブラリでも、無数にSingletonが利用されています。

一方、アプリケーションの状態管理においては、Singletonはそこまで利用されません。
筆者がざっと読んだ範囲になりますが、たとえばService Locatorパターンを実現するget_itは、Singletonを利用しています。しかしflutter_reduxmobxなどの実装例を見ても、Singletonは登場しません。どちらかといえば、これらのライブラリではLifting state upを実現することを目指しています。

この理由について、詳しくないため筆者は意見を述べることができません。印象としては、たまたまSingletonを採用したいという強いモチベーションを持つ人がいないだけかな、と思っています。このあたりに詳しい方がいましたら、ぜひ教えてください!

Riverpodのモチベーション

Riverpodはどのようなモチベーションで開発されたのでしょうか? Riverpodなぜ開発されたかを知ることは、利用する開発者にも意味があります。

というのも、Riverpodが開発される前の時点で、Providerが一定以上の人気を博していました。先述の通り、ProviderはInheritedWidgetをより便利にしたものです。仕組みそのものがFlutterのフレームワークが提供するものになるため、ある意味で、これ以上の統合性はない選択肢になります。2023年現在では、ProviderはFlutterの公式ドキュメントに最初に紹介される状態管理手法となりました。
またProviderとRiverpodは、両方ともRemi Rousselet氏が開発しています。一度Provider作成したものを、同じ作者が熱意を持って開発できるでしょうか…?


Riverpodのモチベーションの項目には、次のように記載されています。

https://pub.dev/packages/riverpod#motivation

If provider is a simplification of InheritedWidgets, then Riverpod is a reimplementation of InheritedWidgets from scratch.

ドキュメントには複数の「Providerと同じ目的」と「Providerと異なる目的」が記載されています。以下にいくつか引用しますが、非常に興味深いため、ぜひ全文を読んでみてください。

  • (Providerと同じく)複数のInheritedWidget(状態)を読む際にコードが読みやすくなること
  • (Providerと同じく)Unidirectinal data flowによるスケール可能なアプリを構築すること
  • (Providerと違って)参照のエラーが実行時ではなくビルド時に発生すること
  • (Providerと違って)Flutterの仕組みから独立していること

このモチベーションに共感できるかどうかが、Riverpodを採用するかどうかの1つの基準になるかもしれません。

Riverpodをなぜ採用するのか

筆者としては、Riverpodがどのような条件でも採用されるべき、とは考えていません。これは開発するアプリケーションの仕様、そして開発するメンバーのスキルや好みによって、状態管理の手法を選択すべきだと考えているからです。

筆者がRiverpodを採用することに好意的な理由には、Flutterの開発に入る前にKotlinによるAndroidアプリケーションの経験があることが挙げられます。

AndroidにおけるDaggerを使ったアプリケーションの構築に馴染みがあり、この手法による開発に違和感がありません。また、StateFlowLiveDataなどを利用した、リアクティブなUIの更新にも親しみがあります。もちろん、Kotlin data classを利用した、状態の定義にも慣れています。

本記事の筆者にこれらの前提がある上で、あらためて「なぜ採用するのか」を考えると、以下の3つの理由が挙げられます。

  1. スコープを適度に調整したLifting state upの実現
  2. Dependency Injectionの実現
  3. riverpod_generatorによるFunction定義とClass定義によるProviderの実現

それぞれについて、補足を加えながら説明していきます。

スコープを適度に調整したLifting state upの実現

Riverpodを利用すると、Lifting state upで共有したい状態の生成と破棄タイミングを、自然な実装でコントロールできます。


Riverpodには、.autoDisposeを設定することで、AutoDisposeがなされるProviderを生成する仕組みがあります。AutoDisposeの設定がなされたProviderは、Providerの参照が全てなくなったタイミングで、自動的に破棄されます。結果として、不要になったProviderがメモリ上に残り続けることがありません。

この仕組みはよくできており、単に「参照されなくなったら破棄される」と書くだけでは足りない面があります。一例として、1つのProviderを2つのWidget、たとえば親Widgetと子Widgetで参照している場合がサンプルケースになります。AutoDisposeなProviderは、子Widgetの破棄されるとき、親Widgetが参照していれば破棄されません。もちろん、親Widgetと子Widgetが同時に破棄されれば、Providerは破棄されます。この制御はFlutterでWidgetのtreeを構築する、つまり普通のFlutterのコードの中に、ref.watchの記述が差し込まれるだけで実現されます。親のWidgetでref.watch、子のWidgetでref.watchを呼び出す、これだけです。

簡単にサンプルコードを書くと、以下のようになります。

final userNameProvider = Provider.autoDispose((ref) => 'John Doe');

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final userName = ref.watch(userNameProvider);
    return Column(
      children: [
        Text('User, $userName'),
        ChildWidget(),
      ],
    );
  }
}

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final userName = ref.watch(userNameProvider);
    return Text('Name Length, ${userName.length}');
  }
}

なお.autoDisposeがどのように実現されているかについては、以下の記事を参照してください。RiverpodがAutoDisposeを実現するために、どのような工夫がなされているかを把握することで、より安心しながら利用できるようになります。

https://zenn.dev/koji_1009/articles/fa972b070eb2f4

上のケースでは、親Widgetと子Widgetで同じProviderを参照していました。当然ではありますが、1つのWidgetで複数のProviderを参照することもできます。

final userNameProvider = Provider.autoDispose((ref) => 'John Doe');
final ageProvider = Provider.autoDispose((ref) => 20);

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final userName = ref.watch(userNameProvider);
    final age = ref.watch(ageProvider);
    return Column(
      children: [
        Text('User, $userName'),
        Text('Age, $age'),
      ],
    );
  }
}

この「やりたいことを書くだけで、複数のProviderの生成と破棄タイミングが管理され、簡単に複数のProviderを参照できる」点は、InheritedWidgetやProviderよりも優れていると感じます。
とりわけ宣言的Navigationを採用する場合には、強力なサポートを得られます。

Dependency Injectionの実現

Riverpodでは、Provider内部で別のProviderを参照できます。このため、Riverpodで管理している状態を、他のProviderの生成時に利用できます。一例としては、レイヤードアーキテクチャにおけるRepositoryの生成時に、APIクライアントをProvider経由で差し込むケースです。
APIクライアントがユーザーの認証状態に依存している場合には、認証が切り替わるたびにRepositoryが自然に再生成されるようになるなど、Providerの依存関係を利用した設計が可能になります。下の例ではuserTokenを差し替える仕組みを追加することで、認証状態によってRepositoryが切り替わるようになっています。もちろん、それぞれの単体テストも容易になります。

final userToken = Provider.autoDispose((ref) => 'token');

final apiClient = Provider.autoDispose((ref) => ApiClient(
  token: ref.watch(userToken),
);

class ApiClient {
  const ApiClient({
    required this.token,
  });

  final String token;

  Future<Response> get() async {
    ~~~
  }
}

final userRepository = Provider.autoDispose((ref) => UserRepository(
  apiClient: ref.watch(apiClient),
);

class UserRepository {
  const UserRepository({
    required this.apiClient,
  });

  final ApiClient apiClient;

  Future<User> fetch() async {
    ~~~
  }
}

また、Providerの値を実行時に差し替えることも可能です。基本的にはテスト時にMockを差し込む用途で利用しますが、interfaceと実装の差し替えにも利用できます。Androidにおけるマルチモジュール構成で話題になる、画面遷移ロジックの差し替えなどで、利用するケースがあるかもしれません。
なお、差し替え自体は作者もあまり推奨をしていないことには、一定の注意を払う必要があります。標準的な実装からは離れることになるので、各APIのドキュメントをよく読んで、利用する必要があります。

https://twitter.com/remi_rousselet/status/1647902120123809792

ここでは、上記の事情もあるためサンプルコードは割愛します。

riverpod_generatorによるFunction定義とClass定義によるProviderの実現

Riverpodには、riverpod_generatorriverpod_lintといった、活用を助ける様々なライブラリが存在します。作者には感謝しかありません。
このうちriverpod_generatorは、Riverpodの理解を助け、より活用しやすくなる重要なライブラリです。

https://docs-v2.riverpod.dev/docs/concepts/about_code_generation#should-i-use-code-generation

The answer is: Most likely Yes.
Using code generation is the recommended way to use Riverpod. It is the more future-proof approach and will allow you to use Riverpod to its full potential.
At the same time, many applications already use code generation with packages such as Freezed or json_serializable. In that case, your project probably is already set up for code generation, and using Riverpod should be simple.

2023年現時点では、DartにはStatic Metaprogrammingの機能が備わっていません。このため、riverpod_generatorはbuild_runnerを利用して、コード生成を行っています。こう言った事情があり、「riverpod_generatorはRiverpodのコードを自動で生成するツール」と見られがちです。しかし、筆者としては、これは「RiverpodにMetaprogrammingを導入するツール」であると見なすべきだと考えています。


Riverpodはv2において、以下の5つのProviderやNotifierに要素を整理しました。それぞれ.autoDisposeの有無や.familyによる拡張はありますが、ここでは割愛します。

  • Provider
  • FutureProvider
  • StreamProvider
  • Notifier
  • AsyncNotifier

ProviderFutureProviderStreamProviderは「外部から状態の変更を想定しない」もの。そしてNotifierAsyncNotifierは「外部から状態の変更を想定する」ものです。これは、Providerが「FunctionをProvideしているもの」、そしてNotifierが「ClassをProvideしているもの」と言い換えできます。

riverpod_generatorを利用しない場合、この2つの違いはコードの記述から伺いにくくなります。例えば、典型的なProviderの記述を見てみましょう。

final userNameProvider = Provider.autoDispose((ref) => 'John Doe');

この記述を素直に読むと、userNameProviderはglobalな変数であり、'John Doe'という値を保持しているように見えます。しかし、実際はref.watch(userNameProvider)を呼び出したタイミングで、'John Doe'という値を返すFunctionが実行されるだけであり、userNameProviderはglobalな変数ではありません。

この処理をriverpod_generatorを利用して書き換えると、以下のようになります。


String userName(UserNameRef ref) => 'John Doe';

この記述を素直に読むと、userNameはglobalな関数であり、実行時に'John Doe'という値を返すように見えます。Riverpodで必要な変数が自動生成されるファイルに押しやられるため、よりコアな処理に着目しやすくなっています。
また、非同期処理に変えたい場合には、次のような修正を行うだけで十分です。書き換え箇所が減ることで、よりシンプルな記述を保ちやすくなります。


Future<String> userName(UserNameRef ref) async => 'John Doe';

続いて、Notifierの記述を見てみましょう。まずはriverpod_generatorを利用しない場合の記述です。

final counterNotifierProvider = NotifierProvider<CounterNotifier, int>(
  CounterNotifier.new,
);

class CounterNotifier extends Notifier<int> {
  
  int build() => 0;

  void increment() {
    state++;
  }
}

続いて、riverpod_generatorを利用した場合の記述です。


class CounterNotifier extends _$CounterNotifier {
  
  int build() => 0;

  void increment() {
    state++;
  }
}

Notifierの場合には、逆にFunctionが自動生成コード側に移動することになり、Classの定義を記述するだけで済むようになります。結果として、更新が必要な状態を利用するときにNotifierを利用する、といったRiverpodのルールも把握しやすくなります。
ルールが把握しやすくなることで、アプリケーションの中でのProviderをどのように利用するかの思想が統一され、コードの可読性が向上することが期待できます。


現時点ではDartにdata classが存在しません。このため、NotifierAsyncNotifierで扱える型は、freezedを利用する必要があります。Record型やequatableを利用することもできますが、stateの更新時にcopyWithの実装が必要になるケースが多いため、現実的には大半の場合でfreezedを利用することになるでしょう。

これらが煩雑さを生んでいる箇所はあるのですが、これはDartの言語機能が不足していることに起因するものであり、Riverpodの問題ではありません。
なおdata classが入ると、ValueNotifierStateNotifierがほぼ同じ機能を提供できるようになります。一方でAsyncNotifierは提供されるとは思えません(AsyncValueを採用するだけですが)。このため、現在Riverpodが提供している5つのProviderNotifierは、data classが入ったとしても運用することになると思われます。

おわりに

ささっと書き上げるつもりで書き出したら、思った以上に時間がかかり、長文となってしまいました。Riverpodの良さを伝えるのは難しいですね…。

あらためての強調になりますが、Riverpodは、Flutterにおける状態管理の手法の1つです。絶対に利用しなければならない手法ではありません。とはいえ、2023年現在のFlutterによるアプリケーション開発では、数多くの面で優れている手法であると考えられます。

是非とも、しっかりとした検討の上で、Riverpodを活用してもらえればと。Flutterを楽しみましょう!

GitHubで編集を提案

Discussion

ぬこ丸ぬこ丸

@riverpodなどのannotationは単純に読みづらくなる&Command + クリックジャンプがしづらく追いづらくなるので、一つ前のバージョンがやはり私は好みです。FutureProviderが最高で属人化しがちなendpointなどのpromiseからModel変換までエラー、ローディングの記法が統一できるのが一番の強みだと思っています。hooksのuseEffectでendpointを呼び出しオレオレStateのような負債になるようなコードが防げる。Riverpodを正しく使っていればRead onlyの画面にStateはいらないということに気がつく(スクロールしてindexをインクリメントのcursorみたいな実装は除く)

koji-1009koji-1009

riverpod_generatorは別ライブラリとして提供されており、現時点ではoptionの扱いになります。このため、riverpod v2でも各Providerを手で記述することは可能です……!
ファイルが分割される点は、ご指摘の通り、開発者が何を重視するかによって判断が別れますね。Static Metaprogrammingが導入されるまでは、トレードオフの関係に着目する必要があります。


FutureProviderは、素晴らしい仕組みだと思っています。
特に、UIに非同期処理の状態や結果を反映させる実装を、一定以上の品質に均してくれる点が好きです。非同期処理が一切ないアプリは想定しにくいので、どの開発チームでもメリットになりうるのかなと。
FYIですが、APIや画面仕様にもよりますが、indexをFutureProvider.familyのextra paramとして活用することもできます。私も実際に採用したことはないのですが、この実装ができるケースであれば、UIでindexを特別に管理する必要もなさそうです。

https://twitter.com/remi_rousselet/status/1553706678213902337

ぬこ丸ぬこ丸

現時点ではoptionの扱いになります

それはそうなんですが、
ドキュメント見る限りかなり強い意志で入れたほうが良さそうなことが書いてありますね。入れないと不都合がおきそうな感じはします。

https://docs-v2.riverpod.dev/docs/concepts/about_code_generation

Should I use code generation?
Code generation is optional in Riverpod. With that in mind, you may wonder if you should use it or not.
The answer is: Most likely Yes.

FYIですが、APIや画面仕様にもよりますが、indexをFutureProvider.familyのextra paramとして活用することもできま
す。私も実際に採用したことはないのですが、この実装ができるケースであれば、UIでindexを特別に管理する必要もなさ
そうです。

Scrollとindexインクリメント部分のみやはりStateなりどこかしらに管理する必要はあるものの(これぐらいviewにかいてもいいかという気持ちもある)
私にとって勉強になりました。しらなかった。Firestore使ってるとFirestoreUIを優先させて使用しているため外部のAPIやるとき役立ちそう。ありがとうございます。

koji-1009koji-1009

それはそうなんですが、
ドキュメント見る限りかなり強い意志で入れたほうが良さそうなことが書いてありますね。入れないと不都合がおきそうな感じはします。

"不都合がおきそう"は、2023〜24年の間であれば、杞憂ではないかなと思われます……!
下記に私がそう考えている理由を記載します。もしもGitHubのIssueなどで将来的な不都合の話が出ていましたら、ぜひ教えていただけますと🙏


前提として、riverpodは複数のライブラリに分割され、開発されています。このため「riverpodライブラリに対して、riverpod_generatorライブラリで機能を追加する」ことは考えづらく、riverpod_generatorを利用しない開発はサポートされ続けるはずです。

Most likely Yes. について。こちら、少し長めに引用します。

Should I use code generation?

Code generation is optional in Riverpod. With that in mind, you may wonder if you should use it or not.

The answer is: Most likely Yes.
Using code generation is the recommended way to use Riverpod. It is the more future-proof approach and will allow you to use Riverpod to its full potential.

It is the more future-proof approach and will allow you to use Riverpod to its full potential.はriverpod_generatorのThis new syntax has all the power of Riverpod, but also:を指していると理解しています。

https://pub.dev/packages/riverpod_generator

This new syntax has all the power of Riverpod, but also:

  • solves the problem of knowing "What provider should I use?".
    With this new syntax, there is no such thing as Provider vs FutureProvider vs ...
  • enables stateful hot-reload for providers.
    When modifying the source code of a provider, on hot-reload Riverpod will re-execute
    that provider and only that provider.
  • fixes various flaws in the existing syntax.
    For example, when passing parameters to providers by using [family], we are limited
    to a single positional parameter. But with this project, we can pass multiple parameters,
    and use all the features of function parameters. Including named parameters, optional
    parameters, default values, ...

列挙されている課題は、Riverpodを学び始めた開発者が、特に混乱しやすい箇所です。またProviderを自前で定義した際には、hot-reloadに対応するためのコードを書くのが煩わしく、開発体験を下げている箇所があります。
これらが解消されることが、Riverpodエコシステムにとって大きなメリットであるので、 Most likely Yes. と推奨していると解釈しています。ただ、これらはあくまでもoptionの位置付けなため、riverpod_generatorを採用せずとも、まず不都合は生じないのではないかなと。


なお、DartにStatic Metaprogrammingが導入されると、話が変わるかもしれません。導入後はriverpod_generatorが必須となったり、riverpodライブラリに統合される可能性はあると思います。
この後では、riverpod_generator相当の機能を利用していないときに、不都合が生じるかもしれません。。

ぬこ丸ぬこ丸

なるほど!

なお、DartにStatic Metaprogrammingが導入されると、話が変わるかもしれません。導入後はriverpod_generatorが必> 須となったり、riverpodライブラリに統合される可能性はあると思います。

コードを書く観点では現時点不都合が起きないにしろ
新しい方のドキュメントはriverpod_generatorを入れる前提で書かれている為
riverpod_generatorは入れるのが正しいというか合理的に見えます。