【Flutter】新しい状態管理パッケージを作ってみた

こんにちは。広瀬マサルです。

[riverpod](https://pub.dev/packages/riverpod)や[GetX](https://pub.dev/packages/get)などすでに広く使われているパッケージが多数存在する中なにやってんだって感じですが

新しい状態管理パッケージを作りました。

イメージ的には

**構造としては`Provider+ChangeNotifier`を１つのWidgetにまとめた感じ**

**使い勝手としては[riverpod](https://pub.dev/packages/riverpod)の`ConsumerWidget`に[flutter_hooks](https://pub.dev/packages/flutter_hooks)の簡便さと自由度を付け加えた感じ**

です。（雑ですが。。）

[freezed](https://pub.dev/packages/freezed)や[kanata_listenables](https://pub.dev/packages/katana_listenables)のようなbuild_runnerで特定クラスを出力するパッケージと相性が良いものを、と思い作りました。

使い方をまとめたので興味ある方はぜひ使ってみてください！

# katana_scoped

https://pub.dev/packages/katana_scoped

# はじめに

Flutterの状態は公式にも書かれている通り下記の２つに分かれます。

[https://docs.flutter.dev/development/data-and-backend/state-mgmt/ephemeral-vs-app](https://docs.flutter.dev/development/data-and-backend/state-mgmt/ephemeral-vs-app)

- **Ephemeral state**
    - １つのWidget内で閉じた状態。ナビゲーションバーの現在の位置、テキストフォームの現在の入力状態など。
- **App state**
    - アプリ内で共有される状態。複数のWidget間で利用される。DBから取得したデータ、ログインの状態、ユーザープリファレンスなど。

**Ephemeral state**はWidgetが作成されると同時に利用可能となり、Widgetが破棄されると同時に破棄してほしいものです。対して**App state**はWidgetが破棄されても保持され続けどのWidgetでも同じように利用可能にしてほしいものです。

`State+StatefulWidget`や`Provider+ChangeNotifier`、`riverpod+StateNotifier`、`GetX`など多くの状態管理手法がありますが、このような分け方を明示的に行っているものは多くありません。

そのためパッケージの使い方で工夫したり、複数の状態管理手法を組み合わせることで意識的（あるいは無意識）に状態の扱い方を変えているのではないかと思います。

（私は**App state**をriverpodで**Ephemeral state**をStatefulWidgetで管理していました）

そこで上記の状態のタイプをさらに下記のように`スコープ`として再定義し、明示的に分けて利用できるような状態管理パッケージを作りました。

- `Widget`
    - 個別のウィジェット。**Ephemeral state**と同義
    - `ScopedWidget<T>`で取れるようになる
    - 表示・非表示のトグルやテキストフォームの現在の入力状態など個別Widgetに閉じた状態を管理
- `Page`
    - アプリの１つの画面。単一・複数の`Widget`を持つ想定
    - `PageScopedWidget`で取れるようになる
    - ナビゲーションバーの現在の位置などの１画面に閉じた状態を管理
- `App`
    - アプリ全体。**App state**と同義
    - どこからでも取れる
    - DBから取得したデータ、ログインの状態、ユーザープリファレンスなど、アプリ全体で利用される状態を管理

**Ephemeral state**を`Page`と`Widget`の2種類に分けたのはWidget間をまたがり画面単位で状態を管理する機会が多くより便利になると感じたからです。

実際マップなどをコントロールするときマップ内に配置されたWidgetそれぞれを1つのコントローラーで操作したいですが、メモリを節約するために画面を移動したときにコントローラーを破棄したいといった場合、Pageスコープで閉じたコントローラーを用意すると扱いやすかったりします。

[riverpod](https://pub.dev/packages/riverpod)のConsumerWidgetのように特定の抽象クラスを継承してWidgetを作成するだけでよく、

特別なボイラープレートを書かずに[flutter_hooks](https://pub.dev/packages/flutter_hooks)のように状態を管理することができます。

有名なカウンターアプリのサンプルがこのシンプルさで作成可能です。

```dart
// counter_page.dart

class CounterPage extends PageScopedWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, PageRef ref) {
    final counter = ref.page.watch((ref) => ValueNotifier(0));

    return Scaffold(
      appBar: AppBar(
        title: const Text("Test App"),
      ),
      body: Center(
        child: Text("${counter.value}"),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counter.value++;
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}
```

また、[flutter_hooks](https://pub.dev/packages/flutter_hooks)のようにカスタムのメソッドを追加を容易に行えるようにしています。

その際に明示的に`スコープ`を指定することで利用者がスコープを意識すること無く利用することができるようになります。

```dart
// repository_value.dart

extension RepositoryAppRefExtension on AppScopedValueOrAppRef {
  Repository repository(){
    return getScopedValue(
      (ref) => RepositoryValue(),
      listen: true,
    );
  }
}

class Repository with ChangeNotifier {
  ~~~~~
}

class RepositoryValue extends ScopedValue<Repository> {
  @override
  ScopedValueState<Repository, RepositoryValue> createState() =>
      RepositoryValueState();
}

class RepositoryValueState
    extends ScopedValueState<Repository, RepositoryValue> {
  late Repository repository;

  @override
  void initValue() {
    super.initValue();
    repository = Repository();
    repository.addListener(_handeldOnUpdate);
  }

  void _handeldOnUpdate() {
    setState(() {});
  }

  @override
  void dispose() {
    super.dispose();
    repository.removeListener(_handeldOnUpdate);
    repository.dispose();
  }

  @override
  Repository build() => repository;
}
```

上記の実装を予め行っておくと下記のように利用できます。

```dart
// test_page.dart

class TestPage extends PageScopedWidget {
  const TestPage();

  @override
  Widget build(BuildContext context, PageRef ref){
    // DB Repository for app.
    final respository = ref.app.repository();
    ~~~~
  }
}
```

上記の仕組みを応用することで状態の管理だけでなく

- ページやWidgetのライフサイクルの管理
- ページやWidgetの手動再描画
- Futureの終了を待って終了時に再描画
- Streamの値が更新されたら再描画

なども可能になります。

# インストール

下記パッケージをインポートします。

```dart
flutter pub add katana_scoped
```

# 実装

## 事前準備

必ず`AppRef`を作成し、`AppScoped`のWidgetをアプリのルート近くに配置します。

```dart
// main.dart
import 'package:flutter/material.dart';
import 'package:katana_scoped/katana_scoped.dart';

void main() {
  runApp(const MyApp());
}

final appRef = AppRef();

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return AppScoped(
      appRef: appRef,
      child: MaterialApp(
        home: const ScopedTestPage(),
        title: "Flutter Demo",
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
      ),
    );
  }
}
```

`AppRef`をグローバル変数で定義するとWidget外からでも状態を取得することが可能になります。

## ページ作成

ページを作成する際は`PageScopedWidget`を継承したWidgetを実装します。

`PageRef`からは`app`と`page`が取得可能で、それぞれ`Appスコープ`、`Pageスコープ`で状態を取得できます。

```dart
// counter_page.dart
import 'package:flutter/material.dart';
import 'package:katana_scoped/katana_scoped.dart';

class CounterPage extends PageScopedWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, PageRef ref) {
    final counter = ref.page.watch((ref) => ValueNotifier(0));

    return Scaffold(
      appBar: AppBar(
        title: const Text("Test App"),
      ),
      body: Center(
        child: Text("${counter.value}"),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counter.value++;
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}
```

## Widget作成

ページの配下のWidgetは`StatelessWidget`や`StatefulWidget`など何でも置くことができますが、状態を管理したい場合は`ScopedWidget`を作成することで可能になります。

`WidgetRef`からは`app`と`page`、`widget`が取得可能で、それぞれ`Appスコープ`、`Pageスコープ`、`Widgetスコープ`で状態を取得できます。

```dart
// scoped_test_page.dart
import 'package:flutter/material.dart';
import 'package:katana_scoped/katana_scoped.dart';

class ScopedTestPage extends PageScopedWidget {
  const ScopedTestPage({super.key});

  @override
  Widget build(BuildContext context, PageRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Test App"),
      ),
      body: ScopedTestContent(),
    );
  }
}

class ScopedTestContent extends ScopedWidget {
  const ScopedTestContent({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final widget = ref.widget.watch((ref) => ValueNotifier(0));
    final page = ref.page.watch((ref) => ValueNotifier(0));
    final app = ref.app.watch((ref) => ValueNotifier(0));

    return Column(
      children: [
        ListTile(
          title: Text(app.value.toString()),
          onTap: () {
            app.value++;
          },
        ),
        ListTile(
          title: Text(page.value.toString()),
          onTap: () {
            page.value++;
          },
        ),
        ListTile(
          title: Text(widget.value.toString()),
          onTap: () {
            widget.value++;
          },
        ),
      ],
    );
  }
}
```

## 利用可能な`ScopedValueFunction`

`Appスコープ`、`Pageスコープ`、`Widgetスコープ`で状態を取得する場合`ScopedValueFunction`を通して取得します。

パッケージ内で定義されているデフォルトの`ScopedValueFunction`は下記です。

- `T cache<T>(T Function(Ref ref) callback, { List<Object> keys = const [], String? name })`
    - `callback`で返した値をキャッシュします。
	    - callbackに渡される`ref`はこのメソッドが呼ばれたときの`Ref`がそのまま渡されます。
    - keysの値を変えた場合再度`callback`が実行され、キャッシュする値を更新することができます。
    - `name`を指定すると別の状態として保存することができます。
- `T watch<T extends Listenable>( T Function(Ref ref) callback, { List<Object> keys = const [], String? name, bool disposal = true })`
    - `callback`で返した値をキャッシュします。
	    - callbackに渡される`ref`はこのメソッドが呼ばれたときの`Ref`がそのまま渡されます。
    - さらに実行されたWidget上で値を監視し`T`の内部で`notifyListners`が実行された場合、Widgetが再描画されます。
    - keysの値を変えた場合再度`callback`が実行され、キャッシュする値を更新することができます。
    - `name`を指定すると別の状態として保存することができます。
    - `disposal`をfalseにすると与えられた`ChangeNotifier`が参照がなくなった場合破棄されなくなります。
- `OnContext on({ FutureOr<void> Function()? initOrUpdate,  VoidCallback? disposed,  List<Object> keys = const [], String? name })`
    - pageスコープ、Widgetスコープのみ実行可能
    - 実行されたWidgetのライフサイクル上で各処理を実行することが可能です。
        - initOrUpdate
            - onメソッドが実行された瞬間の初回および`keys`の値を変えて実行されたときに処理が走ります。
            - 値をFutureで返した場合、onメソッドから返される`OnContext.future`で終了を監視・検知することが可能です。
            - なにかしらの初期化処理を行い、終了するまで****CircularProgressIndicator****を表示しておくといった実装が可能です。
        - disposed
            - Widgetが破棄されたときに実行されます。
- `void refresh()`
    - 実行すると関連するWidgetが再描画されます。
- `T query<T>(ScopedQuery<T> query)`
    - [riverpod](https://pub.dev/packages/riverpod)のようにグローバルに状態を提供するクエリーを定義しておきそれを読み込ませて状態を管理します。
    - 詳しくは後述

### ScopedQuery

ScopedQueryを別途定義することで[riverpod](https://pub.dev/packages/riverpod)のようにグローバルで状態を提供するクエリーを発行し、使い回すことが可能です。各スコープの`T query<T>(ScopedQuery<T> query)`を利用することで状態を管理できます。

コールバック内で利用可能な`ref`でさらに他のクエリを読み込むことも可能です。

```dart
final valueNotifierQuery = ChangeNotifierScopedQuery(
  (ref) => ValueNotifier(0),
);

class TestPage extends PageScopedWidget {
  @override
  Widget build(BuildContext context, PageRef ref) {
    final valueNotifier = ref.page.query(valueNotifierQuery);

    return Scaffold(
      body: Center(child: Text("${valueNotifier.value}")),
    );
  }
}
```

ScopedQueryは、下記の種類が用意されています。

- ScopedQuery
    - コールバックで返した値を保持します。
    - `cache`と同等の機能を持ちます。
- ChangeNotifierScopedQuery
    - コールバックで返した値を保持して値の変更を監視します。
    - `watch`と同等の機能を持ちます。

また、各スコープに限定したScopedQueryを作成することも可能です。

- AppScopedQuery
    - Appスコープに限定したScopedQueryを作成します。
- PageScopedQuery
    - Pageスコープに限定したScopedQueryを作成します。
- WidgetScopedQuery
    - Widgetスコープに限定したScopedQueryを作成します。

# `ScopedValueFunction`の追加

## スコープを明示的に限定した利用

既存の`ScopedValueFunction`を利用して新しい`ScopedValueFunction`を追加することができます。

その場合、スコープを限定した利用を行うことも可能です。

例えばDBからのデータを取得するクラスをChangeNotifierを継承して`〇〇Repository`で作成したとします。

DBからのデータはAppスコープで管理したいのでデフォルトだと下記のように利用します。

```dart
final userRepository = ref.app.watch((ref) => UserRepository());
```

ただしこの場合、下記のようにも書けます。

```dart
final userRepository = ref.page.watch((ref) => UserRepository());
```

この場合状態は管理できますが、ページが破棄された段階で状態も合わせて破棄されてしまいます。

なので、アプリのスコープとして強制的に管理するために別途`ScopedValueFunction`を追加します。

追加には`extension`を用います。

```dart
// user_repository_extension.dart
 
extension UserRepositoryAppRef on RefHasApp {
  TRepository repository<TRepository extends Repository>(
    TRepository source,
  ) {
    return app.watch((ref) => source);
  }
}
```

上記のように実装することで下記のように利用することができるようになります。

```dart
final userRepository = ref.repository(UserRepository());
```

`on`で指定するクラスを変えると様々なスコープで`ScopedValueFunction`を定義することが可能です。

- on `Ref`
    - `AppRef`、`PageRef.app`、`PageRef.page`、`WidgetRef.app`、`WidgetRef.page`、`WidgetRef.widget`すべてに`ScopedValueFunction`が追加されます。
- on `AppRef`
    - `AppRef`のみに`ScopedValueFunction`が追加されます。
- on `PageRef`
    - `PageRef`のみに`ScopedValueFunction`が追加されます。
- on `WidgetRef`
    - `WidgetRef`のみに`ScopedValueFunction`が追加されます。
- on `RefHasApp`
    - .appを持つリファレンス、つまり`PageRef`と`WidgetRef`に`ScopedValueFunction`が追加されます。インターフェースが.appのみ利用可能です。
- on `RefHasPage`
    - .pageを持つリファレンス、つまり`PageRef`と`WidgetRef`に`ScopedValueFunction`が追加されます。インターフェースが.pageのみ利用可能です。
- on `RefHasWidget`
    - .widgetを持つリファレンス、つまり`WidgetRef`に`ScopedValueFunction`が追加されます。インターフェースが.widgetのみ利用可能です。
- on `AppScopedValueRef`
    - .app自体、つまり`PageRef.app`と`WidgetRef.app`に`ScopedValueFunction`が追加されます。
- on `PageScopedValueRef`
    - .page自体、つまり`PageRef.page`と`WidgetRef.page`に`ScopedValueFunction`が追加されます。
- on `WidgetScopedValueRef`
    - .widget自体、つまり`WidgetRef.widget`に`ScopedValueFunction`が追加されます。
- on `AppScopedValueOrAppRef`
    - AppRefと.app自体、つまり`AppRef`と`PageRef.app`、`WidgetRef.app`のすべてのAppスコープに`ScopedValueFunction`が追加されます。
- on `PageOrWidgetScopedValueRef`
    - .page自体、もしくは.widget自体、つまり`PageRef.page`、`WidgetRef.page`、`WidgetRef.widget`に`ScopedValueFunction`が追加されます。
- on `PageOrAppScopedValueOrAppRef`
    - AppRefと.app自体、つまり`AppRef`と`PageRef.app`、`PageRef.page`、`WidgetRef.app`、`WidgetRef.page`に`ScopedValueFunction`が追加されます。
- on `QueryScopedValueRef<TRef extends Ref>`
    - `ScopedQuery`のproviderに渡される`Ref`に対して`ScopedValueFunction`が追加されます。

## `ScopedValue`の新規作成

ScopedValueを新規に作成して新しい機能を持った`ScopedValueFunction`を追加することも可能です。

例えば、`Future`を返す処理を実行し`Future`が完了した際に画面を再描画する所謂`FutureBuilder`のような機能を実装したい場合は下記のように`ScopedValue`と`ScopedValueState`を新規作成します。

```dart
// future_value.dart

class FutureValue<T> extends ScopedValue<Future<T>> {
  const FutureValue(this.future);

  final Future<T> future;

  @override
  ScopedValueState<Future<T>, ScopedValue<Future<T>>> createState() =>
      FutureValueState<T>();
}

class FutureValueState<T> extends ScopedValueState<Future<T>, FutureValue<T>> {
  late final Future<T> _future;

  @override
  void initValue() {
    super.initValue();
    _future = value.future;
    _future.then(
      (value) => setState(() {}),
    );
  }

  @override
  Future<T> build() => _future;
}
```

これを`ScopedValueFunction`として追加したい場合は、`getScopedValue`のメソッドを介して下記のように書きます。

```dart
extension FutureValueRefExtension on Ref {
  Future<T> useFuture<T>(Future<T> Function() callback) {
    return getScopedValue(
      () => FutureValue(callback.call()),
      listen: true,
    );
  }
}
```

これを実際に利用すると下記のようになります。

```dart
ref.page.useFuture(() => Future.delayed(const Duration(seconds: 5))); // 5秒後に再描画
```

今回は単にFutureをそのまま返すだけですが、`FutureValueState<T>`で`snapshot`のオブジェクトを作り監視することでFutureBuilderのように状態をいつでも把握できるような仕組みも実装可能です。

# おわりに

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください！

また、[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しているのでissueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております！

https://github.com/sponsors/mathrunet