🍡

状態管理を構成する 3 つの要素とそれらが解決したい状態管理の課題

2023/10/03に公開

Flutter の UI は、状態 を定義し、それを build() メソッドで参照しながら「宣言的」に構築される設計になっています。

そしてその 状態をどのように参照し、どのように状態の変更を検知してリビルドするか という課題をひとことで 状態管理 と言い、その状態管理の手法にはさまざまな(覚えきれないくらいの)選択肢があることはご存じの通りかと思います。

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

ひとつひとつを独立した手法ととらえて理解しようとするととても難しく感じてしまいますが、それぞれの状態管理手法を眺めていると、だいたい大きく分けて以下の 3 つの要素で成り立っているように思えます。[1]

  • ステート: 状態を保持するオブジェクト
  • メンテナ: ステートを生成、変更するオブジェクト
  • プロバイダ: ステートへアクセスする手段や変更を通知するオブジェクト

この記事では、具体例として Riverpod パッケージを見ながらこの 3 つの要素がどのように実現されているかを観察し、またそれを通して状態管理における一般的な課題について考えてみたいと思います。

3 つの要素とその役割

ステート

ステート は UI を構築する際に最終的に利用する値を保持するオブジェクトです。

Riverpod においては、 ref.watch(someProvider) で取得できるオブジェクトがこのステートにあたります。

たとえば、カウンターアプリにおける「現在のカウント」というステートを取得する場合、以下のようなコードになるでしょう。


Widget build(BuildContext context, WidgetRef ref) {
  final counter = ref.watch(counterProvider); // ステートを取得(同時に監視も)

  return Text(counter.toString());
}

ステートは build() メソッドによる UI 構築のために参照されるのが主な役割で、ほとんどの場合は「値を保持する」以外の役割は持たないことが多いです。

Riverpod ではその役割を徹底するためか、ステートは不変(immutable)なオブジェクトでなければならない とされています。[2] ほとんどの場合で Riverpod と freezed パッケージがセットで利用されるのはそのためです。

言い換えると、ステートそのものは自身が保持する値を生成したり変更したりすることはありません。それは次に説明する メンテナ の役割です。

メンテナ

メンテナ はステートを生成したり更新したりするのが役割です。

Riverpod においては、大きく分けて以下の 2 つがメンテナの役割を果たします。

  1. 関数
  2. クラス

1 の 関数 の場合、その関数を実行した結果 return されたものがステートとして扱われます。


int counter(CounterRef ref) {
  return 0; // <- 0 という値をステートとして生成
}

一方で 2 の クラス の場合、

  • build() メソッドを実行した結果返却されたもの
  • .state に代入されたもの

のどちらか最後に行われたものがステートとして扱われます。[3]


class Counter extends _$Counter {
  
  int build() {
    return 0; // <- 0 という値をステートとして生成、もしくは更新
  }

  void increment() {
    state += 1; // <- state に 1 を加えた値をステートとして更新
  }
}

Riverpod はこの点においても厳しく役割分担をさせる作りになっていて、ステートの生成や更新は必ずこの関数もしくはクラスを通さなければならない作りになっています。

さらに、このメンテナに直接アクセスすることはできません。関数を呼び出すことは引数の CounterRef がないためできませんし、クラスを方を手動で生成してもそこから作られたステートを扱う方法がありません。

それを解決するための 3 つ目の役割として プロバイダ が存在します。

プロバイダ

プロバイダ の役割は以下の通りです。

  • Widget からステートにアクセスする方法を提供する
  • ステートの変更を Widget に通知する

つまり、ステートと Widget (もしくは別のメンテナ)を連携させるためのあれこれを担当するのがプロバイダと言えるでしょう。

Riverpod においては、そのまま同じ名前で Provider というクラスとその派生クラスが用意されています。[4]

説明のしやすさのためコード生成を使わずに実装した例は以下の通りです。

// 関数ベースのメンテナにアクセスするための Provider
final counterProvider = Provider<int>((ref) {
  return 0;
});
// クラスベースのメンテナにアクセスするための NotifierProvider
final counterProvider = NotifierProvider<CounterNotifier, int>(() {
  return CounterNotifier();
});

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

  void increment() => state += 1;
}

Widget build(BuildContext context, WidgetRef ref) {
  // WidgetRef を使って Provider を通してステートにアクセス
  final counter = ref.watch(counterProvider); 

  // 同様の方法でメンテナにもアクセス可能
  final counterNotifier = ref.watch(counterProvider.notifier); 

  return Text(counter.toString());
}

このように Provider を通して( WidgetRef を用いて)メンテナやステートにアクセスすることで、

  • 適切な場所からのみアクセスできるように制限する
  • ステートの変化を監視し、変更があればリビルドされる

といった仕組みが実現されています。

厳密に言うと、Riverpod におけるステートへのアクセスには ProviderElementProviderContainer のようなさまざまなクラスが関わっていますが、いったんわれわれアプリ開発者が目にする範囲で考え、 Provider とその派生クラスがこのプロバイダとしての役割を担っているということで話を進めます。


以上が状態管理を実現するための 3 つの要素です。状態管理の各手法がこの 3 つの役割をそのままの形で意識しているわけではないと思いますが、われわれ利用者としてはこの 3 つの分類を念頭に仕組みを整理することで、たとえば以下のような点についてうまく理解・説明できるようになると考えています。

3 つの役割分担から見えること

ここからは、以上の 3 つの役割分担を用いて説明ができることを箇条書きで簡単に列挙していきます。

オブジェクトを共有するだけでは「状態管理」ではない

Flutter アプリ開発を初めてしばらくすると、「シングルトンオブジェクトやグローバル変数で状態を管理すればいいのでは?」 と思いつく瞬間があるのではないでしょうか。

確かに、どこからでもアクセスできる場所にオブジェクトを置いておけば、同じオブジェクトを複数の Widget で共有できるようになります。

しかし、先ほどの 3 つの役割分担を思い出すと、この方法では少なくとも プロバイダ が不在であり、それによって「変更を検知してリビルドする」という挙動が実現できません。

また、明確な メンテナ も不在のため、ステートがどこから変更されるかが制御できず、不具合の温床にもなりテストコードも書きづらくなってしまうでしょう。

Riverpod パッケージではこの問題を回避するため、ステートの更新は必ずメンテナ自身が行う、もしくはメンテナ自身に用意されたメソッドを外部から呼び出す形が強制される作りになっています。外部から任意の値をステートとして更新することはできません。[5]

グローバル変数やシングルトンオブジェクトによる状態管理が不適切な理由はこのように説明できそうです。

Riverpod の Provider がグローバル変数なのは良いのか?

では、Riverpod における Provider オブジェクトがグローバル変数として定義されることに問題はないのでしょうか。

その答えとしては 「問題ない」 です。なぜなら、Provider はステートそのものではなく、あくまで 「Widget がステートにアクセスするための要素のひとつ」 に過ぎないからです。

ステートではないため Provider 自体が変化することはありません。またステートにアクセスするためにはセットで WidgetRef も必要になるため、Provider がグローバル変数であるからといってどこからでもステートにアクセスできるわけではありません。 同様に メンテナへのアクセスも制限されているため、どこからでも変更できるわけではありません。

Riverpod の作者である Remi さんは X(旧Twitter) で、「グローバル変数自体が問題なのではなく、可変なオブジェクトをグローバルにしてしまうことが問題」であると発言しています。

https://x.com/remi_rousselet/status/1697605587763187741?s=20

つまり Provider がグローバルに宣言されて問題ないのか、という点については Provider オブジェクト自体は不変であり、Provider オブジェクトがグローバルだからと言ってステートにグローバルでアクセスできるわけではないので問題ない、と説明できそうです。

Riverpod の ref.onDispose() の挙動を理解する

ここで問題です。

以下のようなコードを書いた場合、 ref.onDispose() に渡した関数が呼ばれるのはどのタイミングでしょうか。

/// [Counter] が保持する値を 2 倍した値をステートとして生成するクラス

class MultiCounter extends _$MultiCounter {
  
  int build() {
    final counter = ref.watch(counterProvider);

    ref.onDispose(() {
      // いつ呼ばれる?
      log('MultiCounter の dispose 処理');
    });

    return counter * 2;
  }; 
}

正解は、build() メソッドが呼ばれるたびに毎回呼ばれる です。つまり、watch 先の counterProvider がステートの変化を通知するたびに呼び出されるということになります。

では、同じタイミングで MultiCounter オブジェクト自体が破棄・再生成がされるかというとそんなことはありません。動作確認してみるとわかりますが、 ref.onDispose() に渡した関数の呼び出しと Notifier オブジェクトの破棄は無関係 です。

この挙動も、メンテナステート の役割分担で説明ができます。

つまり、 ref.onDispose() に渡した関数が呼ばれるのはあくまで ステートが破棄された時 であって、 メンテナのライフサイクルとは関係がない ということです。

ref.watch(counterProvider) と書いておけば、counterProvider が変更を通知するたびに MultiCounterbuild() メソッドが呼び出されますが、この時の流れを細かく説明すると、

  1. MultiCounter が保持するステートが破棄される
  2. ステートが破棄されたので ref.onDispose() に渡した関数が呼ばれる
  3. build() メソッドが呼ばれて新しいステートが生成される

となります。

まとめると、 onDispose() の呼び出しと Notifier オブジェクトの破棄は関係ありません。そのため onDispose() に渡した関数に Notifier オブジェクト自体の終了処理を書いてしまうと思わぬタイミングでそれが呼ばれて不具合につながる可能性がありますので注意が必要です。

ref.invalidate() の挙動を理解する

Riverpod パッケージには ref.invalidate()ref.refresh() といったメソッドが用意されています。役割としては 状態を破棄する(その後再生成する) と理解している方も多いと思いますが、その際 Notifier オブジェクトは破棄されるでしょうか。

答えは Notifier オブジェクトは破棄されない となります。

これも 3 つの要素で説明すると ref.invalidate() で破棄されるのはあくまでステートでありメンテナは関係ない と理解できます。 ref.invalidate()ref.refresh() を呼び出しても Notifier オブジェクトが自身のフィールドに保持しているオブジェクトはそのままです。

.autoDispose は?

最後に Riverpod の機能のひとつである .autoDispose ですが、これは ステートもメンテナもどちらも破棄されます 。どちらも破棄されますので、Notifier オブジェクトがフィールドに保持しているオブジェクトも破棄されます。

そして次に参照された時にはメンテナである Notifier オブジェクトの再生成から始まり build() メソッドの呼び出しによるステートの再生成が行われる、という動作になっています。

同じ dispose という単語が使われていますが、 ref.onDispose() が対象とするものと .autoDispose によって破棄される範囲は異なりますので、細かな制御をしたい場合は注意が必要になるでしょう。

その他の状態管理手法を理解する

さて、ステート メンテナ プロバイダ という役割分担を Riverpod パッケージを例として見てきたところで、他の状態管理手法がどうなっているかを少しだけ確認してみましょう。

StatefulWidget

Flutter 標準の仕組みである StatefulWidget はとてもシンプルです。

ステート、メンテナ、プロバイダすべてを State オブジェクトが担当します。

class _MyAppState extends State<MyApp> {
  int _counter = 0; // ステート

  // メンテナ
  void increment() {
    setState(() { // 変化を通知
      _counter += 1;
    });
  }

  
  Widget build(BuildContext context) {
    // 自身のオブジェクトが持つフィールドなので、直接アクセスできる。
    return Text(_counter.toString()); 
  }
}

そもそも StatefulWidget は Flutter のドキュメントで言うところの "Ephemeral State" を管理するための Widget です。

https://docs.flutter.dev/data-and-backend/state-mgmt/ephemeral-vs-app

複数の Widget で共有することをほぼ想定していませんので、役割を分担するよりもシンプルな文法で手軽に使えることを優先した作りになっていると考えられるでしょう。

InheritedNotifier

InheritedNotifier は Flutter 標準の状態管理手法である InheritedWidget をより扱いやすくしてくれるサブクラスです。

たとえばシンプルなカウンターを管理する場合、以下のようなコードで役割分担されます。

/// Widget からアクセスできる仕組みを提供し、ステートの変更を通知するためのプロバイダ
/// [InheritedNotifier] 自体も Widget のサブクラス
class CounterProvider extends InheritedNotifier<CounterNotifier> {
  
  // CounterProvider.of(context) でメンテナである [CounterNotifier] にアクセスできる
  static CounterNotifier of(BuildContext context) {
    final widget = context.dependOnInheritedWidgetOfExactType<LocationStateProvider>();
    assert(widget != null);
    return widget!.notifier!;
  }

  CounterProvider({
    super.key,
    required super.child,
  }) : super(notifier: CounterNotifier());
}

/// メンテナ
/// ステートとして int 型の [value] フィールドを保持している
class CounterNotifier extends ValueNotifier<int> {
  CounterNotifier() : super(0);

  void increment() {
    value += 1;
  }
}

Widget build(BuildContext context, WidgetRef ref) {
  // CounterProvider.of() を使ってメンテナやステートにアクセス
  final counterNotifier = CounterProvider.of(context); 

  return Text(counterNotifier.value.toString());
}

この例の場合、コード上はステート、メンテナ、プロバイダと分割されているように見えますが、CounterNotifier オブジェクトの .value フィールドに直接値を代入しようと思えばできてしまいますし、ステートにアクセスするための .of() メソッドの実装内容も作る側次第になっています。

そのため、Riverpod に比べて 3 つの役割分担が強制されるようなことはなく、ゆるい制限の中でアプリ開発者それぞれが実装方法を考えられる設計になっていると言えそうです。

MobX

MobX パッケージは少し独特な考え方になっています。


class Counter = CounterBase with _$Counter;

/// 任意のクラスを定義し、[Store] を mixin すると
/// ステートの変化を通知できるようになる
abstract class CounterBase with Store {
  CounterBase();

  // ステートとなるフィールドには @observable を付与
  
  Observable<int> count = Observable(0);

  // メンテナとなるメソッドには @action を付与
  
  void increment() {
    count.value += 1;
  }
}
// 任意の方法で Counter オブジェクトを受け取る
final Counter _counter;


Widget build(BuildContext context) {
  // Observer を使ってアクセスすると変化の通知を受け取れるようになる
  return Observer(
    builder: (_) {
      return Text('${_counter.value}');
    }
  );
}

Store を mixin したクラスを用意し、ステートにしたいフィールド、メンテナにしたいメソッドそれぞれに @observable@action をつけてコード生成することでそれぞれに役割を付与できます。

ステート・メンテナ・プロバイダを複数のクラスで分業するのではなく、ひとつのクラスにまとめつつそれぞれに対してひと工夫入れることでそれぞれが役割を果たせるような作りになっています。

また、そのひとまとめのオブジェクトに Widget からアクセスする方法が任意であることも特徴的です。Widget のコンストラクタで受け取るもよし、Riverpod のようなオブジェクトを Widget 間で共有する機能を持った別パッケージを使うもよし、get_it パッケージのようなサービスロケータを使うもよし、とにかく Observer.builder の中で @observable なフィールドを参照するだけで変更の検出までできるようになるため、アプリの設計が柔軟にできる特徴があると言えそうです。

まとめ

Flutter における状態管理は、「状態に参照できれば OK」ではありません。

状態の生成や更新・破棄を安全に行い、その状態の変更を確実に検知してリビルドを発生させ、且つ状態へ手軽にアクセス(場合によっては Widget 外からも)できるよう、それぞれの状態管理手法には工夫が入っています。

「工夫が入っている」ということは「そこに解決したい課題がある」ということですので、それを研究することは自分たちがアプリを開発する際に発生しがちな一般的な問題を把握することにもつながります。

実際問題として Riverpod しか使わないのだとしても、さまざまな状態管理の手法を観察してみることで、より安全なアプリの設計ができるようになるのではないかと思います。その観察のとっかかりのひとつとして、この記事で挙げた ステート メンテナ プロバイダ という役割分担を意識してみると良いかと思います。


脚注
  1. 完全に主観ですので、しっくり来ない場合は読み飛ばしてしまってください。それぞれの要素の呼称についても筆者が便宜的に名付けただけで、一般的な名称があるわけではありません。また「状態」や "Provider" などの一般的な名称やクラス名と分けるためにカタカナで表記しています。 ↩︎

  2. 正確には immutable でなくても動作はしますが、ChangeNotifierProvider を利用する場合を除いて推奨されていません。また ChangeNotifierProvider 自体も非推奨です。 ↩︎

  3. AsyncNotifier などが生成する AsyncValue の場合はまた少し話が変わってきますが、ここでは触れないこととします。 ↩︎

  4. というより、Riverpod のプロバイダを参考にして私がこの 3 つ目の役割を持つオブジェクトを __プロバイダ と名付けました。 ↩︎

  5. そのため、任意の値を代入できてしまう StateProvider は現在 非推奨 となっています。 ↩︎

GitHubで編集を提案

Discussion