Signals.dart使用介绍

本文旨在为 Dart 和 Flutter 开发者提供 Signals.dart 框架的全面介绍。Signals.dart 借鉴了现代前端响应式系统(如 SolidJS 或 Vue 3)的核心理念,提供了一种基于 Pull-Based(拉取式) 架构的、高效且极简的状态管理方案。

文档重点阐述了框架的核心 API、Flutter 集成能力以及两种主流的应用架构模式(轻量级局部状态和分层 MVC 模式),旨在帮助开发者快速掌握 Signals 的基础使用。

声明:本文部分内容由 AI 参考官方文档整理,但所有核心要点用法均经过人工核对

官方文档要点(Signals.dart Overview

核心理念

  • Pull-Based 架构:Signals 采用"拉取"模式,只有在读取值时才会触发计算(区别于传统的 push-based notifyListeners
  • 极简更新(Minimal Updates)
    • 如果从不读取某个 computed,它的回调永远不会被调用
    • 链式 computed 会缓存值,只在依赖变化且被读取时才重新计算
    • 使用链表实现依赖追踪(signal boosting),性能更优

示例:懒计算

final a = signal(0);
final b = computed(() => a.value + 1);
final c = computed(() => b.value + 1);
final d = computed(() => c.value + 1);

// 如果从不读取 d.value,所有回调都不会执行
print(d.value); // 3 - 此时才会计算整个链路
print(d.value); // 3 - 直接返回缓存值,无需重新计算

Core 核心 API

API 作用 用法
signal(value) 创建响应式值 final count = signal(0);
computed(() => ...) 创建派生值,自动追踪依赖 final double = computed(() => count.value * 2);
effect(() { ... }) 注册副作用,依赖变化时执行 effect(() => print(count.value));
untracked(() => ...) 在回调中读取信号但不建立依赖 untracked(() => count.value);
batch(() { ... }) 批量更新多个信号,只触发一次通知 batch(() { a.value = 1; b.value = 2; });

Flutter 集成(重要更新)

Widget/工具 功能
Watch((context) => ...) 在 widget 中自动追踪依赖并重建(类似 Riverpod 的 Consumer)
SignalProvider 提供依赖注入能力,在 widget 树中共享信号(单个 signal)
FlutterSignal Flutter 专用信号,集成 Flutter 生命周期
FlutterComputed Flutter 专用计算信号,自动 dispose
ValueListenable 将 Signal 转换为 ValueListenable(兼容旧 API)
ValueNotifier 将 ValueNotifier 转换为 Signal
Hooks 支持 Flutter Hooks(useSignaluseComputed 等)

核心差异

  • createSignal / createComputed → 自动绑定 widget 生命周期,无需手动 dispose
  • signal / computed → 全局或手动管理,需要手动 dispose(如果没有直接引用会在 GC 的时候被回收,此时不会调用 onDispose)

Mixins 扩展能力

提供了丰富的 Mixin 让自定义类支持响应式:

  • SignalsMixin - 基础信号混入
  • ValueNotifierSignalMixin 🆕 - 让 Signal 变成 ValueNotifier
  • ValueListenableSignalMixin 🆕 - 让 Signal 变成 ValueListenable
  • IterableSignalMixin / ListSignalMixin / SetSignalMixin 🆕 - 集合类型响应式
  • StreamSignalMixin 🆕 - Stream 集成

Async 异步支持

类型 用途
AsyncState<T> 异步状态封装(loading / data / error)
FutureSignal 将 Future 转为响应式状态
StreamSignal 将 Stream 转为响应式状态
Computed Async 异步计算信号
connect() 连接异步数据源到信号

Value 类型信号

提供常用数据结构的响应式版本:

  • ListSignal<T> - 响应式列表(支持 addremove 等操作)
  • MapSignal<K, V> - 响应式 Map
  • SetSignal<T> - 响应式 Set
  • IterableSignal<T> - 响应式迭代器
  • ChangeStack<T> - 支持撤销/重做的信号

对于 Signals 有两种用法,一种对于简单的 Widget,直接使用在 State 中createXXX 创建并使用: 这样的好处是框架自动处理了生命周期,数据和 widget 在同一处方便处理,但是对于复杂项目可能难以维护优化,不方便共享数据。 还有一种可以使用类似 MVC 等模式,将数据,操作使用单独文件处理,然后再在 widget 中使用, 这里只介绍 MVC 模式,其余参考官方文档,比如clean_architecture

方式一:在 Widget 中直接使用 createSignal(官方推荐的轻量写法)

这种方式适合局部状态(如登录表单),信号直接定义在 widget 内部,生命周期与 widget 绑定。

在 widget 内用 createSignal/createComputed 管理局部状态,UI 用 Watch 或 SignalsMixin 绑定,副作用用 createEffect,这样信号会自动追踪依赖并在 widget 销毁时自动回收。

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

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

  @override
  State<LoginPageSimple> createState() => _LoginPageSimpleState();
}

class _LoginPageSimpleState extends State<LoginPageSimple> with SignalsMixin {
  // 在 widget 中直接创建局部信号:随 widget 销毁自动回收,不需要手动 dispose
  late final username = createSignal('');
  late final password = createSignal('');
  // 基于其他信号计算派生值,自动追踪依赖
  late final canLogin = createComputed(
    () => username.value.isNotEmpty && password.value.isNotEmpty,
  );

  @override
  void initState() {
    super.initState();

    // 在 initState 中使用 createEffect 监听信号变化(副作用)
    // 会自动注销,不需要主动调用 dispose()。
    // 不要在 build 等方法中调用,也不要在此方法中创建新的 signal
    createEffect(() {
      debugPrint('✨ Effect: username changed to "${username.value}"');
    });

    createEffect(() {
      debugPrint('✨ Effect: canLogin changed to ${canLogin.value}');
      if (canLogin.value) {
        debugPrint('🎉 用户现在可以登录了!');
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    // 注意:因为用了 SignalsMixin,这里直接用 .value 就会被追踪
    return Scaffold(
      appBar: AppBar(title: const Text("登录 - SignalsMixin 用法")),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              decoration: const InputDecoration(labelText: '用户名'),
              onChanged: (v) => username.value = v,
            ),
            const SizedBox(height: 8),
            TextField(
              decoration: const InputDecoration(labelText: '密码'),
              onChanged: (v) => password.value = v,
            ),
            const SizedBox(height: 20),

            // ✅ 直接使用 .value,不需要 Watch
            ElevatedButton(
              onPressed: canLogin.value ? _login : null,
              child: const Text("登录按钮(SignalsMixin)"),
            ),
            const SizedBox(height: 12),
          ],
        ),
      ),
    );
  }

  void _login() {
    debugPrint("🔐 登录中: ${username.value}/${password.value}");
  }
}

方式二:View + Controller + State 分层架构

这种方式更接近传统 MVC/MVVM,把业务逻辑抽离到 Controller,UI 只负责展示。适合复杂业务或跨页面共享状态。

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

/// 状态模型
class LoginState {
  final String username;
  final String password;
  final bool isLoading;
  final String? error;

  const LoginState({
    this.username = '',
    this.password = '',
    this.isLoading = false,
    this.error,
  });

  LoginState copyWith({
    String? username,
    String? password,
    bool? isLoading,
    String? error,
  }) {
    return LoginState(
      username: username ?? this.username,
      password: password ?? this.password,
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
    );
  }
}

/// 控制器
class LoginController {
  // 这里创建的 signal 和 computed 在没有被强引用的情况下,会随着 LoginController 的回收
  // 而被 GC 回收(被 GC 回收的时候不会调用 onDispose() 方法)
  // 所以也不需要主动调用 dispose() 方法回收,但是如果是 effect 的话则要主动回收
  final state = signal(const LoginState());
  late final canLogin = computed(
    () => state.value.username.isNotEmpty && state.value.password.isNotEmpty,
  );
  EffectCleanup? _effectCleanup;

  LoginController() {
    _effectCleanup = effect(() {
      debugPrint('✨ effect: canLogin changed to ${canLogin.value}');
    });
  }

  void updateUsername(String v) =>
      state.value = state.value.copyWith(username: v);

  void updatePassword(String v) =>
      state.value = state.value.copyWith(password: v);

  Future<void> login() async {
    state.value = state.value.copyWith(isLoading: true, error: null);
    await Future.delayed(const Duration(seconds: 1));
    if (state.value.username == "admin" && state.value.password == "123456") {
      debugPrint("✅ 登录成功");
    } else {
      state.value = state.value.copyWith(error: "用户名或密码错误");
    }
    state.value = state.value.copyWith(isLoading: false);
  }

  void dispose() {
    // effect 必须主动调用回收,否则会一直持有监听
    _effectCleanup?.call();
    // signal 和 computed 本质是普通对象,如果没有强引用,会随 GC 自动回收。
    // GC 回收时不会触发 onDispose(),只有显式调用 dispose() 才会触发。
    // 因此:
    // - 纯 signal/computed:不需要手动 dispose。
    // - 有 effect 或全局持有:必须手动清理。
    // - 为了架构安全,通常仍建议在 Controller 提供 dispose() 方法。
    state.dispose();
    canLogin.dispose();
  }
}

/// 视图
class LoginPageMVC extends StatefulWidget {
  const LoginPageMVC({super.key});

  @override
  State<LoginPageMVC> createState() => _LoginPageMVCState();
}

class _LoginPageMVCState extends State<LoginPageMVC> {
  late final LoginController controller;

  @override
  void initState() {
    super.initState();
    controller = LoginController();
  }

  @override
  void dispose() {
    controller.dispose(); // 可选,清理信号
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final c = controller;
    return Scaffold(
      appBar: AppBar(title: const Text("登录 - MVC版")),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(onChanged: c.updateUsername, decoration: const InputDecoration(labelText: "用户名")),
            TextField(onChanged: c.updatePassword, decoration: const InputDecoration(labelText: "密码")),
            const SizedBox(height: 20),

            // ========== 方式1: Watch ==========
            const Text("方式1: Watch", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
            Watch((context) {
              return ElevatedButton(
                onPressed: c.canLogin.value ? c.login : null,
                child: c.state.value.isLoading
                    ? const CircularProgressIndicator()
                    : const Text("登录"),
              );
            }),
            const Divider(),

            // ========== 方式2: Watch.builder ==========
            const Text("方式2: Watch.builder", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green)),
            Watch.builder(builder: (context) {
              return Text("canLogin: ${c.canLogin.value}");
            }),
            const Divider(),

            // ========== 方式3: WatchBuilder ==========
            const Text("方式3: WatchBuilder", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
            WatchBuilder(
              builder: (context, child) {
                return Column(
                  children: [
                    Text("username: ${c.state.value.username}"),
                    Text("password: ${c.state.value.password}"),
                  ],
                );
              },
            ),
            const Divider(),

            // ========== 方式4: .watch(context) ==========
            // 只会触发直接使用他的这个 Text 组件的重建
            const Text("方式4: .watch(context)", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple)),
            Text("username: ${c.state.select((s) => s.username).watch(context)}"),
            Text("canLogin: ${c.canLogin.watch(context)}"),
            const Divider(),

            // ========== 方式5: .value(需配合 Watch) ==========
            const Text("方式5: .value(需配合 Watch)", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
            Watch(() {
              return Text("直接读取 .value: ${c.state.value.username}");
            }),
            // ❌ 错误示范:直接在 build 中用 .value 不会更新
            Text("❌ 错误: 直接用 .value(不会更新): ${c.state.value.username}"),
          ],
        ),
      ),
    );
  }
}

如上代码所述,signal 和 computed 本质上是普通 Dart 对象,没有外部强引用时,会被 Dart GC 自动回收。 → 所以如果 LoginController 本身被释放,且没有其他地方持有 state 或 canLogin 的引用,它们也会被 GC 清理。

GC 回收不会触发 onDispose(): onDispose 是 Signals 库提供的手动清理钩子,不是 Dart VM 的生命周期事件。

effect 必须手动清理: 官方文档明确提到,effect 会注册一个订阅/副作用,它会保持对依赖信号的引用。 如果不调用返回的 EffectCleanup,引用链就存在,对象不会被 GC 回收。

这里注意 signal 在 Widget 的几种使用方法:

方式 语法 特点 适用场景
1. Watch() Watch(() { ... }) 最简洁,自动追踪依赖,内部访问的信号变化时会重建 包裹一小段 UI,响应式更新
2. Watch.builder Watch.builder(builder: (context) { ... }) 提供 context 参数,方便使用 Theme.of(context)MediaQuery 需要上下文的响应式 UI
3. WatchBuilder WatchBuilder(builder: (context, child) { ... }) 提供 contextchild,可传递静态子组件避免重建 性能优化场景,大组件中局部响应式
4. .watch(context) signal.watch(context) 在 build 中直接调用,自动注册依赖并触发当前 widget 重建(直接使用他的这个 widget) 简单的文本展示、内联使用
5. .value(配合 Watch) signal.value Watch 内使用会追踪依赖;在普通 build 中直接用只是快照,不会更新 需要在 Watch 内部读取多个信号时
6. SignalsMixin signal.value(直接在 build 中) 混入 SignalsMixin 后,build 中访问的 .value 会自动追踪依赖 想让整个 build 方法天然响应式,无需显式 Watch

特点:

  • 状态与逻辑集中在 LoginController,UI 更干净。
  • 适合复杂业务、跨页面共享、可测试性更强。
  • (可选)需要手动 dispose() 清理信号。

两种方式对比

维度 Widget 内部 createXXX View + Controller + State
生命周期 自动随 widget 销毁 需要手动 dispose
代码复杂度 简单,几行搞定 更复杂,分层清晰
适用场景 局部状态、轻量交互 跨页面、复杂业务、团队协作
可测试性 较弱 强,可单测 Controller

总结:

  • 局部状态 → 用 createSignal/createComputed 直接在 widget 内定义,简洁高效。
  • 复杂业务/跨页面状态 → 用 Controller + State 分层,手动管理生命周期,保证可维护性。

其余关于 Signals.dart 的一些使用、分析等等可以查看这个 gist: dart_signals_example.dart

有想法?欢迎通过邮件讨论。

目录