🛜

【Flutter, Dart】Signals 6.0 の新機能を紹介

2025/01/07に公開

状態管理パッケージのSignalsが5.5→6.0で大幅に機能が追加されたのでご紹介します。

Mixinで独自のSignalComputedが作れる

これまでは

  • SignalRiverpodStateProviderに相当)
  • ComputedRiverpodProviderに相当)

を使って状態管理するパッケージでした。
それでもAsyncSignalFutureSignalなど大抵の機能が揃っているので便利でしたが、6.0からはMixinを組み合わせることで独自のSignalが作れるようになりました。

これによってRiverpodNotifierProviderAsyncNotifierProviderのような使い方も可能になっただけではなく、Signals独自の特長を生かした状態管理が行えるようになりました。

ChangeStackSignalMixin

過去の状態を記憶し、undoredoで自由に復元したりできるようになる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です。
AsyncStatehasValuehasErrorisLoadingで状態を管理するステートクラスですが、そこから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でListMapを状態管理する際、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);
}
MapSignalMixinはキーとバリューの型を併記する
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標準のValueNotifierValueListenableに対応させられるようになるMixinです。
こちらは少し解説が長くなるので公式ドキュメントをお読みください。
https://dartsignals.dev/mixins/value-notifier/
https://dartsignals.dev/mixins/value-listenable/

独自のMixinを作る

ChangeStackSignalMixinTrackedSignalMixinの実装を見ていただくと分かりやすいかと思いますが、公式のMixinは非常に簡潔な作りをしています。
これらを参考にすれば、用途に合わせて独自のMixinを作ることも簡単に出来ます。

公式ドキュメントでは例としてSharedPreferencesで値の保存と読み出しを行うPersistedSignalMixinの作り方を解説しています。

https://dartsignals.dev/guides/persisted-signals/

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.watchcontext.readがまだ無いのでちょっと長いですね(SignalMultiProviderSignalBuilderも欲しい)
一昔前の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
GitHubで編集を提案
合同会社zoome(ズーム)

Discussion