Riverpod の難しさを受け止めてみる
この記事は
2025/01/23 Flutter エンジニアの集いの発表内容をまとめ直したものとなります。
パネルディスカッションめちゃくちゃ盛り上がりました。またやりたい〜〜
概要
Riverpod は難しいとよく話題に上がります。仮に難しくとも相応の理由があれば良い技術選定になるはずですが、代替手段に溢れていると難しさはデメリットになりがちです。
とくに、状態管理手法にはProvider もあるし、こちらのほうがシンプルです。せっかく使うなら、「僕はこういう理由で Provider ではなく Riverpod を使うんだ!」言いたいなぁと思いました。
この記事は、僕がそう言えるようになるための調査記録と見解です。
Provider vs Riverpod という話題には公式の解説があって、だいぶ参考にしました。こちらのほうが原典に近いので良かったら読んでみてください。
※ 本記事における「Provider」はすべてライブラリとしてのProviderを指します。
技術方面を簡単に自己紹介
- 2016〜2019年: Android 開発
- JavaからKotlinへの移行期、Jetpack Compose が現れる前くらいです
- ViewModel, LiveData などで「UIと状態を分離しよう」が来てた時期だったと思います
- 2019〜2022年: Flutter 開発(Provider)
- 通信周りの状態管理が辛かったので Riverpod でいうAsyncValue+AsyncNotifier のようなものを自作してました
- ProviderNotFoundExceptionが嫌だったので「一画面につきStateNotifierは1つまで。状態はなるべくこれに持たせる」で運用してました
- 2022 〜 : は Flutter開発(Riverpod)
- AsyncNotifier, riverpod generator を主に使ってます
Riverpod における参照カウント方式の概説
Riverpod で採用されている参照カウント方式について簡単に触れておこうと思います。
大前提、宣言的UI環境ではUIのライフサイクルと状態のライフサイクルの2種類を考慮する必要があります。特にモバイルアプリケーションでは、デバイスのメモリがPCよりも少なめだったり、画面遷移の方式がスタック式であることなどから、状態のライフサイクル管理(≒ メモリ管理)の議論が活発な印象です。
状態管理手法には大きく2種類の方法があって(要出典)、
- UIと状態のライフサイクルと同期させる方法
- スタンダードな方法多分。Provider や React hooks などが該当
- 参照カウント方式で状態を管理する方法
- ガベージコレクタやRxのストリームの管理でよく見る方式。
Riverpod は後者の参照カウント方式を採用していると言えます。
参照カウント方式で管理される状態は、次のような振る舞いを取ります。
- 状態の参照先が0件から1件になったら、状態を生成する
- 状態の参照先が1件から0件になったら、状態を破棄する
Provider の方式と比べると、Widget への依存を解消できていることがわかります。
Riverpod | Provider | |
---|---|---|
状態の生成 | 参照が1件になった時 | 状態を保持するWidgetがツリーに組み込まれた時 |
状態の破棄 | 参照が0件になった時 | 状態を保持するWidgetがツリーから外れた時 |
Riverpod は参照カウント方式の状態管理手法を採用することで Widget に依存しない形の状態管理を実現しています。
参照カウント方式の各種観点からの比較
参照カウント方式であることを前提に、Riverpod と Provider を次の3つの観点から比較します。
- 状態変化の連鎖
- スコープの設計
- 状態管理の粒度
状態変化の連鎖という観点から
StateNotifierを用いた Provider による状態管理では、「Widgetからの状態変化の購読」と「StateNotifierからの状態変化の購読」で実装方法が違いました。
- Widget からの購読:
context.watch
を利用 - StateNotifier からの購読: Stream を利用
StateNotifier からの状態変化の購読、つまり StateNotifierA の変化に基づいて StateNotifierB の状態を変化させたい場面では、Stream を利用せざるを得ませんでした。
この Stream が曲者で、学習コスト・メンテナンスコストが高いです。良い技術なので否定するつもりはありませんが、状況としては「それしか選択肢がないから渋々使う」といったもので、コストに見合った技術選定とは言えませんでした。
実装のイメージ
class HogeHogeController extends StateNotifier<HogeHogeState> with LocatorMixin {
HogeHogeController() : super(HogeHogeState());
final CompositeSubscription _compositeSubscription = CompositeSubscription();
void initState() {
super.initState();
read<AnotherController>() //状態変化を検知するためにStreamを購読する
.stream
.distinct()
.listen((x) => …) // 状態を書き換えるのはここ
.addTo(_compositeSubscription);
}
void dispose() {
_compositeSubscription.close(); //ちゃんと購読を破棄すること
}
}
「Streamがほぼ必須だ」という個人的見解で開発を進めていたところ公式にちょっぴりサポートをもらえて罪悪感から開放された当時の僕のツイートをどうぞ。
Stream に関して説明した記事もあるのでこちらもよかったらどうぞ。
対して Riverpod では、Stream を使わずに「Notifierからの状態変化の購読」を実現できます。しかもその方法は Widget からの購読とほとんど同じであり、両者を区別する必要がなくなっています。
Riverpod Win 🎉
スコープの設計という観点から
Provider における「スコープ」とは、次の2つを示すWidgetツリーの部分木を指します。
- 状態を読み取れるWidgetの範囲
- 状態のライフサイクル(部分木がツリーから外れたら状態を破棄する)
Widget ツリーにDIする感覚に近く、シンプルで良い方法だと思います。一方で、次のような課題感もありました。
- 範囲の広いスコープは長いライフサイクルを意味する。メモリ管理という観点ではなるべく小さいスコープが求められるが、狭めすぎると逆に状態の粒度が小さくなり、管理が難しくなる
- Widgetツリーをカスタマイズできない箇所(SDK内部とか)にはスコープを挿入できない
Riverpod vs Provider のコンテキストではよく「Riverpod では複数画面にまたがる状態を作れるから良い」という話題が上がりますが、まさに2つ目の課題感のわかりやすい言い換えだと思います。大体の人は MaterialApp に内包される Navigator を使っていて、この Navigator の直下にスコープを差し込むのは現実的ではありませんでした。
対して Riverpod における「スコープ」は、「状態を読み取れるWidgetの範囲」を指します。
Riverpod | Provider | |
---|---|---|
スコープの定義 | 状態を読み取れる範囲 | 状態の読み取り範囲とライフサイクルを示すWidgetツリーの部分木 |
名前は同じですが、意味が減ってるんですね。前述の通り参照カウント方式を採用することで Widgetへの依存は解消できているし、状態のライフサイクル管理もこちらに移譲できています。
例えば次のような Widget ツリーを考える時、
もしこのスコープが Provider のものであれば、やばいです。アプリ内の全ての状態は利用の有無によらずプロセスが終了するまで破棄されないため、メモリ効率が悪いと言えます。
一方でこのスコープが Riverpod のものであれば、単に「すべてのWidgetで値を読み取れる」というだけであって、メモリ効率の低下に(直接的には)つながりません。メモリ効率を左右するのはあくまで参照数であり、利用されていない状態は適宜破棄されます。
まとめると、Riverpod では。メモリの効率を気にせず、Widget に縛られることもなく、状態を読み取りたい範囲だけを考えてスコープを設計できるようになりました。
Riverpod Win 🎉
状態管理の粒度という観点から
Provider における「細かい粒度の状態管理」には、その細かさに比例して Widgetツリーが肥大化していく課題感がありました。
また、状態へのアクセスは「Widgetツリー上に存在する祖先のうち、同じ型で登録されているスコープ」というロジックで行われるため、静的なソースコード解析でコードの正しさを判定するのが難しいという課題感もありました。
対して Riverpod では、これまでも説明したように Widget からの依存を解消できています。また、事前に宣言した変数の参照という形で状態にアクセスするため、静的解析の恩恵も受けられるようになりました。Provider における様々な課題感が解消され、細かい粒度の状態管理が圧倒的にやりやすくなった状況と言えます。
Riverpod Win?
.
.
.
.
.
というのも、「細かな粒度での状態管理」そのものが良いかどうかを決めるためには、環境要因(メンバの習熟度や運用予定、プロジェクトの規模など開発現場特有の要因)も考慮する必要があると思っていて、これらを抜きに「細かな粒度での状態管理は良いものだ」とは言えないと思ってしまったんですよね。
もちろん選択肢が多いのは良いことですが、場合によっては習得の難しさにつながってきがちです。もし Riverpod が本当に難しい技術だとしたら、「使わない選択肢など無い方が良い」という考え方もできます。
もし環境要因を固定して議論できるなら Win! を言える気もしますが、固定できない状況では判断できないとするのがいいのかなと思いました。
No Contest.
まとめ
僕は!!!次の理由で!!!Provider じゃなく Riverpod を使うんだ!!!
を言いたいときの観点は大きく3つあるという見解でした。
- Rxを使わずに状態変化の連鎖を実装したいから
- Provider だとほぼ必須
- Rxは学習コスト・メンテナンスコストが高い。導入には覚悟が必要(そこに覚悟するくらいならRiverpod の難しさを受け止めたい)
- Widgetに依存しないスコープ設計を行いたいから
- これにより、「1つの状態を複数の画面間で共有するUI要件」を実現可能となる。Providerでは不可。そういう要件があるならすぐに採用したい
- 仮に今はないにしても、将来的にデザイナから要望されたときに「無理です」と返答したくないし、ひたすら画面引数でバケツリレーをするのは保守性最悪なので、それを見越して採用しても良いだろう
- メモリの効率化に取り組みやすくなっているというメリットも有る
- 「Widgetの破棄」ではなく「参照0件」でメモリが開放される。より厳密だ
- 状態の読み取り範囲とは分けて考えることができる
- 細分化された状態管理を行いたいから
- Providerだと難しい
- そもそも必要かどうかは環境次第。環境として必要だという判断があるなら今すぐ採用したい
余談
余談1. 細かい話はさておき便利だから良いのでは?という考え方について
わかる。とても。実際 AsyncNotifier の自動生成とかもう離れられない気がする。
ただ、Riverpod がもし本当に学習コストが高い技術なのであれば、「単に便利」の理由だけで導入を決定したくない気持ちがあります。
というのも、その「便利」というのは学習コストを乗り越えた先で得られる恩恵であって、「どうやって学習コストを乗り越えるか?」という課題が別で生まれるからです。特に会社では個人ではなく組織として生まれるので厄介。組織としての保守性や開発速度など、様々な観点が入ってきがちです。
なので今回は、「便利」という言葉は使わずに、非Flutterエンジニアでも理解できる解像度で書いてみたかった次第です。
余談2. すべて autoDispose を前提として話を進めましたが
Riverpod では autoDispose を使わない選択肢ももちろんあります。
ただし autoDispose を使わない Notifier とはシングルトンのようなものであって、「効率的にメモリ管理したい」というコンテキストでは選択肢から外れがちです。
逆に効率的なメモリ管理という要件を外してしまうと「Riverpod じゃなくてええやん」となってしまいそう。
なので Riverpod を論じるときは極力 autoDispose を前提に進めたほうが良い気がしてます。
Discussion
ちょうど自分も最近改めて考えていたテーマでしたので、イベント聞かせていただきました!記事によるまとめもありがとうございます。
一点だけ
この部分についてですが、Provider による管理では「すべての状態はプロセスが終了するまで破棄されない」のは(使い方次第では)確かにその通りですが、それは「同じものが破棄されず使いまわされる」ということであって、アプリを触っていたら無限に増えていくというものではないため、「どんどん膨らんでいく。今すぐ修正が必要な状態」とまでは言えないと思いました。
Provider やそのベースになっている
InheritedWidget
が「使ってはいけないもの」という印象を受けてしまいかねない文章かなと思いましたので、コメントさせていただきました!めちゃくちゃその通りですね... ご指摘ありがとうございます!修正しました。