Flutterアプリケーション開発にRiverpodを僕が使う理由
はじめに
Flutterにおける状態管理の手法は、数多く存在します。
筆者は、その中でもRiverpodを好んで利用しています。最近はFlutterKaigi 2023の公式アプリでも採用しました。
このようにRiverpodを採用していると、「Riverpodの勉強方法はどのようにすればいいのか」とか、「便利さがよく理解できない」という声をよく聞きます。勉強方法に関しては、公式ドキュメントを読むのが一番です。とはいえ、急に公式ドキュメントを読むのはハードルが高いかもしれません。
本記事では、筆者がRiverpodを好んで使う理由をまとめます。「なぜRiverpodを便利だと思う人がいるのか」を知ることで、Riverpodへの関心を高めてもらえれば幸いです。
TL;DR
文章が長くなってしまったため、先に結論を簡潔に。書き始めた時には、1万字を超えるとは思っていませんでした…。
以下の4つのポイントが、筆者が感じるRiverpodのメリットです。
- Riverpodはシンプルに
Lifting state up
ができる - Riverpodは適切なスコープで状態の共有と破棄ができる
- Riverpodは簡単に
Dependency Injection
が実現できる - Riverpodはコード自動生成により、共有したい状態の形式を
Function
とClass
の書き方で簡単に実現できる
Flutterにおける状態管理
Flutterを利用したアプリケーション開発において、状態管理は常に議論されるトピックです。とはいえ、近年ではどの手法も洗練されてきています。どの手法を選んだとしても、大きな失敗はないはずです。
Flutterの公式ドキュメントには、状態管理手法を紹介する一覧があります。全てを動かして確認するのは難しい量の、さまざまな手法が紹介されています。
どのライブラリを選ぶかは、絶対的な正解はありません。おそらく、将来的にも正解は存在しないと思います。
ありきたりな表現になりますが、利用する人にとって、利用しやすいものを使うのがよいでしょう。
というのもの、どのライブラリを採用しても、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アプリケーションの中ではどこでも利用される仕組みです。
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の議論や解説を読んでみると良いかもしれません。
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を採用することになるはずです。
A wrapper around InheritedWidget to make them easier to use and more reusable.
強調しておきたいことは、InheritedWidget
を利用すればLifting state up
は実現可能である、と言う点です。Flutterというフレームワークそのものが、Lifting state up
をサポートしていると言っても、過言ではありません。(たぶん。)
Lifting state up
とSingleton
もしかすると、Singleton
による状態管理の実現が気になるかもしれません。こちらは、(Androidで安定しきるか疑問はちょっとありますが、)Dartの言語機能を利用するだけで実現できる手法です。
しかし、筆者の知る限り、Singleton
をアプリケーションの主要な状態管理に利用するケースはあまりありません。
アプリケーション全体で共有したいライブラリのインスタンス管理では、Singleton
が頻出します。たとえばshared_preferencesやfirebase_analyticsでは、それぞれのインスタンスをSingleton
で管理しています。ここには列挙していないライブラリでも、無数にSingleton
が利用されています。
一方、アプリケーションの状態管理においては、Singleton
はそこまで利用されません。
筆者がざっと読んだ範囲になりますが、たとえばService Locatorパターンを実現するget_itは、Singleton
を利用しています。しかしflutter_reduxやmobxなどの実装例を見ても、Singleton
は登場しません。どちらかといえば、これらのライブラリではLifting state up
を実現することを目指しています。
この理由について、詳しくないため筆者は意見を述べることができません。印象としては、たまたまSingleton
を採用したいという強いモチベーションを持つ人がいないだけかな、と思っています。このあたりに詳しい方がいましたら、ぜひ教えてください!
Riverpodのモチベーション
Riverpodはどのようなモチベーションで開発されたのでしょうか? Riverpodなぜ開発されたかを知ることは、利用する開発者にも意味があります。
というのも、Riverpodが開発される前の時点で、Providerが一定以上の人気を博していました。先述の通り、ProviderはInheritedWidget
をより便利にしたものです。仕組みそのものがFlutterのフレームワークが提供するものになるため、ある意味で、これ以上の統合性はない選択肢になります。2023年現在では、ProviderはFlutterの公式ドキュメントに最初に紹介される状態管理手法となりました。
またProviderとRiverpodは、両方ともRemi Rousselet氏が開発しています。一度Provider作成したものを、同じ作者が熱意を持って開発できるでしょうか…?
Riverpodのモチベーションの項目には、次のように記載されています。
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を使ったアプリケーションの構築に馴染みがあり、この手法による開発に違和感がありません。また、StateFlowやLiveDataなどを利用した、リアクティブなUIの更新にも親しみがあります。もちろん、Kotlin data classを利用した、状態の定義にも慣れています。
本記事の筆者にこれらの前提がある上で、あらためて「なぜ採用するのか」を考えると、以下の3つの理由が挙げられます。
- スコープを適度に調整した
Lifting state up
の実現 -
Dependency Injection
の実現 - 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を実現するために、どのような工夫がなされているかを把握することで、より安心しながら利用できるようになります。
上のケースでは、親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のドキュメントをよく読んで、利用する必要があります。
ここでは、上記の事情もあるためサンプルコードは割愛します。
riverpod_generatorによるFunction定義とClass定義によるProviderの実現
Riverpodには、riverpod_generatorやriverpod_lintといった、活用を助ける様々なライブラリが存在します。作者には感謝しかありません。
このうちriverpod_generatorは、Riverpodの理解を助け、より活用しやすくなる重要なライブラリです。
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
Provider
とFutureProvider
、StreamProvider
は「外部から状態の変更を想定しない」もの。そしてNotifier
とAsyncNotifier
は「外部から状態の変更を想定する」ものです。これは、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(Ref ref) => 'John Doe';
この記述を素直に読むと、userName
はglobalな関数であり、実行時に'John Doe'
という値を返すように見えます。Riverpodで必要な変数が自動生成されるファイルに押しやられるため、よりコアな処理に着目しやすくなっています。
また、非同期処理に変えたい場合には、次のような修正を行うだけで十分です。書き換え箇所が減ることで、よりシンプルな記述を保ちやすくなります。
Future<String> userName(Ref 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が存在しません。このため、Notifier
やAsyncNotifier
で扱える型は、freezedを利用する必要があります。Record型やequatableを利用することもできますが、state
の更新時にcopyWith
の実装が必要になるケースが多いため、現実的には大半の場合でfreezedを利用することになるでしょう。
これらが煩雑さを生んでいる箇所はあるのですが、これはDartの言語機能が不足していることに起因するものであり、Riverpodの問題ではありません。
なおdata classが入ると、ValueNotifier
とStateNotifier
がほぼ同じ機能を提供できるようになります。一方でAsyncNotifier
は提供されるとは思えません(AsyncValue
を採用するだけですが)。このため、現在Riverpodが提供している5つのProvider
やNotifier
は、data classが入ったとしても運用することになると思われます。
おわりに
ささっと書き上げるつもりで書き出したら、思った以上に時間がかかり、長文となってしまいました。Riverpodの良さを伝えるのは難しいですね…。
あらためての強調になりますが、Riverpodは、Flutterにおける状態管理の手法の1つです。絶対に利用しなければならない手法ではありません。とはいえ、2023年現在のFlutterによるアプリケーション開発では、数多くの面で優れている手法であると考えられます。
是非とも、しっかりとした検討の上で、Riverpodを活用してもらえればと。Flutterを楽しみましょう!
Discussion
@riverpodなどのannotationは単純に読みづらくなる&Command + クリックジャンプがしづらく追いづらくなるので、一つ前のバージョンがやはり私は好みです。FutureProviderが最高で属人化しがちなendpointなどのpromiseからModel変換までエラー、ローディングの記法が統一できるのが一番の強みだと思っています。hooksのuseEffectでendpointを呼び出しオレオレStateのような負債になるようなコードが防げる。Riverpodを正しく使っていればRead onlyの画面にStateはいらないということに気がつく(スクロールしてindexをインクリメントのcursorみたいな実装は除く)
riverpod_generatorは別ライブラリとして提供されており、現時点ではoptionの扱いになります。このため、riverpod v2でも各
Provider
を手で記述することは可能です……!ファイルが分割される点は、ご指摘の通り、開発者が何を重視するかによって判断が別れますね。Static Metaprogrammingが導入されるまでは、トレードオフの関係に着目する必要があります。
FutureProvider
は、素晴らしい仕組みだと思っています。特に、UIに非同期処理の状態や結果を反映させる実装を、一定以上の品質に均してくれる点が好きです。非同期処理が一切ないアプリは想定しにくいので、どの開発チームでもメリットになりうるのかなと。
FYIですが、APIや画面仕様にもよりますが、indexを
FutureProvider.family
のextra paramとして活用することもできます。私も実際に採用したことはないのですが、この実装ができるケースであれば、UIでindexを特別に管理する必要もなさそうです。それはそうなんですが、
ドキュメント見る限りかなり強い意志で入れたほうが良さそうなことが書いてありますね。入れないと不都合がおきそうな感じはします。
Scrollとindexインクリメント部分のみやはりStateなりどこかしらに管理する必要はあるものの(これぐらいviewにかいてもいいかという気持ちもある)
私にとって勉強になりました。しらなかった。Firestore使ってるとFirestoreUIを優先させて使用しているため外部のAPIやるとき役立ちそう。ありがとうございます。
"不都合がおきそう"は、2023〜24年の間であれば、杞憂ではないかなと思われます……!
下記に私がそう考えている理由を記載します。もしもGitHubのIssueなどで将来的な不都合の話が出ていましたら、ぜひ教えていただけますと🙏
前提として、riverpodは複数のライブラリに分割され、開発されています。このため「riverpodライブラリに対して、riverpod_generatorライブラリで機能を追加する」ことは考えづらく、riverpod_generatorを利用しない開発はサポートされ続けるはずです。
Most likely Yes. について。こちら、少し長めに引用します。
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:
を指していると理解しています。列挙されている課題は、Riverpodを学び始めた開発者が、特に混乱しやすい箇所です。またProviderを自前で定義した際には、hot-reloadに対応するためのコードを書くのが煩わしく、開発体験を下げている箇所があります。
これらが解消されることが、Riverpodエコシステムにとって大きなメリットであるので、 Most likely Yes. と推奨していると解釈しています。ただ、これらはあくまでもoptionの位置付けなため、riverpod_generatorを採用せずとも、まず不都合は生じないのではないかなと。
なお、DartにStatic Metaprogrammingが導入されると、話が変わるかもしれません。導入後はriverpod_generatorが必須となったり、riverpodライブラリに統合される可能性はあると思います。
この後では、riverpod_generator相当の機能を利用していないときに、不都合が生じるかもしれません。。
なるほど!
コードを書く観点では現時点不都合が起きないにしろ
新しい方のドキュメントは
riverpod_generator
を入れる前提で書かれている為riverpod_generator
は入れるのが正しいというか合理的に見えます。