Flor Signal 响应式系统
Flor 框架内置了一个轻量级的响应式信号系统。该信号系统受 Floem 启发,参考了其 API 设计,但由我们完全独立实现。与 Floem 不同的是,因为 Flor 的设计目标,Flor Signal 天生支持跨线程使用,信号可以在多线程环境下安全地读写和传递。
本文主要说明如何使用 Signal。完整函数签名和 trait 列表见 Signal API 参考。
创建信号
Signal 的使用入口基本都在 flor::signal 下。先记住这些 create_* API,就能完成大多数场景:
值信号
最常用的是 create_signal。它返回一个 RwSignal<T>,可以读也可以写。
如果读取和写入会交给不同的代码,使用 create_rw_signal 可以在创建时直接拿到一对读写句柄:
也可以先创建 RwSignal<T>,再在需要的地方拆分或派生:
常量信号
常量信号是很实用的能力:它允许 API 同时接收固定值和响应式值。比如控件属性既可以传 "Hello",也可以传一个读取 signal 的闭包。
ConstSignal<T> 不进入全局运行时,不建立订阅,destroy() 也是空操作。它只是把一个普通值包装成实现 Read<T> 的读取器。
多数时候不需要直接写 ConstSignal::new,使用 IntoRead 更自然:
IntoRead<T> 已经覆盖常用基础类型、String、&str、&String、ConstSignal<T>、ReadSignal<T> 和 RwSignal<T>。这让 API 可以把“固定值”和“响应式值”统一成读取器处理。
读取与写入
克隆读取
get() 和 try_get() 会克隆值,因此要求 T: Clone + 'static。
get() 在信号不存在或类型不匹配时 panic;try_get() 在信号不存在时返回 None。
引用读取
如果值很大,不想 clone,可以使用 get_ref() 或 try_get_ref()。它们返回 SignalRef<'_, T>,可以像 &T 一样使用。
SignalRef 持有底层读锁。守卫未释放前,不要在同一线程对同一个信号调用 set()、update() 等需要写锁的操作,否则可能死锁。
写入
set() 会替换整个值,update() 会在原地修改值。
如果信号可能已经被销毁,使用 try_set() 和 try_update():
响应式更新
create_effect
create_effect 创建基础副作用。闭包会立即执行一次,并接收上一次执行返回的值。
第一次执行时 previous 是 None。后续由信号更新触发时,previous 是上一次闭包返回的值。
create_updater
create_updater 更适合 UI 和派生状态绑定。它先执行 compute 得到初始值并返回;后续依赖变化时重新执行 compute,再把新值交给 on_change。
注意:on_change 不会在初始计算时调用,只会在依赖更新后调用。
create_updater_with_id
普通业务代码通常使用 create_updater 即可。create_updater_with_id 主要给框架内部使用,它返回 (Id, R),其中 Id 是 effect id,R 是初始值。
框架内部会用它把某些 UI effect 延迟到控件激活后再入队,例如 class、layout、transform 等 builder。之所以需要延迟,是因为在布局组装期间,控件的 ViewId 可能还没有挂载到窗口和父级控件上。等控件激活后再把 effect id 放入更新队列,可以让这些 UI 更新在完整的控件关系中执行。
列表信号
列表信号保存 Vec<T>,并为每个列表元素分配独立的行级 Id。结构变化订阅列表 Id;读取或修改某个元素时,会订阅或触发该元素自己的行级 Id。
创建和基础操作
读取列表
len() 返回 Option<usize>,信号销毁后返回 None;len_or_zero() 会把不存在的列表当作空列表处理。get(index) 在列表不存在或越界时 panic;try_get(index) 在列表不存在或越界时返回 None。
无克隆读取
需要避免 clone 时,可以使用 for_each_ref() 或 try_borrow()。
for_each_ref() 会订阅列表结构和遍历到的行级信号。try_borrow() 当前只订阅列表结构;如果 effect 需要在元素值被 set(index, ...) 或 update(index, ...) 后重新执行,优先使用 get()、to_vec()、for_each_ref() 或 contains()。
安全写入版本
所有列表写入方法都有对应的安全版本:
结构变化会触发列表 Id;元素值变化只触发该元素的行级 Id。因此,只读取 len() 的 effect 不会因为 set(index, ...) 重新执行;读取某个元素、to_vec() 或 for_each_ref() 的 effect 会对对应元素值变化敏感。
批量更新
batch 会在当前线程内开启批处理模式。闭包中的信号写入不会立即把每次变化都加入更新队列,而是收集变化过的信号 Id,闭包结束后统一入队并去重。
batch 的去重粒度是信号 Id。同一个信号在一次 batch 中被多次写入,只会在闭包结束后按这个信号入队一次;所以上面的 a.set(1) 和 a.set(2) 最终只会触发一次 a 的订阅更新,订阅者看到的是最后一次写入后的值。不同信号仍会分别入队,例如 a 和 b 会各自触发自己的订阅更新。
batch 是线程局部的,只影响当前线程。其他线程中的信号写入不会进入当前线程的批处理集合。如果闭包 panic,batch 会恢复批处理状态,然后继续向外传播 panic。
与 UI 控件集成
这章节属于“控件开发”的内容。如果不需要开发控件,快速了解即可。
Flor UI 控件通常通过 create_updater 绑定响应式值。
以 Label::new 的模式为例:
用户侧一般传入闭包读取信号:
工作流程:
create_updater先执行一次compute,得到控件初始值。compute中读取信号时,当前 effect id 记录在线程局部SCOPE中。Read::track()或ListRead::track()把当前 effect 订阅到被读取的信号。- 信号写入时,运行时把对应信号 id 加入更新队列。
- Flor 事件循环调用
RUNTIME.execute_update_queue(),运行订阅的 effect。 on_change调用ViewId::update_state或其他 UI 更新入口。
使用建议
优先通过闭包传递响应式值:
不要把 title.get() 的结果直接传给需要响应式更新的 API。那只会读取一次普通值。
用 ReadSignal<T>、WriteSignal<T> 表达业务意图:
避免在 effect 中写入自己依赖的信号:
这类代码会在后续更新中反复触发自己,容易形成无限更新。
持有 SignalRef 或 ListRef 时,先释放引用守卫,再写入同一个信号。
完整函数签名和 trait 列表见 Signal API 参考。
设计说明
生命周期
所有值信号和列表信号都是全局存活的。RwSignal<T>、ReadSignal<T>、WriteSignal<T>、RwListSignal<T>、ReadListSignal<T>、WriteListSignal<T> 都是 Copy 句柄,复制句柄不会复制底层值,也不会改变生命周期。
全局存活是 Flor 为了让信号句柄保持 Copy 可用而做的取舍:信号创建后会在全局运行时中存活,丢弃某个句柄不会释放底层数据。Flor 不只适合大型甚至超大型 GUI,也适合小工具类程序;在轻量程序中,Flor 的编译体积表现也是最核心的设计目标之一。对于小工具、简单窗口、界面结构稳定、窗口生命周期接近进程生命周期的程序,让信号随进程结束自然回收通常就是推荐写法之一,内存和性能影响很小。
destroy() 更适合用在生命周期明确会结束的动态资源上,比如可反复创建和销毁的窗口、页面、面板,或者重型、超重型 GUI 中数量很大的临时信号。使用 destroy() 后,再配合 try_get()、try_set()、try_update() 处理可能已经失效的句柄。
列表信号销毁时,会同时销毁它内部所有行级信号。
读写语义
Flor 的读写能力是通过 trait 提供的。ReadSignal<T> 只实现 Read<T>,所以在普通类型约束下不能调用 set() 或 update();WriteSignal<T> 只实现 Write<T>,所以不能调用 get()。
读写拆分表达的是业务语义,不是安全边界或权限隔离。这句话并不表示读取器拥有写入器的权限:ReadSignal<T> 本身没有写入方法,按 Read<T> 约束传递时就只能读取;WriteSignal<T> 本身没有读取方法,按 Write<T> 约束传递时就只能写入。读就是读,写就是写,在业务代码和源码搜索里都能保持明确。
因此,推荐放心地用对应的信号类型表达业务访问边界:只需要读的地方传 ReadSignal<T>,只需要写的地方传 WriteSignal<T>,需要两者时再传 RwSignal<T>。这样的拆分能让 API、调用点和代码阅读都更清晰。
同时,Flor 不会让这种语义拆分拖住使用。确实需要写入时,代码里会出现 as_write()、split() 或写入器参数,读者能清楚看到这里拿到了新的访问能力;普通路径下,读取器仍然不会有写入权限。
调试标签
可以为读写信号设置标签,方便调试和追踪。
标签只在 debug_assertions 或启用 signal-tracing feature 时写入运行时。当前这部分主要提供运行时侧的标签记录,完整的可视化调试体验还需要等待 devtools 相关能力完善;因此它更适合作为后续追踪和调试工具的基础能力,而不是已经完成的用户侧调试面板。
深入:运行时机制
这部分用于理解 Signal 的内部行为;普通使用不需要先掌握这些细节。
RUNTIME 保存以下核心状态:
依赖追踪依靠线程局部的 SCOPE:
- effect 执行前,把当前 effect id 写入
SCOPE。 get()、get_ref()、ListRead读取方法会调用track()。track()读取SCOPE,把当前 effect 订阅到对应信号 id。- 写入信号时,运行时把被写入的信号 id 加入更新队列。
- 事件循环执行更新队列,找到订阅该信号的 effect 并运行。
batch 使用线程局部的 BATCH 收集信号 id。批处理结束后,这些 id 被统一加入全局更新队列。
当前实现不会在每次 effect 重新执行前主动清空旧依赖。对于依赖集合会频繁变化的动态分支,应尽量让读取范围稳定,避免长期积累不再需要的订阅。

