🚦

Signals.dartに入門する

2024/08/05に公開1

What is Signals.dart?

official
pub.dev

Signals features:

  • 🪡 Fine grained reactivity: Based on Preact Signals and provides a fine grained reactivity system that will automatically track dependencies and free them when no longer needed
  • ⛓️ Lazy evaluation: Signals are lazy and will only compute values when read. If a signal is not read, it will not be computed
  • 🗜️ Flexible API: Every app is different and signals can be composed in multiple ways. There are a few rules to follow but the API surface is small
  • 🔬 Surgical Rendering: Widgets can be rebuilt surgically, only marking dirty the parts of the Widget tree that need to be updated and if mounted
  • 💙 100% Dart Native: Supports Dart JS (HTML), Shelf Server, CLI (and Native), VM, Flutter (Web, Mobile and Desktop). Signals can be used in any Dart project

シグナルの特徴

  • 🪡 細粒度の反応性: Preact Signalsをベースとし、依存関係を自動的に追跡し、不要になったら解放する、きめ細かい反応性システムを提供する。
  • ⛓️ 遅延評価: シグナルは遅延的で、読み込まれたときだけ値を計算する。シグナルが読み込まれなかった場合、その値は計算されません。
  • 🗜️ 柔軟なAPI: アプリはそれぞれ異なり、シグナルは複数の方法で構成できます。従うべきルールはいくつかあるが、APIサーフェスは小さい。
  • 外科的レンダリング: ウィジェットは外科的に再構築することができ、ウィジェットツリーの更新が必要な部分のみを汚し、マウントされている場合はマークします。
  • 100%Dartネイティブ: Dart JS(HTML)、Shelf Server、CLI(およびネイティブ)、VM、Flutter(ウェブ、モバイル、デスクトップ)をサポートします。シグナルはどのDartプロジェクトでも使用できます。

プロジェクトを作って早速導入してみよう!

flutter create signals_tutorial --org com.jboycode
cd signals_tutorial
code .

add package:

flutter pub add signals

公式のカウンターのサンプル

確かにこれに似てる?

使うときのポイントは、このコードにcontextを渡すことみたい。React HooksのuseState()みたい。

late final Signal<int> counter = createSignal(context, 0);

全体のコード:

import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';

void main() {
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  ThemeData createTheme(BuildContext context, Brightness brightness) {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: Colors.blue,
        brightness: brightness,
      ),
      brightness: brightness,
      useMaterial3: true,
    );
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: createTheme(context, Brightness.light),
      darkTheme: createTheme(context, Brightness.dark),
      themeMode: ThemeMode.system,
      home: const CounterExample(),
    );
  }
}

class CounterExample extends StatefulWidget {
  const CounterExample({super.key});

  
  State<CounterExample> createState() => _CounterExampleState();
}

class _CounterExampleState extends State<CounterExample> {
  late final Signal<int> counter = createSignal(context, 0);

  void _incrementCounter() {
    counter.value++;
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter Counter'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

CheckBoxの場合だと、同じように書くが、Widgetで値を参照するときは、flutter_hooksみたいに、.valueと書く必要があった。

import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const Counter(),
    );
  }
}

class Counter extends StatefulWidget {
  const Counter({super.key});

  
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {

  late final Signal<bool> isDone = createSignal(context, false); 

  void toggle() => isDone.value = !isDone.value;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Counter'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            // Checkbox
            Checkbox(
              value: isDone.value,
              onChanged: (value) => toggle(),
            ),
          ],
        ),
      ),
    );
  }
}

こちらのページに解説動画がある

https://dartsignals.dev/reference/overview/
preactのシグナルは、最初はセットを使って依存関係を追跡する実装だったが、後にリンクリストを使うように変更された。リンクリストの実装は、シグナルブースティングを利用することでよりパフォーマンスが高く、Signals.dartで使用されている実装です。

また、DartPadのプレイグラウンドには、実験に使えるコア・メソッドがいくつか用意されている!

もしあなたがJSの世界からやってきて、シグナルに慣れているのであれば、これはとても馴染みのあるものに感じられるはずだ。JSの世界でもDartの外でも使えるFlutterの状態管理ライブラリを探しているなら、これ以上探す必要はない!

JS書いてる人が、Dartでも同じ感覚で、state管理ができるようにした状態管理ライブラリなのだろう。

Core

https://dartsignals.dev/core/signal/
使ってみたしたの機能だけど、build()メソッドの中に書かないと使えない?
Stateクラスの中に書かないようだ?

Dartだけで使う機能なのかも?

Signal

シグナル関数は新しいシグナルを作成する。シグナルは、時間の経過とともに変化する値のコンテナです。シグナルの.valueプロパティにアクセスすることで、シグナルの値を読んだり、値の更新を購読したりすることができます。

import 'package:signals/signals.dart';

final counter = signal(0);

// Read value from signal, logs: 0
print(counter.value);

// Write to a signal
counter.value = 1;

シグナルはグローバルに作成することも、クラスや関数の内部で作成することもできる。どのようにアプリを構成するかはあなた次第です。

エフェクトやコンピューテッドがトリガーされるたびに新しいシグナルが生成されるので、エフェクトやコンピューテッドの内部でシグナルを作成することはお勧めしません。これは予期せぬ動作につながる可能性があります。

ウィジェットが再構築されるたびに新しいシグナルが生成されるからです。

Writing to a signal

シグナルへの書き込みは、.valueプロパティを設定することで行います。シグナルの値を変更すると、そのシグナルに依存するすべての計算とエフェクトが同期的に更新されます。

.peek()

まれに、前の値に基づいて別のシグナルに書き込むべきエフェクトがあるが、そのエフェクトをそのシグナルにサブスクライブさせたくない場合、signal.peek()を使ってシグナルの前の値を読むことができる。

final counter = signal(0);
final effectCount = signal(0);

effect(() {
  print(counter.value);

  // Whenever this effect is triggered, increase `effectCount`.
  // But we don't want this signal to react to `effectCount`
  effectCount.value = effectCount.peek() + 1;
});

本当に必要な場合のみ、signal.peek()を使用することに注意してください。シグナルの値を読むには、signal.valueを使うのがほとんどの場面で望ましい方法です。

.value

シグナルの.valueプロパティは、シグナルの読み書きに使用されます。エフェクトや計算の内部で使用すると、シグナルを購読し、シグナルの値が変化するたびにエフェクトや計算をトリガーします。

final counter = signal(0);

effect(() {
  print(counter.value);
});

counter.value = 1;

.previousValue

シグナルの.previousValueプロパティは、シグナルの前の値を読み取るために使用されます。エフェクトや計算の内部で使用された場合、シグナルはサブスクライブされず、シグナルの値が変化するたびにエフェクトや計算がトリガーされることはありません。

final counter = signal(0);

effect(() {
  print('Current value: ${counter.value}');
  print('Previous value: ${counter.previousValue}');
});

counter.value = 1;

Force Update

シグナルの更新を強制したい場合は、.set(..., force: true)メソッドを呼び出します。これにより、すべてのエフェクトがトリガーされ、すべてのコンピュートがダーティとしてマークされます。

final counter = signal(0);
counter.set(1, force: true);

Disposing

Auto Dispose

autoDisposeをtrueに設定してシグナルを作成すると、リスナーがいなくなったときに自動的にdisposeされます。

final s = signal(0, autoDispose: true);
s.onDispose(() => print('Signal destroyed'));
final dispose = s.subscribe((_) {});
dispose();
final value = s.value; // 0
// prints: Signal destroyed

オートディスポーズシグナルは、依存関係がオートディスポーズである必要はありません。ディスパッチされると、その値はフリーズし、依存関係の追跡を停止します。

final s = signal(0);
s.dispose();
final c = computed(() => s.value);
// c will not react to changes in s

シグナルが破棄されたかどうかは、.disposedメソッドを呼び出すことで確認できる。

final s = signal(0);
print(s.disposed); // false
s.dispose();
print(s.disposed); // true

On Dispose Callback

シグナルが破棄されたときに呼び出されるコールバックをシグナルにアタッチすることができます。

final s = signal(0);
s.onDispose(() => print('Signal destroyed'));
s.dispose();

Flutter

Flutterで、ウィジェットがウィジェットツリーから削除されたときに自動的に自身を破棄し、シグナルが変更されたときにウィジェットを再構築するシグナルを作成したい場合、ステートフルウィジェットの中でcreateSignalを使うことができます。

import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';

class CounterWidget extends StatefulWidget {
  
  _CounterWidgetState createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> with SignalsAutoDisposeMixin {
  late final counter = createSignal(context, 0);

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Counter: $counter'),
            ElevatedButton(
              onPressed: () => counter.value++,
              child: Text('Increment'),
            ),
          ],
        ),
      ),
    );
  }
}

ウォッチウィジェットやエクステンションは不要で、ウィジェットがウィジェットツリーから削除されると、シグナルは自動的にディスポされます。

SignalsAutoDisposeMixin は、ウィジェットがウィジェットツリーから削除されたときに、その状態で作成されたすべてのシグナルを自動的に破棄する mixin です。

Testing

シグナルのテストは、シグナルをストリームに変換し、Dartの他のストリームと同様にテストすることで可能である。

テストはサンプル通りなら成功するはず?

test/signal_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals.dart';

void main() {
  test('test as stream', () {
    final s = signal(0);
    final stream = s.toStream(); // create a stream of values

    s.value = 1;
    s.value = 2;
    s.value = 3;

    expect(stream, emitsInOrder([0, 1, 2, 3]));
  });
}

signalの内部実装はこんな感じです。

Signal<T> signal<T>(
  T value, {
  String? debugLabel,
  bool autoDispose = false,
}) {
  return Signal<T>(
    value,
    debugLabel: debugLabel,
    autoDispose: autoDispose,
  );
}

signal(0)の内部実装を見てみた。

/// overrideWith returns a new signal with the same global id sets the value as if it was created with it. This can be useful when using async signals or global signals used for dependency injection.
/// @link https://dartsignals.dev/core/signal
/// {@endtemplate}

/// overrideWith は、同じグローバル ID を持つ新しいシグナルを返します。これは、非同期シグナルや依存性注入に使われるグローバルシグナルを使うときに便利です。
/// @link https://dartsignals.dev/core/signal
/// {@endtemplate}

toStreamだとStream型のextensionがあった。

import 'dart:async';

import '../core/signals.dart';

final Map<int, Stream<dynamic>> _streamCache = {};

/// Signal extensions
extension ReadonlySignalUtils<T> on ReadonlySignal<T> {
  /// Convert a signal to a [Stream] to be consumed as
  /// a read only stream.
  Stream<T> toStream() {
    final existing = _streamCache[globalId];

    if (existing != null) {
      return existing as Stream<T>;
    }

    final controller = StreamController<T>();

    final stream = controller.stream.asBroadcastStream();

    _streamCache[globalId] = stream;

    subscribe(controller.add);

    onDispose(() {
      _streamCache.remove(globalId);
      controller.close();
    });

    return stream;
  }

emitsInOrder は、ストリームが正しい順序で値を出力するかどうかをチェックする matcher です。

また、テスト時にシグナルの初期値をオーバーライドすることもできます。これは、モッキングや特定の値の実装をテストするのに便利です。

import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals.dart';

void main() {
  test('test with override', () {
  final s = signal(0).overrideWith(-1);

  final stream = s.toStream();

  s.value = 1;
  s.value = 2;
  s.value = 3;

  expect(stream, emitsInOrder([-1, 1, 2, 3]));
});
}

overrideWithは、同じグローバルIDを持つ新しいシグナルを返し、そのシグナルで作成されたかのように値を設定します。これは、非同期シグナルや依存性注入に使用されるグローバルシグナルを使用する場合に便利です。

最後に

使ってみた感想ですが、flutter_hooksに似てますが、StatefulWidgetのStateクラス内でしか使えないようです。
他の状態管理ライブラリと組み合わせて問題なさそうなら、使ってみたいですね。setState()よりは、ジャンクが出ていないように思えた?

Jboy王国メディア

Discussion

JboyHashimotoJboyHashimoto

createSignal deprecated

createSignalが非推奨になってしまった😱

'createSignal' is deprecated and shouldn't be used. use SignalsMixin > createSignal instead.
Try replacing the use of the deprecated member with the replacement.

代わりにSignalsMixin > createSignalを使用してください。
非推奨のメンバの使用を置き換えてみてください。

修正したコードを参考に今後は試してみてください🙏
まさか破壊的変更が出るとは...

StatefulWidgetのStateクラスにwithでSignalsMixinを多重継承する。

main.dart
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SignalsMixin {
  final _counter = signal(0);

  void _incrementCounter() {
    setState(() {
      _counter.value++;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Watch((context) {
              return Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              );
            }
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}