こんにちは。広瀬マサルです。
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
スポンサーを随時募集してます。ご支援お待ちしております!