当我们熟练运用 Signals 的 signal 、computed 、effect 等核心API后,难免会在大型项目中遇到那些隐藏在深处、难以捉摸的"坑"。本篇文章专为解决这些边界情况、性能瓶颈和高级用法而来,希望可以帮助大家解锁 Signals 的底层细节,让我们在复杂场景下写出更稳健、更清晰的代码。
一、首要原则:不要在 build / computed / effect 中创建信号
这是使用 Signals 时最重要也最容易违反的规则,是规避内存泄漏的第一道防线。
为什么这样做很危险?
- 内存泄漏 :每当 Widget 重建(因父级更新、setState、屏幕旋转等),如果在 build 方法内创建 signal、computed 或 effect,都会生成一个全新的实例。旧的实例会失去引用,但其内部的订阅关系可能依然存在,最终引发内存泄漏和逻辑错乱。
- 逻辑混乱:在 effect 或 computed 的回调中创建信号,每次回调触发都会重复创建,导致状态不一致或意外的副作用。
正确做法:将信号声明为 State 的属性
scala
class MyWidgetState extends State<MyWidget> with SignalsMixin {
// ✅ 在 State 初始化阶段创建,生命周期与 State 绑定
late final counter = signal(0);
late final isEven = computed(() => counter.value % 2 == 0);
@override
Widget build(BuildContext context) {
return Column(
children: [
// 使用 .watch(context) 订阅信号
Text('Count: ${counter.watch(context)}'),
Text('Is even: ${isEven.watch(context)}'),
ElevatedButton(onPressed: () => counter.value++, child: const Text('Inc')),
],
);
}
}
错误示例:
scss
@override
Widget build(BuildContext context) {
// ❌ 每次 build 都会创建一个新的 signal 实例,引发内存泄漏
final temp = signal(0);
return Text('${temp.watch(context)}');
}
二、生命周期与销毁机制:dispose、autoDispose 与 SignalsMixin
Signals 提供了强大的资源清理能力,以防止信号、订阅和依赖关系残留。
1. SignalsMixin:Flutter 中的最佳实践
在 StatefulWidget 的 State 中混入 SignalsMixin,是管理信号生命周期的最推荐方式。
-
自动清理 :SignalsMixin 会自动追踪在 State 实例内部创建的所有 signal、computed 和 effect,并在 State 的 dispose 方法被调用时,统一将它们全部销毁。
-
注意事项:
- 它只负责清理在当前 State 作用域内 创建的信号。如果信号来自全局单例、Service 层或外部注入,SignalsMixin 不会也无法销毁它们。
- onDispose 注册的回调会在 SignalsMixin 自动销毁时被触发。
2. 手动销毁:dispose()
- 每个信号都提供 .dispose() 方法用于手动销毁。销毁后,信号将被"冻结",不再响应写入操作,也不会触发任何下游更新。
- 可以通过只读属性 .disposed 来检查信号是否已被销毁。
- 可以使用 onDispose 注册一个或多个销毁时的回调函数。
scss
final s = signal(0);
s.onDispose(() {
print('Signal 已被销毁');
});
s.dispose(); // 将触发上面注册的回调
3. 自动销毁:autoDispose
- 在创建信号时设置 autoDispose: true ,该信号会在没有任何监听者(订阅者)时自动销毁。
- 这非常适合用于生命周期较短 或临时性的状态,例如与特定UI组件绑定的局部状态。当组件被移除或不再需要监听该信号时,资源可以被自动回收。
- 注意 :一个 autoDispose 的信号被销毁,并不会连带销毁它所依赖的上游信号(除非那些信号也是 autoDispose 且同样满足了无监听者的条件)。
三、策略性读取:peek 与 untracked
在某些 effect 或 computed 中,我们只想读取一个信号的当前值,而不希望建立订阅关系,以避免不必要的重计算或循环依赖。
1. peek():读取但不订阅
- signal.peek() 直接返回信号的当前值,但不会将当前执行上下文(如 effect)注册为此信号的订阅者。
- 适用场景:在 effect 中需要更新另一个信号时,为避免 effect 反过来订阅这个被更新的信号,从而造成循环触发。
ini
final counter = signal(0);
final timesTriggered = signal(0);
effect(() {
print('Counter is: ${counter.value}');
// 使用 peek() 读取 timesTriggered 的当前值,用于计算新值
// 这样,此 effect 就不会订阅 timesTriggered 的变化
timesTriggered.value = timesTriggered.peek() + 1;
});
2. untracked(fn):在回调中屏蔽所有依赖追踪
untracked(() => ...)
会执行一个函数,并确保该函数内部所有的信号读取都不会被当前上下文追踪。- 适用场景 :当一个 effect 或 computed 的逻辑依赖于某个主要信号,但其中又需要引用其他辅助信号(而又不希望这些辅助信号的变化触发重计算)时。
ini
final a = signal(1);
final b = signal(2);
final c = computed(() {
// ✅ 这个 computed 仅订阅 a 的变化
print('a changed: ${a.value}');
// ✅ 在 untracked 块中读取 b,所以 b 的变化不会触发 c 的重计算
final bValue = untracked(() => b.value);
return a.value * bValue;
});
四、强制更新:force: true
默认情况下,如果赋给信号的值与当前值相同(通过 ==
判断),信号不会通知其订阅者。但在某些特殊场景下,我们希望即使值不变也强制触发更新。
- 用法 :
signal.set(newValue, force: true)
。 - 典型场景 :当信号持有的值是一个可变对象(如 List 或自定义类)时。如果你只修改了对象内部的属性而没有替换整个对象实例,信号本身的值(对象的引用)并未改变,此时就需要强制更新来通知UI刷新。
ini
final user = signal(User(name: 'Alice'));
effect(() => print('User name: ${user.value.name}'));
// ❌ 错误做法:直接修改内部属性,信号无法感知
// user.value.name = 'Bob'; // effect 不会触发
// ✅ 正确做法:修改后强制刷新
user.value.name = 'Bob';
user.set(user.value, force: true); // 或者 user.set(user.value, force: true);
五、性能利器:batch 原子化更新
batch 是 Signals 中一项至关重要的性能优化工具,它能将多个写操作合并为一次通知,避免中间状态导致的重复计算和UI刷新。
- 基本行为 :
batch(() { ... })
会将其闭包内的所有信号写操作缓存起来,直到闭包执行完毕后,才一次性 通知所有受影响的下游 computed 和 effect。 - 即时读取新值 :在 batch 内部,读取一个刚刚被修改的信号,会立即得到新值。依赖该信号的 computed 也会在被读取时同步计算出新结果。但相关的 effect 和UI更新会被延迟到 batch 结束。
- 嵌套支持 :batch 支持嵌套调用,只有最外层的 batch 执行完毕时,才会触发最终的通知。
ini
final a = signal(0);
final b = signal(0);
final c = computed(() => a.value + b.value);
effect(() => print('C updated: ${c.value}'));
batch(() {
a.value = 5;
b.value = 5;
print(c.value); // ✅ 会立即输出 10
}); // effect 在这里被触发一次,输出 "C updated: 10"
六、核心机制:懒计算与动态依赖链
理解 Signals 的懒计算和动态依赖管理,有助于编写出更高性能的代码。
- 懒计算 (Lazy Evaluation) :一个 computed 的计算函数只有在它首次被读取或订阅 时才会执行。如果一个 computed 从未被任何 effect 或UI监听,那么它的计算逻辑可能永远不会运行,从而节省了计算资源。
- 动态依赖链 :当一个信号或 computed 不再有任何订阅者时,它会自动断开与上游信号的依赖连接。当它被重新订阅时,依赖链会再次建立。这个机制在UI中尤其有用,比如一个组件被条件性地隐藏(
if (condition)
),其内部使用的信号订阅会自动清理;当它再次显示时,订阅会重新激活。
七、调试利器:可视化依赖图与日志追踪
随着应用复杂度的增加,手动追踪信号的依赖关系和更新路径变得异常困难。
-
DevTools 扩展 :signals 包提供了官方的 DevTools 扩展。它能以**图(Graph View)和列表(List View)**的形式,可视化展示您应用中所有 signal、computed 和 effect 之间的依赖关系。这对于定位性能瓶颈(如不必要的订阅)和理解数据流非常有帮助。
-
全局日志:在开发过程中,可以在应用启动时开启全局调试日志,它会打印出信号的创建、订阅、更新和销毁等详细信息。
csharpvoid main() { // 开启全局日志 globalSignalDebugMode = true; runApp(MyApp()); }
-
手动订阅调试 :可以临时使用
signal.subscribe(...)
方法来监听特定信号的变化,并打印日志。记得在调试结束后移除它。
perl
final sub = mySignal.subscribe((value) {
print('[DEBUG] mySignal changed to: $value');
});
// ... 在不再需要时取消订阅
sub.dispose();
八、总结
- 严禁 在 build 、computed 或 effect 的回调中创建任何信号。
- 在 StatefulWidget 中,优先使用 SignalsMixin 来自动管理信号的生命周期。
- 对于临时的、与局部UI绑定的状态,考虑使用 autoDispose: true 实现自动回收。
- 当多个信号需要连续更新时,务必 将它们包裹在 batch 中,以合并通知,优化性能。
- 在 effect 或 computed 中,如果只想读取值而不创建依赖,请使用 peek() 或 untracked() 。
- 当信号持有可变对象(如
List
)并修改其内部状态时,使用.set(..., force: true)
强制刷新。 - 善用 DevTools 扩展来审查信号依赖图,定位和解决不必要的重计算问题。
- 在大型项目中,建立统一的信号命名规范和生命周期管理策略。