📼

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

2022/10/31に公開

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

riverpodGetXなどすでに広く使われているパッケージが多数存在する中なにやってんだって感じですが

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

イメージ的には

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

使い勝手としてはriverpodConsumerWidgetflutter_hooksの簡便さと自由度を付け加えた感じ

です。(雑ですが。。)

freezedkanata_listenablesのようなbuild_runnerで特定クラスを出力するパッケージと相性が良いものを、と思い作りました。

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

katana_scoped

https://pub.dev/packages/katana_scoped

はじめに

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

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

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

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

State+StatefulWidgetProvider+ChangeNotifierriverpod+StateNotifierGetXなど多くの状態管理手法がありますが、このような分け方を明示的に行っているものは多くありません。

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

(私はApp stateをriverpodでEphemeral stateをStatefulWidgetで管理していました)

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

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

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

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

riverpodのConsumerWidgetのように特定の抽象クラスを継承してWidgetを作成するだけでよく、

特別なボイラープレートを書かずにflutter_hooksのように状態を管理することができます。

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

// counter_page.dart

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

  
  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のようにカスタムのメソッドを追加を容易に行えるようにしています。

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

// repository_value.dart

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

class Repository with ChangeNotifier {
  ~~~~~
}

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

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

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

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

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

  
  Repository build() => repository;
}

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

// test_page.dart

class TestPage extends PageScopedWidget {
  const TestPage();

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

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

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

なども可能になります。

インストール

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

flutter pub add katana_scoped

実装

事前準備

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

// 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});

  
  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からはapppageが取得可能で、それぞれAppスコープPageスコープで状態を取得できます。

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

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

  
  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はStatelessWidgetStatefulWidgetなど何でも置くことができますが、状態を管理したい場合はScopedWidgetを作成することで可能になります。

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

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

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

  
  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,
  });

  
  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のようにグローバルに状態を提供するクエリーを定義しておきそれを読み込ませて状態を管理します。
    • 詳しくは後述

ScopedQuery

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

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

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

class TestPage extends PageScopedWidget {
  
  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スコープで管理したいのでデフォルトだと下記のように利用します。

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

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

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

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

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

追加にはextensionを用います。

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

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

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

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

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

ScopedValueの新規作成

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

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

// future_value.dart

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

  final Future<T> future;

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

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

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

  
  Future<T> build() => _future;
}

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

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

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

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

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

おわりに

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

また、こちらにソースを公開しているのでissueやPullRequestをお待ちしてます!

また仕事の依頼等ございましたら、私のTwitterWebサイトで直接ご連絡をお願いいたします!

https://mathru.net/ja/contact

GitHub Sponsors

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

https://github.com/sponsors/mathrunet

Discussion