【Flutter】新しい状態管理パッケージを作ってみた
こんにちは。広瀬マサルです。
riverpodやGetXなどすでに広く使われているパッケージが多数存在する中なにやってんだって感じですが
新しい状態管理パッケージを作りました。
イメージ的には
構造としてはProvider+ChangeNotifier
を1つのWidgetにまとめた感じ
使い勝手としてはriverpodのConsumerWidget
にflutter_hooksの簡便さと自由度を付け加えた感じ
です。(雑ですが。。)
freezedやkanata_listenablesのようなbuild_runnerで特定クラスを出力するパッケージと相性が良いものを、と思い作りました。
使い方をまとめたので興味ある方はぜひ使ってみてください!
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+StatefulWidget
やProvider+ChangeNotifier
、riverpod+StateNotifier
、GetX
など多くの状態管理手法がありますが、このような分け方を明示的に行っているものは多くありません。
そのためパッケージの使い方で工夫したり、複数の状態管理手法を組み合わせることで意識的(あるいは無意識)に状態の扱い方を変えているのではないかと思います。
(私はApp stateをriverpodでEphemeral stateをStatefulWidgetで管理していました)
そこで上記の状態のタイプをさらに下記のようにスコープ
として再定義し、明示的に分けて利用できるような状態管理パッケージを作りました。
-
Widget
- 個別のウィジェット。Ephemeral stateと同義
-
ScopedWidget<T>
で取れるようになる - 表示・非表示のトグルやテキストフォームの現在の入力状態など個別Widgetに閉じた状態を管理
-
Page
- アプリの1つの画面。単一・複数の
Widget
を持つ想定 -
PageScopedWidget
で取れるようになる - ナビゲーションバーの現在の位置などの1画面に閉じた状態を管理
- アプリの1つの画面。単一・複数の
-
App
- アプリ全体。App stateと同義
- どこからでも取れる
- DBから取得したデータ、ログインの状態、ユーザープリファレンスなど、アプリ全体で利用される状態を管理
Ephemeral stateをPage
とWidget
の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
からはapp
とpage
が取得可能で、それぞれ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はStatelessWidget
やStatefulWidget
など何でも置くことができますが、状態を管理したい場合はScopedWidget
を作成することで可能になります。
WidgetRef
からはapp
とpage
、widget
が取得可能で、それぞれ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
がそのまま渡されます。
- callbackに渡される
- 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
がそのまま渡されます。
- callbackに渡される
- さらに実行された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を表示しておくといった実装が可能です。
- onメソッドが実行された瞬間の初回および
- disposed
- Widgetが破棄されたときに実行されます。
- initOrUpdate
-
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
-
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のみ利用可能です。
- .appを持つリファレンス、つまり
- on
RefHasPage
- .pageを持つリファレンス、つまり
PageRef
とWidgetRef
にScopedValueFunction
が追加されます。インターフェースが.pageのみ利用可能です。
- .pageを持つリファレンス、つまり
- on
RefHasWidget
- .widgetを持つリファレンス、つまり
WidgetRef
にScopedValueFunction
が追加されます。インターフェースが.widgetのみ利用可能です。
- .widgetを持つリファレンス、つまり
- on
AppScopedValueRef
- .app自体、つまり
PageRef.app
とWidgetRef.app
にScopedValueFunction
が追加されます。
- .app自体、つまり
- on
PageScopedValueRef
- .page自体、つまり
PageRef.page
とWidgetRef.page
にScopedValueFunction
が追加されます。
- .page自体、つまり
- on
WidgetScopedValueRef
- .widget自体、つまり
WidgetRef.widget
にScopedValueFunction
が追加されます。
- .widget自体、つまり
- on
AppScopedValueOrAppRef
- AppRefと.app自体、つまり
AppRef
とPageRef.app
、WidgetRef.app
のすべてのAppスコープにScopedValueFunction
が追加されます。
- AppRefと.app自体、つまり
- on
PageOrWidgetScopedValueRef
- .page自体、もしくは.widget自体、つまり
PageRef.page
、WidgetRef.page
、WidgetRef.widget
にScopedValueFunction
が追加されます。
- .page自体、もしくは.widget自体、つまり
- on
PageOrAppScopedValueOrAppRef
- AppRefと.app自体、つまり
AppRef
とPageRef.app
、PageRef.page
、WidgetRef.app
、WidgetRef.page
にScopedValueFunction
が追加されます。
- AppRefと.app自体、つまり
- on
QueryScopedValueRef<TRef extends Ref>
-
ScopedQuery
のproviderに渡されるRef
に対してScopedValueFunction
が追加されます。
-
ScopedValue
の新規作成
ScopedValueを新規に作成して新しい機能を持ったScopedValueFunction
を追加することも可能です。
例えば、Future
を返す処理を実行しFuture
が完了した際に画面を再描画する所謂FutureBuilder
のような機能を実装したい場合は下記のようにScopedValue
とScopedValueState
を新規作成します。
// 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をお待ちしてます!
また仕事の依頼等ございましたら、私のTwitterやWebサイトで直接ご連絡をお願いいたします!
GitHub Sponsors
スポンサーを随時募集してます。ご支援お待ちしております!
Discussion