Flutterの状態管理手法の選定
Flutterの状態管理周りの手法はちょくちょく動きがあって、それに関する話題が度々盛り上がっている気がします。
今の自分は以下を組み合わせて使っていて、満足しています。
- Riverpod (新Provider)
- StateNotifier (Better ValueNotifier)
- freezed (immutableなクラスの扱いなどを楽にするコード生成器)
どうしてこの組み合わせを好んでいるのか、以下述べていきます(コード例などはリンク先で充分かなと思うものが多かったので少なめです)。
Riverpod (新Provider)
そもそもProviderとは
Provider自体、決して古いものではなく、2018年末に登場し、2019年初めにFlutterチームとのやり取りを経て認められ、Google I/O 2019にて公式推奨の立場になり、今ではFlutter Favoritesに選ばれている数少ないパッケージの1つです。最初期はジェネリクス対応したInheritedWidgetというだけの極めてシンプルなものでした。以下の記事の冒頭でもこのあたりの経緯触れています。
その後InheritedWidget・StatefulWidgetの以下の機能を簡単に利用できる、より便利なラッパーパッケージになり、バージョンアップを経て機能が増えていきました。
- InheritedWidgetによるO(1)のアクセス
- InheritedWidgetによる変更伝播
- StatefulWidgetによる状態・リソース管理
InheritedWidgetとProviderの関係は、以下の記事の後半でも触れました。
Providerは色々機能(各種Providerの種類)はありつつも、主に以下の用途で使え、状態管理をこれだけに頼ってアプリを組むことも可能です。
- (不変な)インスタンスを受け渡す(DI・サービスロケーター的な用途)
- 状態の変更を伝える
特に、ChangeNotifierProviderでFlutter本体に含まれるChangeNotifierを扱うパターンが定番で、Flutter公式ドキュメントでStatefulWidgetベタ書きの次の段階としてまずお勧めのやり方として紹介されています。
Providerを使わずとも、Flutter本体に含まれるInheritedWidget・StatefulWidgetとChangeNotifier/ValueNotifierを組み合わせて書くこともできますし、Flutter本体の内部実装はそうなっています。もちろんアプリコードもそうやって書くことも可能ですが、Providerを活用するとボイラープレートコードを減らせます。
Riverpodの台頭
Providerは上述の通り、公式推奨の最も定番の選択となりました。また、他の選択として人気の blocパッケージ のBlocProviderの内部実装にもProviderが利用されているなど、部分利用もよくされています。
Providerのバージョンアップによる変更も落ち着いてきて、ここの責務はProviderに任せておけば安泰な時代になったかと思われましたが、2020年6月に同じ作者がProviderの改良版であるRiverpodをリリースしました(その前にそれを期待させる発信もありました)。
一見同じようなものを同じ作者がリリースしたことには、もちろん明確な意図があります。
Providerの課題
万能で欠点の少ないように見えるProviderですが、Flutterフレームワーク(のInheritedWidget)にも起因する、一般的なアプリを組む際に不都合な点がいくつかあります。
- Providerで囲った配下のWidgetツリー以外からアクセスするとProviderNotFoundExceptionの実行時例外が発生してしまうが、そのミスを確実に防ぐ方法がない(「気をつけて使う」なりテストで守るなりするしかない)
- ある状態をアプリの特定の一連の画面フローの中でだけ共有しつつそれらが閉じられたら破棄したい場合の書き方が煩雑になる(特定のページ配下を囲ったProviderは画面遷移で途絶えてしまうのでそれを手動で繋いでいくなど手間のかかるやり方しかなく、[Discussion] Scope spanning multiple screensでも議論されている)
- Widgetツリーが肥大化するゆえ、DevToolsのインスペクターの入れ子が巨大になって見にくくなる(ただしツリー肥大化によるパフォーマンス劣化などの動作的な実害はほぼゼロ)
- 同じ型のものを複数同時にProvideできない(Widgetツリーの直近の指定された型が得られるため)
これらはProviderパッケージ自体を順当に改善するだけでは解決不可能でした。どれも規模・複雑度がある程度低めのプロジェクトではあまり気にならない欠点ですが、それが増していくとけっこう気になってきます。これらの課題に対して、Riverpod登場前は「ちょっと気になるけどアプリコードの工夫でがんばってなんとかしよう」という感じで取り組むしかありませんでした(それでなんとかなっていましたが)。
RiverpodでProviderの課題が克服された
しかし、Riverpodの登場でこれらの課題がすべて解決されました🎉
- Providerがグローバル変数として定義されるため確実なアクセスが保証される(ProviderScopeでのoverrideも可能でテスト容易性なども担保してあり一般的なグローバル変数の問題は抱えていない)
.autoDispose()
modifierを指定すれば、そのProviderへの参照(正確にはwatchによる監視)がゼロになったタイミングで解放してくれる(次回再参照すると再生成される)- DevToolsのインスペクター上ではProviderScopeにProviderがフラットに紐付くので見やすい(入れ子構造ではない)
- グローバル変数複数定義や
.family()
modifier 活用などで複数の同じ型のProviderの参照が可能
また、Providerでは依存解決に get_it との併用が良いという意見もあり、以下のように一長一短ではありつつも一理あると思っていましたが、Riverpodではあえてget_itを併用する理由は無くなった気がします(併用するメリットが思い浮かばない一方、併用すると混在するややこしさが増えるので)。
- 🙆♀️ Widgetツリーのネスト構造と無関係な依存解決は、それとは別にコントロールするのが自然
- 🙆♀️ DevToolsのインスペクターが見にくくなる問題も軽減できる
- 🙅♀️ get_itで提供したものに誤ってProviderからアクセスしようとすると前述のProviderNotFoundExceptionの実行時例外が発生してしまい、この脆さが増す(初回実装ではその場で直せば済む些細な問題ではあるがリファクタリングでそのあたり組み替えた時などに危険)
ProviderとRiverpod、どちらがお勧め?
まず、純粋に機能的にProviderがRiverpodより優れている点は思い浮かびません。そのため、個人的には今後Providerをあえて選ぶことはないと思いますが、客観的には以下の点はProviderの方が優位に感じます。
- 特定のWidgetツリーをProviderで囲むという作りは直観的(Flutter標準のInheritedWidgetと全く同じ作り)
- 広く普及しているため情報が多い
- 安定している(Ripoverpodはまだv0.8.0で破壊的変更もたまにある一方、Providerは特にv4.0以降半年以上破壊的変更が無く安定している)
- Providerの方が僅かに品質が高いかも?(Providerのバグはもう潰され尽くされている気がする一方、Riverpodはマイナーバグがまだ少しあるかも?)
また、Riverpodは autoDispose
の適切な制御がたまに難しく感じることがあります(誤用して利用中にうっかり解放されてしまったり逆に参照し続けてしまったり)。
すでにProviderで組んでいるプロジェクトをRiverpodに移行するべきかどうかは、以下のように思っています。
- 上述のProviderの課題を元々ネックに感じて解消したいと思っていたならRiverpod化検討をお勧めできる(逆にそうでないならそのままでも良さそう)
- Riverpodのドキュメント・サンプルを読んだり色々試し書きして知見を得た状態なら、移行コストはあまり高くない(経験上、1万行規模/日くらい)
StateNotifier
Provider/Riverpodで状態の伝達を行えますが、その伝達される側のクラスとしてStateNotifierをメインに使っています。
状態をimmutableに扱いたいかどうかが肝
まず、状態値をmutableで扱ってしまっても良いという考えで実際にそれで課題も感じていないなら、ChangeNotifierを使うのが楽です。
一方、immutableにする利点は以下によくまとまっています。
簡単な具体例で言うと、例えばあるアイテムの編集画面でそのオブジェクトの状態を弄って確定せずにキャンセルしたとします。immutableに扱っていれば単にそれを破棄すれば良いだけですが、mutableに扱っていて特に何もケアせず単純に組んでいたらその中途半端な操作が別の箇所で反映されてしまうバグを生む可能性があります。もちろん、mutable中心に扱っていてもその編集画面の実装次第で問題を起こさないようにはできますが、immutableに扱っている場合は何の工夫をせずともその類のバグを引き起こすことはあり得ません。
コレクションの安全なコピーも、要素がimmutableならシャローコピーで簡単に済むという利点もあります。
また、 Provider/Riverpodでの扱いの話だと、例えば select()
結果がmutableなクラスだと同一インスタンスの比較になってしまい常に結果がtrueでまともに機能しない問題もあります(そのクラスが持つimmutableなプロパティを select()
結果にするとまともに動きます)。
あるいは必要なところでのみ状態をimmutableで扱うというのもあり得そうですが、そうやって混在させた場合に今扱おうとしている状態がimmutableかどうかを利用側で判断して扱い方を変えるというのはある程度の複雑性・規模のプロジェクトではややこしさという弊害を招くと思います。そのため、状態クラスを必要に応じてmutable・immutable混在させるというのはあまり良い手だとは思いません。
なので、個人的な結論としては、以下です。
- mutableで簡単に済ませたいし、それで問題を感じない → ChangeNotifier
- 状態クラスの扱いが多少煩わしくなるのを許容してでもimmutableの恩恵を得たい → StateNotifier
そもそも、WidgetもFlutter標準のInheritedWidgetを介してアクセスできるもの、例えばTheme.of(context)で取れるThemeDataなどもimmutableですし、そちらに寄せた方がFlutterらしい気もしています。
仮にimmutableなクラスが扱いやすいように言語機能としてサポートされていればそちら一択で良いと思いますが、後述の通りDartの場合は残念ながらそうではないので、ある程度トレードオフが発生します。
immutableの解説記事としては以下などお勧めです。
このあたりについて、こちらの記事でさらに詳しく書きました:
余計なリビルドを避けつつStateNotifierを得るのが簡単というメリットもある
RiverpodもProviderも、StateNotifier自体を watch
系の操作でアクセスした場合、余計なリビルドが起こりません。なぜなら、StateNotifierの状態値は state
プロパティに集約されているため、原理上StateNotifierは大抵のケースで不変とみなせるからです。つまり、 build
メソッド直下でStateNotifierのインスタンスを保持しておいて、複数箇所のコールバック内で使いたい時など簡潔に書けます。
ChangeNotifierは状態値の管理もセットになっているため、それを普通に得ると状態値の変化に応じて必然的にリビルドが発生してしまい、同じ使い方はできず、以下のいずれかのやり方をするしかありません(前者が素直)。
- コールバック内で
context.read()
でそれぞれアクセス final controller = useProvider(someProvider.select((c) => c));
のようにChangeNotifier自体の同一性を利用してselect
で絞って取り出す(この時のcontroller
の値は適切に更新されないのでそれをUI表示に使うと更新漏れのバグを生むリスクを伴う)
状態プロパティは束になっている方が分かりやすいと思う
特にある程度コード量が大きくなった状態管理クラスにおいて、状態プロパティはStateNotifierの state
のように束になっている方が分かりやすいと思います。ChangeNotifierでは状態用のクラスを別途用意しなくて楽という意見もありますが、複数の場合は僕はむしろ逆にChangeNotifierでも状態プロパティを束ねるクラスがあった方が良いくらいに感じますし、例えばこの記事のようにChangeNotifierでそうしている人もいます。
ValueNotifierは?
StateNotifierとかなり似たものとしてFlutter標準のValueNotifierというのもがあります。また、Provider作者がValueNotifierを使ってValueNotifierProvider を提供しようとしていたこともありました。
これは、StateNotifier/StateNotifierProviderの組み合わせと、大抵のケースでほぼ同等に機能します。
しかし、アプリコードの状態管理用途で使う場合に少し難もあるため、それを許容しつつ標準クラスを使うか、それとは別の状態管理に都合の良いものを作るかの是非を検討した末、StateNotifierの自作に至ったのだと解釈しています。
StateNotifierのValueNotifierとの違いはドキュメントの以下を読むと分かると思います。
そして、ProviderもRiverpodもValueNotifierProviderを提供していないため、ValueNotifierをそれらと使う選択肢は自作する以外になく、この2つの比較では実質StateNotifier一択となります。
freezed
Dartでのimmutableの扱いの煩雑さ
まず、上述の通り、Dartでimmutableクラスは扱いにくいです。例えば、以下のようにPersonのageを1つ足したコピーを作りたい場合、基本的には以下のように愚直に書くことしかできません。
@immutable
class Person {
const Person({
@required this.name,
@required this.age,
});
final String name;
final int age;
}void f() {
const p1 = Person(name: 'mono', age: 33);
final p2 = Person(name: p1.name, age: p1.age + 1);
}
Dartでは以下のように慣例的に copyWith
メソッドをクラスに足して、利用側の手間やミスを防ぐことが多いです。こうすると利用側のコードとしては良い感じになります。
@immutable
class Person {
const Person({
@required this.name,
@required this.age,
});
final String name;
final int age;Person copyWith({
String name,
int age,
}) {
return Person(
name: name ?? this.name,
age: age ?? this.age,
);
}
}void f() {
const p1 = Person(name: 'mono', age: 33);
final p2 = p1.copyWith(age: p1.age + 1);
}
ただ、以下の課題もあります。
- クラス側で
copyWith
を定義するのが面倒 copyWith
にnull
値を与えられない(null
値と無指定の区別が付かないので)
Dart標準でのimmutableの扱いにくさをコード生成によって解決したfreezed
freezedを使うと、定められたコンストラクターを定義するだけで、immutableクラスに必要な機能実装コードを自動生成してくれるようになります。
copyWith
: null値指定も対応(デフォルト値に定数が与えられててnullと区別できるようになっている)- json_serializableとの組み合わせもできるのでAPIレスポンス用のクラスにも適している
==
やtoString
なども自動実装されて便利- Union/Sealed クラス生成にも対応
freezedのマイナス面
現状のDartの言語仕様的にカバーされていない要件をコード自動生成で解決するアプローチとしては満点な仕上がりだと思っていてパッケージの出来自体を批判する気持ちはゼロですが、コード自動生成ゆえの扱いにくさは付きまといます。
- コード自動生成処理に時間がかかる(特にプロジェクト規模が大きい場合)
- freezedの書き方・使い方を覚える学習コストがかかる
コード自動生成処理の時間は以下の工夫で劇的に改善されますが、それでも多少気になります。
freezed
書き方・使い方を覚える学習コストは、僕は以下のように思っているためほとんど気になりません。
- Live Templates・スニペットを見つけたり自作したりして利用すれば大したことない(いつもそれに頼っているため僕はソラで書けないかも?)
- ちょっと癖があるのは、lateのgetterを足した時にconstにできなくなること と 普通のメソッドを足す時に調整が必要なこと 程度で、誤用した時のエラー表示も分かりやすい
freezed
のおかげで短縮される手間の方がずっと大きい
built_valueは?
freezed登場以前から、同じような目的のコード生成パッケージとしてbuilt_valueがDart公式で提供されています。
以前使おうと検討したことがありましたが、以下の理由で採用を見送りました(ちなみに同時期に検討したjson_serializableは気に入って採用し今もfreezedと併用中)。
- freezedドキュメントでも指摘されている通り利用側のコードが煩雑
- 使い方が難しく感じた
一方、freezedはそんな僕でもすんなり使いこなせて、愛用しています。
その他、関連事項をもう少し補足します。
BLoCは?
bloc_provider パッケージを自作したり以下の記事を書いたり、かつては当時の選択肢の中ではBLoCが良いなと思って採用していました。
しかし、以下の経緯があって、そこからProviderで提供されている更新伝播方法の採用に変えてBLoCを使うのはやめました。
- 2019年5月のGoogle I/OでProviderが公式推奨されて、ChangeNotifierを利用した更新伝播方法(ChangeNotifierProvider)なども提供されていた
- 2019年6月に発表されたSwiftUIのチュートリアルを観察してそちらの方がBLoCを用いたFlutterコードよりかなりすっきりしていることに魅力を感じて、ChangeNotifierProviderを利用すると似た感じに書けることに気付いた
素のBLoC自前実装はともかく、blocパッケージは色々手厚くて良いかもとは思いつつ、僕は以下の理由で今のところ採用したいとは思いません。
- Provider/Riverpodでbuilderコールバックを使わずにフラットに書くスタイルが気に入っている
- Provider/Riverpodからblocに変えることで受ける恩恵が思い浮かばない
Riverpod・StateNotifier・freezedを使う前提でも、実際の実装の仕方はそれぞれ差が出るはず
僕のスタイルは大体以下のサンプルのような感じで、コントローラー的なStateNotifierクラスおよびそれが移譲するクラスに寄せた、わりとベーシックなクラスベースな書き方のつもりです(こういう小さめのサンプルと実際のプロダクトコードは色々差があるので参考程度にしてください)。
Riverpodのproviderを useProvider()
を使ってフラットに書きたいために hooks_riverpod を使って flutter_hooks のHookWidgetを継承していますが、それ以外の用途では今のところhooksはあまり活用していません。
ただ、作者はhooks多用スタイルっぽかったり、Riverpodのexampleもちょっと自分のスタイルとは違う感じ(細かいproviderコンポーネントで組み合わせる感じ?)で、そういうのを参考にしつつ良さそうと感じたら書き方を変えたりするかもしれません🤔
また、これ関連だと、以下の動向も気になって見てます👀初めの方はかなり丁寧に読んでたもののコメント数が数百件に膨らんだ今は飛ばし読みしつつ面白そうなところを拾い読みしています。
というわけで、色々経つつ今一旦落ち着いた感のある気に入った状態管理スタイルの話でした。Provider作者の方お勧めのやり方であるため採用している(しようとしている)人は多い組み合わせだと思いますが、僕がどういう理由でこれを好んで使っているかなどの考えが参考になったら幸いです🐶
後編はこちらです: