🧩

[Flutter] 状態の再分類とその管理例

2024/11/20に公開

はじめに

Flutter の状態管理、悩ましいですよね...?
プロダクトの仕様が複雑化するにつれて、アプリケーションコードが複雑になったり、状態の整合性を欠くことが増えてきたり、そのバグを特定するのが困難になったり...
例えば、複数の Widget 間での状態の共有がうまくいかない、状態の更新が即時に反映されないといった問題に直面したことはありませんか?
本記事ではそんなお悩みを解消すべく、従来の状態を再分類することで Flutter の状態管理における新たな視点を考察します。

対象読者

  • Flutter の状態管理に悩んでいる方
  • ephemeral state と app state の状態の分類に違和感を感じている方
  • flutter_hooks や riverpod を使った状態管理手法を整理したい方

本記事で解説しないこと

  • flutter_hooks や riverpod の基本的な使い方
  • MVVM や Clean Architecture などのアーキテクチャパターン
    • MVVM の ViewModel については軽く言及します
  • ephemeral state, app state, SSOT(Single Source of Truth), 単方向データフローの詳説
    • 参考文献へのリンクを記載しているので、適宜参照ください

背景

Flutter の公式ドキュメントでは、状態を ephemeral state と app state に分類しています[1]

  • ephemeral state: 単一の Widget に閉じた一時的な状態
  • app state: アプリの多くの部分で共有される永続的な状態

ここで、状態のスコープと状態の SSOT(Single Source of Truth) のライフタイムに着目すると、以下のマトリクスが作成できそうです。

scope \ lifetime 一時的 永続的
単一の Widget に閉じた ephemeral state ?
アプリの多くの部分で共有される ? app state

では、「単一の Widget に閉じた永続的な状態」や「アプリの多くの部分で共有される一時的な状態」は、どのように管理したらよいのでしょうか?
これらは ephemeral state や app state の管理手法とは異なるのでしょうか?

上記のマトリクスより状態を再分類し、それぞれの状態の管理手法について考えてみたいと思います。

準備

はじめに、状態管理を考える上で定義したい用語や採用したい原則について共有します。

状態

Flutter 公式ドキュメントを参照すると、状態とは(アプリ実行中にメモリ内に存在するデータのうち) UI を再構築するために必要なデータ[2]です。
これは以下の式にも表されています。


UI=f(state). https://docs.flutter.dev/data-and-backend/state-mgmt/declarative.

SSOT の原則

SSOT の原則は、Android Developers のアプリアーキテクチャガイドより部分的に引用しながら見ていきます。

アプリ内で新しいデータ型を定義するときは、信頼できる唯一の情報源(SSOT)を割り当てる必要があります。[3]

新たに状態(UI の再構築に必要なデータ)が必要になった場合、その状態の SSOT がどこなのかを意識することが大切だという解釈です。例えば、その状態の SSOT はサーバやクラウド、ローカルストレージなのか、メモリ内なのかなどです。状態の SSOT がメモリ内の場合には、状態 = SSOT となります。

SSOT はそのデータの「オーナー」であり、SSOT のみがそのデータを変更またはミューテーションできます。そのために、SSOT は不変の型を使用してデータを公開します。[4]

したがって、状態は read-only です。ただし状態の SSOT がメモリ内の場合、つまり状態 = SSOT の場合は、状態は read/write 可能です。

SSOT がデータを変更するには、関数を公開するか、他の型が呼び出すことができるイベントを受け取ります。[5]

UI を変更したい場合は SSOT を直接変更する、もしくは公開されているインターフェースを利用します。

単方向データフロー

単方向データフローは、状態が下方に流れ、SSOT を変更するイベントが上方に流れる設計パターンです[6]

再び Android Developers のアプリアーキテクチャガイドより具体的な流れを引用します。

一般的にアプリデータはデータソースから UI に流れます。ボタンの押下などのユーザー イベントは UI から SSOT に流れ、SSOT でアプリデータが変更されて、不変の型で公開されます。[7]

SSOT の原則で示したフローチャートからもわかるように、SSOT の原則に従って状態管理を行うと上記で引用した具体例の流れのように自然と単方向データフローになります。これによって SSOT の原則のメリット[8]を享受できます。

SSOT の原則に則していない状態管理例

下記のフローチャートのように、

  • 状態をサーバから取得し、その状態を read/write 可能なデータとして保持する
  • UI からその状態を経由してサーバにイベントを送信する

これらは SSOT の原則には則っていないと言えるでしょう。

後述しますが、この管理手法が最適解である場合も存在します。


以上の準備をもとに、本題に進んでいきましょう。

状態の再分類とその管理手法

背景で示したように、状態のスコープと状態の SSOT のライフタイムに着目して状態を分類してみます。

  • 状態のスコープ(2 通り): 単一の Widget に閉じた / アプリの多くの部分で共有される
  • 状態の SSOT のライフタイム(2 通り): 一時的 / 永続的

上記の組み合わせで計 4 通りの状態に分類します。
それぞれの命名は以下とします。

  • 単一の Widget に閉じた: local
  • アプリの多くの部分で共有される: global
  • 一時的: transient (※ ephemeral とすると ephemeral state と混同するため)
  • 永続的: persistent

以上より、下記のマトリクスが作成できます。

scope \ lifetime 一時的 (transient) 永続的 (persistent)
単一の Widget に閉じた
(local)
① local transient state ② local persistent state
アプリの多くの部分で共有される
(global)
③ global transient state ④ global persistent state

※ local / global, transient / persistent の命名は私が独自に命名したものなので、違和感があれば各自で命名し直してください。
※ 状態管理には flutter_hooks, riverpod を用いることとします(一部、graphql_flutter を含みます)。

これらの状態それぞれについて管理手法を考えてみます。

① local transient state (単一の Widget に閉じた一時的な状態)

この状態は例えば、単一の画面で利用されるチェックボックスの ON/OFF のチェック状態やテキストフォームの入力内容などです。
この状態の SSOT はメモリ内 (HookWidgetHookState オブジェクト)です。
管理手法は flutter_hooks の useState, useTextEditingController などを使います。もちろん、StatefulWidget でも構いません。

実装例
class LocalTransientStateSample extends HookWidget {
  const LocalTransientStateSample({super.key});

  
  Widget build(BuildContext context) {
    final state = useState(0);

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('${state.value}'),
        TextButton(
          onPressed: () => state.value++,
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

② local persistent state (単一の Widget に閉じた永続的な状態)

この状態は例えば、単一の画面で利用される API レスポンスなどです。
この状態の SSOT はサーバ、クラウド、ローカルストレージなどです。
管理手法は flutter_hooks の useFuture, useStream, useMemoized, useQuery などを使います。

useFuture, useStream の戻り値は AsyncSnapshot のため、非同期の状態を表示し分けるような汎用 Widget を ④ と共通で利用したい場合には ④ のアプローチを選択しても良さそうですが、この場合その状態は Widget の近くに配置 (colocate) するのが望ましいと思います。

useQuery は graphql_flutter + graphql_codegen の利用下において operation を定義すると build_runner によって自動生成される hook です。内部実装で useStream を使用しています。

SSOT から非同期で状態を取得する方法が Future の場合には、SSOT の変更後に useMemoized を再評価する必要があります。Stream の場合とは異なり、SSOT の変更は伝播されないためです。

実装例

int _ssot = 0;

Future<int> _getSsot() async {
  return Future.delayed(const Duration(milliseconds: 500), () => _ssot);
}

Future<void> _updateSsot() async {
  await Future.delayed(const Duration(milliseconds: 500), () => _ssot++);
}

class LocalPersistentStateSample extends HookWidget {
  const LocalPersistentStateSample({super.key});

  
  Widget build(BuildContext context) {
    final pendingUpdate = useState<Future<void>?>(null);
    final asyncSnapshot = useFuture(
      useMemoized(
        () async {
          if (pendingUpdate.value != null) {
            await pendingUpdate.value;
          }
          return _getSsot();
        },
        [pendingUpdate.value],
      ),
    );

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        if (asyncSnapshot.connectionState == ConnectionState.waiting)
          const CircularProgressIndicator.adaptive()
        else
          Text('${asyncSnapshot.data}'),
        TextButton(
          onPressed: (asyncSnapshot.connectionState == ConnectionState.waiting)
              ? null
              : () {
                  // SSOT に変更を促す + 再評価
                  pendingUpdate.value = _updateSsot();
                },
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

③ global transient state (アプリの多くの部分で共有される一時的な状態)

この状態は例えば、複数の画面で共有される検索クエリや絞り込み、フィルター設定などです。
この状態の SSOT はメモリ内(ランタイム)です。
管理手法は riverpod の Notifier を使います。

実装例
(dependencies: [])
class GlobalTransientState extends _$GlobalTransientState {
  
  int build() => 0;

  void increment() => state++;
}

class GlobalTransientStateSample extends ConsumerWidget {
  const GlobalTransientStateSample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(globalTransientStateProvider);

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('$state'),
        TextButton(
          onPressed: () =>
              ref.read(globalTransientStateProvider.notifier).increment(),
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

④ global persistent state (アプリの多くの部分で共有される永続的な状態)

この状態は例えば、複数の画面で利用される API レスポンスなどです。
この状態の SSOT はサーバ、クラウド、ローカルストレージなどです。
管理手法は riverpod の Provider, FutureProvider, StreamProvider を使います。

SSOT から非同期で状態を取得する方法が Future の場合には、SSOT の変更後に provider を invalidate する必要があります。Stream の場合とは異なり、SSOT の変更は伝播されないためです。

参考: Using ref.invalidateSelf() to refresh the provider.

実装例

int _ssot = 0;

(dependencies: [])
Future<int> globalPersistentState(GlobalPersistentStateRef ref) async {
  return Future.delayed(const Duration(milliseconds: 500), () => _ssot);
}

Future<void> _updateSsot() async {
  await Future.delayed(const Duration(milliseconds: 500), () => _ssot++);
}

class GlobalPersistentStateSample extends HookConsumerWidget {
  const GlobalPersistentStateSample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(globalPersistentStateProvider);
    final pendingUpdate = useState(false);
    final isLoading = asyncValue.isLoading || pendingUpdate.value;

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        if (isLoading)
          const CircularProgressIndicator.adaptive()
        else
          Text('${asyncValue.value}'),
        TextButton(
          onPressed: isLoading
              ? null
              : () async {
                  pendingUpdate.value = true;
                  // SSOT に変更を促す
                  await _updateSsot();
                  // invalidate
                  ref.invalidate(globalPersistentStateProvider);
                  pendingUpdate.value = false;
                },
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

⑤ 例外: SSOT から再取得するとパフォーマンスの低下が懸念される場合

これは、SSOT の原則に則していない状態管理例で挙げたケースです。
②と④で Future をインターフェースにもつ SSOT から状態を取得する場合には、適宜 hook の再評価や provider の invalidate が必要であることに触れました。
ただ、SSOT から再取得するとパフォーマンスの低下が懸念される場合には、SSOT から再取得せずに read/write 可能な状態を変更するという選択肢もあります。

例えば、あるリスト UI に対してリストアイテムを追加/削除する際に、リストアイテム数が膨大であったり、追加/削除が頻繁に行われたりするのであれば SSOT から再取得しないことを検討してもよいかもしれません。
この場合の SSOT はサーバ、クラウド、ローカルストレージなどです。
管理手法は riverpod の Notifier を使います。

ただ、この管理手法は SSOT の原則に則しておらず、また単方向データフローでもないため、基本的には利用しない方針が良さそうです。
(MVVM パターンの ViewModel は、①と②の状態を一緒くたに Notifier で以下のフローチャートのように管理していることが多い印象です)

参考: Updating our local cache to match the API response
  : Updating the local cache manually

実装例

int _ssot = 0;

(dependencies: [])
class UpdateLocalCache extends _$UpdateLocalCache {
  
  Future<int> build() {
    return Future.delayed(const Duration(milliseconds: 500), () => _ssot);
  }

  Future<void> updateSsot() async {
    state = const AsyncLoading();
    await Future.delayed(const Duration(milliseconds: 500), () => _ssot++);
    state = AsyncData(_ssot);
  }
}

class UpdateLocalCacheSample extends ConsumerWidget {
  const UpdateLocalCacheSample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(updateLocalCacheProvider);

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        if (asyncValue.isLoading)
          const CircularProgressIndicator.adaptive()
        else
          Text('${asyncValue.value}'),
        TextButton(
          onPressed: asyncValue.isLoading
              ? null
              : () => ref.read(updateLocalCacheProvider.notifier).updateSsot(),
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

まとめ

以下のマトリクスのように状態を分類し直し、SSOT と単方向データフローを意識してそれぞれの状態の管理手法について考えてきました。

scope \ lifetime 一時的 (transient) 永続的 (persistent)
単一の Widget に閉じた
(local)
① local transient state
flutter_hooks [read/write]
- useState など
② local persistent state
flutter_hooks [read-only]
- useFuture + useMemoized + 再評価
- useStream/useQuery
アプリの多くの部分で共有される
(global)
③ global transient state
riverpod [read/write]
- Notifier
④ global persistent state
riverpod [read-only]
- FutureProvider/Provider + invalidate
- StreamProvider

※ 状態管理には flutter_hooks, riverpod を利用(一部、graphql_flutter を含む)。

この分類と管理を導入するメリットは以下が挙げられます。

  • 枠組みがあることで、適切な管理手法の選択が容易になります。
  • 状態の SSOT を意識することで、状態の一貫性と予測可能性が向上します。これにより、バグの発生を抑え、デバッグやテストが容易になります。

デメリットは以下が挙げられます。

  • 状態を独自に分類、管理しているため、他の開発者やコミュニティと情報を共有する際に混乱を招く可能性があります。
  • 新たな分類と管理手法の学習コストが増加します。また、チーム全体でこの手法を採用する場合、メンバーの理解度を揃える必要があります。

あとがき

状態を ephemeral state と app state に二分することに少々疑問を抱いていましたが、再分類することで個人的には整理できたように感じました。

本記事の内容はソフトウェアアーキテクチャの中でも状態管理のみを取り上げました。
次回の記事では、この分類と管理手法を基に GitHub API (REST と GraphQL 両方) の Star 機能を利用したサンプル実装をたたき台にして、ディレクトリ構成やレイヤードアーキテクチャなどについて考えていきたいと思います。

脚注
  1. https://docs.flutter.dev/data-and-backend/state-mgmt/ephemeral-vs-app ↩︎

  2. https://docs.flutter.dev/data-and-backend/state-mgmt/ephemeral-vs-app#:~:text=whatever data you need in order to rebuild your UI at any moment in time ↩︎

  3. https://developer.android.com/topic/architecture?hl=ja#:~:text=アプリ内で新しいデータ型を定義するときは、信頼できる唯一の情報源(SSOT)を割り当てる必要があります。 ↩︎

  4. https://developer.android.com/topic/architecture?hl=ja#:~:text=SSOT はそのデータの「オーナー」であり、SSOT のみがそのデータを変更またはミューテーションできます。そのために、SSOT は不変の型を使用してデータを公開します。 ↩︎

  5. https://developer.android.com/topic/architecture?hl=ja#:~:text=SSOT がデータを変更するには、関数を公開するか、他の型が呼び出すことができるイベントを受け取ります。 ↩︎

  6. https://developer.android.com/develop/ui/compose/architecture?hl=ja#:~:text=単方向データフロー(UDF)は、状態が下方に流れ、イベントが上方に流れる設計パターンです。 ↩︎

  7. https://developer.android.com/topic/architecture?hl=ja#:~:text=一般的にアプリデータはデータソースから UI に流れます。ボタンの押下などのユーザー イベントは UI から SSOT に流れ、SSOT でアプリデータが変更されて、不変の型で公開されます。 ↩︎

  8. https://developer.android.com/topic/architecture?hl=ja#:~:text=特定のデータ,やすくなる。 ↩︎

Discussion