Flutter 🇯🇵
Published in

Flutter 🇯🇵

Flutterの状態管理手法の選定

Photo by Daniel Pelaez Duque on Unsplash

Riverpod (新Provider)

そもそもProviderとは

Provider自体、決して古いものではなく、2018年末に登場し、2019年初めにFlutterチームとのやり取りを経て認められ、Google I/O 2019にて公式推奨の立場になり、今ではFlutter Favoritesに選ばれている数少ないパッケージの1つです。最初期はジェネリクス対応したInheritedWidgetというだけの極めてシンプルなものでした。以下の記事の冒頭でもこのあたりの経緯触れています。

  • InheritedWidgetによるO(1)のアクセス
  • InheritedWidgetによる変更伝播
  • StatefulWidgetによる状態・リソース管理
  • (不変な)インスタンスを受け渡す(DI・サービスロケーター的な用途)
  • 状態の変更を伝える

Riverpodの台頭

Providerは上述の通り、公式推奨の最も定番の選択となりました。また、他の選択として人気の blocパッケージBlocProviderの内部実装にもProviderが利用されているなど、部分利用もよくされています

Providerの課題

万能で欠点の少ないように見えるProviderですが、Flutterフレームワーク(のInheritedWidget)にも起因する、一般的なアプリを組む際に不都合な点がいくつかあります。

  • Providerで囲った配下のWidgetツリー以外からアクセスするとProviderNotFoundExceptionの実行時例外が発生してしまうが、そのミスを確実に防ぐ方法がない(「気をつけて使う」なりテストで守るなりするしかない)
  • ある状態をアプリの特定の一連の画面フローの中でだけ共有しつつそれらが閉じられたら破棄したい場合の書き方が煩雑になる(特定のページ配下を囲ったProviderは画面遷移で途絶えてしまうのでそれを手動で繋いでいくなど手間のかかるやり方しかなく、[Discussion] Scope spanning multiple screensでも議論されている)
  • Widgetツリーが肥大化するゆえ、DevToolsのインスペクターの入れ子が巨大になって見にくくなる(ただしツリー肥大化によるパフォーマンス劣化などの動作的な実害はほぼゼロ)
  • 同じ型のものを複数同時にProvideできない(Widgetツリーの直近の指定された型が得られるため)

RiverpodでProviderの課題が克服された

しかし、Riverpodの登場でこれらの課題がすべて解決されました🎉

  • Providerがグローバル変数として定義されるため確実なアクセスが保証される(ProviderScopeでのoverrideも可能でテスト容易性なども担保してあり一般的なグローバル変数の問題は抱えていない)
  • .autoDispose() modifierを指定すれば、そのProviderへの参照(正確にはwatchによる監視)がゼロになったタイミングで解放してくれる(次回再参照すると再生成される)
  • DevToolsのインスペクター上ではProviderScopeにProviderがフラットに紐付くので見やすい(入れ子構造ではない)
  • グローバル変数複数定義や .family() modifier 活用などで複数の同じ型のProviderの参照が可能
  • 🙆‍♀️ 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はマイナーバグがまだ少しあるかも?)
  • 上述のProviderの課題を元々ネックに感じて解消したいと思っていたならRiverpod化検討をお勧めできる(逆にそうでないならそのままでも良さそう)
  • Riverpodのドキュメント・サンプルを読んだり色々試し書きして知見を得た状態なら、移行コストはあまり高くない(経験上、1万行規模/日くらい)

StateNotifier

Provider/Riverpodで状態の伝達を行えますが、その伝達される側のクラスとしてStateNotifierをメインに使っています。

状態をimmutableに扱いたいかどうかが肝

まず、状態値をmutableで扱ってしまっても良いという考えで実際にそれで課題も感じていないなら、ChangeNotifierを使うのが楽です。

  • mutableで簡単に済ませたいし、それで問題を感じない → ChangeNotifier
  • 状態クラスの扱いが多少煩わしくなるのを許容してでもimmutableの恩恵を得たい → StateNotifier

余計なリビルドを避けつつStateNotifierを得るのが簡単というメリットもある

RiverpodもProviderも、StateNotifier自体を watch 系の操作でアクセスした場合、余計なリビルドが起こりません。なぜなら、StateNotifierの状態値は state プロパティに集約されているため、原理上StateNotifierは大抵のケースで不変とみなせるからです。つまり、 build メソッド直下でStateNotifierのインスタンスを保持しておいて、複数箇所のコールバック内で使いたい時など簡潔に書けます。

context.read(myProvider)
  • コールバック内で 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 を提供しようとしていたこともありました

Motivation — state_notifier
Differences with ValueNotifier — state_notifier

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);
}
@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 を定義するのが面倒
  • copyWithnull 値を与えられない( null 値と無指定の区別が付かないので)

Dart標準でのimmutableの扱いにくさをコード生成によって解決したfreezed

freezedを使うと、定められたコンストラクターを定義するだけで、immutableクラスに必要な機能実装コードを自動生成してくれるようになります。

  • copyWith : null値指定も対応(デフォルト値に定数が与えられててnullと区別できるようになっている)
  • json_serializableとの組み合わせもできるのでAPIレスポンス用のクラスにも適している
  • ==toString なども自動実装されて便利
  • Union/Sealed クラス生成にも対応

freezedのマイナス面

現状のDartの言語仕様的にカバーされていない要件をコード自動生成で解決するアプローチとしては満点な仕上がりだと思っていてパッケージの出来自体を批判する気持ちはゼロですが、コード自動生成ゆえの扱いにくさは付きまといます。

  • コード自動生成処理に時間がかかる(特にプロジェクト規模が大きい場合)
  • freezedの書き方・使い方を覚える学習コストがかかる

built_valueは?

freezed登場以前から、同じような目的のコード生成パッケージとしてbuilt_valueがDart公式で提供されています。

BLoCは?

bloc_provider パッケージを自作したり以下の記事を書いたり、かつては当時の選択肢の中ではBLoCが良いなと思って採用していました。

  • 2019年5月のGoogle I/OでProviderが公式推奨されて、ChangeNotifierを利用した更新伝播方法(ChangeNotifierProvider)なども提供されていた
  • 2019年6月に発表されたSwiftUIのチュートリアルを観察してそちらの方がBLoCを用いたFlutterコードよりかなりすっきりしていることに魅力を感じて、ChangeNotifierProviderを利用すると似た感じに書けることに気付いた
翌年、自分の古いサンプルを書き換えた時のツイート
  • Provider/Riverpodでbuilderコールバックを使わずにフラットに書くスタイルが気に入っている
  • Provider/Riverpodからblocに変えることで受ける恩恵が思い浮かばない

Riverpod・StateNotifier・freezedを使う前提でも、実際の実装の仕方はそれぞれ差が出るはず

僕のスタイルは大体以下のサンプルのような感じで、コントローラー的なStateNotifierクラスおよびそれが移譲するクラスに寄せた、わりとベーシックなクラスベースな書き方のつもりです(こういう小さめのサンプルと実際のプロダクトコードは色々差があるので参考程度にしてください)。

--

--

Flutterに関する日本語記事を書いていきます🇯🇵

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store