こんにちは。広瀬マサルです。
riverpodやGetXなどすでに広く使われているパッケージが多数存在する中なにやってんだって感じですが
新しい状態管理パッケージを作りました。
イメージ的には
構造としてはProvider+ChangeNotifierを1つのWidgetにまとめた感じ
使い勝手としてはriverpodのConsumerWidgetにflutter_hooksの簡便さと自由度を付け加えた感じ
です。(雑ですが。。)
freezedやkanata_listenablesのようなbuild_runnerで特定クラスを出力するパッケージと相性が良いものを、と思い作りました。
使い方をまとめたので興味ある方はぜひ使ってみてください!
katana_scoped
はじめに
Flutterの状態は公式にも書かれている通り下記の2つに分かれます。
-
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});
@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のようにカスタムのメソッドを追加を容易に行えるようにしています。
その際に明示的にスコープを指定することで利用者がスコープを意識すること無く利用することができるようになります。
// 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;
}
上記の実装を予め行っておくと下記のように利用できます。
// 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の値が更新されたら再描画
なども可能になります。
インストール
下記パッケージをインポートします。
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});
@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スコープで状態を取得できます。
// 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スコープで状態を取得できます。
// 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がそのまま渡されます。
-
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 [] })- 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 {
@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スコープで管理したいのでデフォルトだと下記のように利用します。
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;
@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のメソッドを介して下記のように書きます。
extension FutureValueRefExtension on Ref {
Future<T> useFuture<T>(Future<T> Function() callback) {
return getScopedValue(
(ref) => 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
スポンサーを随時募集してます。ご支援お待ちしております!