对 signals.dart 细粒度更新的误解

487 阅读4分钟

我们使用一个第三方库,不应只看有没有名气,star 多不多,功能强不强大,库的实现原理是否高大上,更应该看它

  • 解决了什么问题

  • 是否符合我们的项目需求

  • 有没有带来新问题

先说结论,signal 在 flutter ui 更新意义不大。

Signal 简介

基于 preactjs.com/blog/signal… 实现了 dart 版。

主打最大的优势是 高性能的颗粒度更新。

Preact Signals

preactjs.com/guide/v10/s… 

Preact Signals 是 Preact 团队(React 的轻量替代框架)推出的一种 响应式状态管理机制,核心思想是用极小代价实现“自动追踪依赖 + 精准更新”。

数据处理上的细粒度更新

郭神之前有篇文章已经介绍了 signal 的原理:guoshuyu.cn/home/wx/Flu…

flutter ui 上的细粒度更新

flutter 上的细粒度体现在将 element 和 signal 直接关联. 无需经过 Widget 层的 build,直接通知关联的 element 更新。

看这个例子,Signal 通过 Watch 包裹 Widget 来达到最细粒度的更新。


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

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

  @override
  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:',
            ),
            Watch((context) {
              return Text(
                '$counter',
                style: Theme.of(context).textTheme.headlineMedium,
              );
            }),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

这个例子里:

counter 会直接关联 Text 的 element。counter 更新时,他直接标记 Text 的 Element 为脏,触发下一帧更新。这样直接绕过 Widget build 直达 Element,听着很高效是不是。但是其实它还是会触发 build,和 setState 达到的效果是一模一样的, setState 本质上也是标脏 ,所以, WatchValueListenerBuilder 对于 ui 的细腻度更新是 没有任何区别的

唯一的区别,只是写法不同, Watch 不需要传入任何 value:

// 不需要任何输入,Watch 会自动关联 counter
Watch((context) {
              return Text(
                '$counter',
                style: Theme.of(context).textTheme.headlineMedium,
              );
})

// 但 ValueListenableBuilder 需要先传入 counterNotifier 手动关联
ValueListenableBuilder(value:counterNotifier,builder:)

实际的开发场景,状态管理往往是页面为单位的,因为数据会被页面里的很多 Widget 用到,以及一些乱七八糟的逻辑判断。 比如:Signal 持有的会是一个聚合的状态 PageState.

// 聚合的状态对象
class PageState {
  final String title;
  final bool isLoading;
  final int counter;
  // ... 其他几十个属性
  
  // 构造函数和 copyWith 方法...
  PageState({this.title = '', this.isLoading = false, this.counter = 0});
  PageState copyWith({String? title, bool? isLoading, int? counter}) {
    return PageState(
      title: title ?? this.title,
      isLoading: isLoading ?? this.isLoading,
      counter: counter ?? this.counter,
    );
  }
}

// 单一数据源
final pageState = signal(PageState(title: 'My App'));

// 从单一数据源派生出细粒度的 computed signals
final title = computed(() => pageState.value.title);
final isLoading = computed(() => pageState.value.isLoading);
final counter = computed(() => pageState.value.counter);

如果要实现精准的细粒度就得这么写, 每个控件用: Watch((context) 嵌套一下。

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // 这个 AppBar 的标题只关心 title 的变化
        title: Watch((context) => Text(title.value)),
      ),
      body: Center(
        child: Column(
          children: [
            // 这个加载指示器只关心 isLoading 的变化
            Watch((context) {
              return isLoading.value ? CircularProgressIndicator() : SizedBox();
            }),
            // 这个 Text 只关心 counter 的变化
            Watch((context) => Text('Count: ${counter.value}')),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 假设我们只更新 counter
          pageState.value = pageState.value.copyWith(counter: pageState.value.counter + 1);
        },
      ),
    );
  }
}

那么我想问,

  • 把每个 Watch((context) 替换成 ValueNotifierBuilder 吗?

  • final title = computed(() => pageState.value.title); 换成 ValueNotifier

有什么不一样吗? 这也是响应式,也是精准细粒度更新,只不过每个 ValueNotifierBuilder 多调一次 build 而已。

有人说, 我可以每个属性都写成 signal, so? 无非就是省去了 几行 computedWatch 还是少不了,我同样可以每个属性都写成 Notifier。

从数据上看,我不认可官方的 benchmark 数据,有点射完箭再画靶子的意思。 性能敏感的场景我们肯定没那么呆直接嵌套,而是想上述一样用 ValueNotifierBuilder 来实现局部的频繁更新,基本能解决90%的问题。而 Widget 本身的 build 机制已经是足够高效,为了省这么点性能,引入这样的一个有点 违背 Widget 机制的库,并且要达到完美的细粒度更新,必须嵌套写 Watch((context)

违背 Widget 哲学

Watch 的背后做了很多操作: 关联 element 和 signal,建立二者的联系,以及触发其惰性版本依赖。

违背了: 保持 build 的纯净 的单一职责。

signals.dart 解决的痛点

所以 signals.dart 最大的价值就是其惰性版本依赖机制,处理数据的高效。


final a = createSignal(1);
final b = createSignal(2);
final sum = createComputed(() => a.value + b.value);
final doubled = createComputed(() => sum.value * 2);

首先是,写法麻烦,只能被迫用 signal 包装,每个属性都写一个 signal?或者每个地方都写成 createComputed, 你不嫌麻烦吗?如果你偷懒不分这么细的粒度,那你为什么要用它? 然后是 侵入性超强 ,后期想换,每个属性都要去改?

极端场景下,确实能有不错的优化效果,但我非要用这个库来解决吗?我自己写一个策略来控制它不行吗。

当你修改 a.value = 2 时,影响了太多人。你想修改 b 但又不太敢修改,因为会影响上下游的链路,要改的话,整个链路模块最好一起回归测试, 叠甲,我是说最好,他当前可以单独测试, 如果你能拍着胸脯说100%不出问题就行。

转存失败,建议直接上传图片文件

虽然 signal 提供了可视化的调试工具,但只是减缓了上述问题,一堆 signal 依赖耦合在一起,眼花缭乱。

总结

所以 signal 主打的细粒度更新,真的戳到你的痛点了吗?