【Flutter, Dart】Signals 6.0 の新機能を紹介
状態管理パッケージのSignalsが5.5→6.0で大幅に機能が追加されたのでご紹介します。
Signal
やComputed
が作れる
Mixinで独自のこれまでは
-
Signal
(Riverpod
のStateProvider
に相当) -
Computed
(Riverpod
のProvider
に相当)
を使って状態管理するパッケージでした。
それでもAsyncSignal
やFutureSignal
など大抵の機能が揃っているので便利でしたが、6.0からはMixinを組み合わせることで独自のSignal
が作れるようになりました。
これによってRiverpod
のNotifierProvider
やAsyncNotifierProvider
のような使い方も可能になっただけではなく、Signals独自の特長を生かした状態管理が行えるようになりました。
ChangeStackSignalMixin
過去の状態を記憶し、undo
とredo
で自由に復元したりできるようになるMixin
です。
テキストエディターやペイントアプリによくある機能が簡単に実装できますし、API周りでも使い所がありそうですね。
class MySignal extends Signal<int> with ChangeStackSignalMixin<int> {
MySignal(super.internalValue);
int get limit => 3; // オーバーライドすると履歴に上限が持たせられる
}
void main() {
final signal = MySignal(0);
signal.value = 1;
print(signal.canUndo); // true
signal.undo();
print(signal.value); // 0
print(signal.canUndo); // false
signal.redo();
print(signal.value); // 1
signal.value = 2;
signal.value = 3;
signal.value = 4;
signal.undo();
print(signal.value); // 3
// historyでundo、redosでredoする前・後の値を一覧で取得できる
print(signal.history); // {(previousValue: 1, value: 2), (previousValue: 2, value: 3)}
print(signal.redos); // {(previousValue: 3, value: 4)}
}
ChangeStackSignal
もあるので、独自Signal
を作るほどでない場合は一行で作れます。
final history = changeStack(0);
TrackedSignalMixin
Signal
の初期値と一つ前の値を記憶できます。
class MySignal extends Signal<int> with TrackedSignalMixin<int> {
MySignal(super.internalValue);
}
void main() {
final signal = MySignal(0);
print(signal.initialValue); // 0
print(signal.previousValue); // null
signal.value = 1;
print(signal.previousValue); // 0
signal.value = 2;
print(signal.previousValue); // 1
}
5.5まではSignal
自体にpreviousValue
というプロパティーがありましたが、それがMixin
に移行した形です。
今回のバージョンアップでの破壊的変更点ですが、これまではpreviousValue
を使わなくても以前の値を保持し続けていたので、メモリー効率と処理速度が向上したとも言えます。
こちらも一行で書くことも出来ます。
final history = trackedSignal(0);
EventSinkSignalMixin
add
で値を、addError
でエラーを通知するMixin
です。
AsyncState
はhasValue
、hasError
、isLoading
で状態を管理するステートクラスですが、そこからloading
の管理を抜いた形です。
class MySignal extends Signal<AsyncState<int>> with EventSinkSignalMixin<int> {
MySignal(int value) : super(AsyncState.data(value));
}
void main() {
final signal = MySignal(0);
signal.add(1);
print(signal.value.hasValue); // true
print(signal.value.value); // 1
signal.addError('error(example)');
print(signal.value.hasError); // true
print(signal.value.error); // error(example)
signal.close();
print(signal.disposed); // true
}
ListSignalMixin
MapSignalMixin
SetSignalMixin
IterableSignalMixin
RiverpodでList
やMap
を状態管理する際、add
や[]
で変更するとref.watch
で画面が更新されないという問題があります。
ref.notifyListeners()
で手動通知すれば良いのですが手間ですし、そもそも更新されない理由を理解するにはポインターのアドレスが…とかImmutableじゃないから…とか難しい理屈を学ぶ必要がありました。
Signals
では不要です。
// List, Set, Map のMixinはIterableSignalMixinとセットでMixinする
class MyListSignal extends Signal<List<int>>
with IterableSignalMixin<int, List<int>>, ListSignalMixin<int, List<int>> {
MyListSignal(super.internalValue);
}
void main() {
final signal = MyListSignal([0]);
// 値の変更があるとprintする(初期化時にも一度呼ばれる)
signal.subscribe(print); // [0]
signal.add(1); // [0, 1]
signal.add(2); // [0, 1, 2]
signal[0] = 100; // [100, 1, 2]
signal.removeLast(); // [100, 1]
signal.clear(); // []
// value経由で更新すると通知されないので注意
signal.value.add(3);
}
class MyMapSignal extends Signal<Map<String, int>>
with MapSignalMixin<String, int, Map<String, int>> {
MyMapSignal(super.internalValue);
}
こちらもそれぞれ一行で書くことも出来ます。
final priceListsignal = listSignal([100]);
final itemMapSignal = mapSignal({"りんご": 100});
final priceSetSignal = setSignal({100});
final priceIterableSignal = iterableSignal([100]);
QueueSignalMixin
dart:collection
パッケージにあるQueue
にも対応しています。
いわゆる双方向リストで、先頭に追加する速度がList
より早い代わりに一度追加した値の更新が出来ないという特徴があります。
class MySignal extends Signal<Queue<int>>
with QueueSignalMixin<int, Queue<int>> {
MySignal(super.internalValue);
}
void main() {
final signal = MySignal(Queue.from([0]));
// 値の変更があるとprintする(初期化時にも一度呼ばれる)
signal.subscribe(print); // {0}
signal.addFirst(-1); // {-1, 0}
signal.addLast(1); // {-1, 0, 1}
signal.removeFirst(); // {0, 1}
}
final signal = queueSignal(Queue.from([0]));
StreamSignalMixin
Dart標準のStream
に対応するSignal
です。
最初からbroadcast
されているので二つ以上同時にlisten
できます。
これを使うとStreamBuilder
での画面更新も可能になります。
class MySignal extends Signal<int> with StreamSignalMixin<int> {
MySignal(super.internalValue);
}
void main() {
final signal = MySignal(1);
final subscription = signal.distinct().listen(print); // 1
signal.value = 2; // 2
signal.value = 3; // 3
// distinct()をlistenしているので以前と同じ値は通知されない
signal.value = 3;
signal.value = 4; // 4
subscription.cancel();
// listenをキャンセルしているので通知されない
signal.value = 5;
}
こちらもstreamSignal
がありますが使い勝手がかなり異なります(詳細はstreamSignal
のコメントを参照)
ValueNotifierSignalMixin
ValueListenableSignalMixin
それぞれFlutter標準のValueNotifierとValueListenableに対応させられるようになるMixin
です。
こちらは少し解説が長くなるので公式ドキュメントをお読みください。
Mixin
を作る
独自のChangeStackSignalMixin
やTrackedSignalMixin
の実装を見ていただくと分かりやすいかと思いますが、公式のMixin
は非常に簡潔な作りをしています。
これらを参考にすれば、用途に合わせて独自のMixin
を作ることも簡単に出来ます。
公式ドキュメントでは例としてSharedPreferences
で値の保存と読み出しを行うPersistedSignalMixin
の作り方を解説しています。
Signal
の代わりにComputed
を使う
Mixin
は全てComputed
でも使用可能です。
class MyComputed extends Computed<int> with TrackedSignalMixin<int> {
MyComputed(super.internalValue);
}
void main() {
final price = signal(0);
final priceComputed = MyComputed(() => price.value);
effect(() {
print("${priceComputed.value}, ${priceComputed.previousValue}");
}); // 0, null
price.value = 1; // 1, 0
}
SignalsProvider
でWidget間受け渡し
これまではDIパッケージを使うか、InheritedWidget
やグローバル変数を使わないとFlutterのWidgetにSignal
を受け渡すことが出来ませんでした。
6.0からはproviderのSignal版であるSignalProvider
が追加されたので、Signals単体で状態管理が可能になりました。
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
// `SignalProvider`で受け渡せるようにするにはSignalではなくFlutterSignalを継承する
class MySignal extends FlutterSignal<int> with TrackedSignalMixin<int> {
MySignal(super.internalValue);
void increment() => value += 1;
}
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
// MySignalsを注入
home: SignalProvider(
create: () => MySignal(0),
child: const MyHomePage(),
),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
if (SignalProvider.of<MySignal>(context)?.value case int count)
Center(child: Text("value: $count")),
if (SignalProvider.of<MySignal>(context)?.previousValue
case int previousCount)
Center(child: Text("previousValue: $previousCount")),
]),
floatingActionButton: FloatingActionButton(
onPressed: () =>
// 画面更新と関係ない場所では listen: false を付ける
SignalProvider.of<MySignal>(context, listen: false)?.increment(),
child: Text("+"),
),
);
}
}
とはいえ読んでいただくと分かる通り、context.watch
やcontext.read
がまだ無いのでちょっと長いですね(SignalMultiProvider
やSignalBuilder
も欲しい)
一昔前のproviderパッケージという印象なので、バージョンアップに乞うご期待です。
おわりに
これまでのSignalsは、単体で使うというより他の状態管理パッケージと組み合わせて使う印象でしたが、6.0からはこれ一本でアプリの状態管理が出来る上に、他の状態管理パッケージにはない特長も増えたかなと思います。
私は今後小規模なアプリ開発ではSignals単体の設計も選択肢にしていこうと思います(特に通信がほとんど不要なアプリ)
備考
本記事は以下の環境で検証しました
Flutter 3.27.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 17025dd882 (3 weeks ago) • 2024-12-17 03:23:09 +0900
Engine • revision cb4b5fff73
Tools • Dart 3.6.0 • DevTools 2.40.2
Discussion