FUI 绑定实践:拆解 OneWay、TwoWay、Command 与初值陷阱

12 阅读13分钟

系列第 08 篇,面向准备设计 Unity UI 框架的开发者。用三个最小场景拆解绑定:血量 HUD 验证 OneWay,音量滑块验证 TwoWay,确认按钮验证 Command。

前提是理解 C# 事件与 FUI 的声明式绑定。目标不是复制一套未验证的 API,而是沿真实源码找出通知、初值、取消与释放的边界;文末提供可放入引用 FUI 的测试程序集的 NUnit 用例。

先决定谁有权修改这个值

血量文本、音量滑块和确认按钮,表面上都是控件,职责却不同。血量来自战斗状态,文本没有权力反向修改生命值;音量是一份可编辑配置,需要接收初值与用户输入;确认按钮表达一次操作,不是在两个对象之间同步一个 bool。

绑定方向不是省几行配置的问题,它决定了 View 获得多少写权限。默认 OneWay,需要编辑时才开放 TwoWay,操作意图用 Command;业务层不直接获得 GameObject 或内部 Element,让新手沿默认路径也不容易把展示与业务揉在一起。这里的限制不是安全沙箱,而是团队架构上的最小权限。

FUI 的 BindAttribute 默认参数是 BindingMode.OneWay。下面会区分当前源码、教学化简与可继续改进的设计;特别是初值与归一化,不能用理想架构替代真实行为。

为什么直接订阅可能变成反馈环

下面是错误接线的教学伪代码,PropertyChanged 用于表示业务通知,不是完整的 FUI API:

slider.onValueChanged.AddListener(v => vm.Volume = v);
vm.PropertyChanged += name => slider.value = vm.Volume;

用户拖动后,事件写入 ViewModel,属性通知又写回 Slider。如果控件或模型没有相等值短路,就会继续触发;即使能终止,也可能在不同格式之间反复转换。

用户输入 → Slider 事件 → VM.Volume → 属性变化
        → 程序写 Slider → 再次触发 Slider 事件

只给 VM 加一个 if 相等就返回,并不意味着模型从此正确。比如控件保留两位小数、模型保留三位,两个方向可能得到不同数值。去抖是频率策略,不是所有权或收敛性证明。

FUI 的 SliderElement 在 OnInitialize 中用 SetValueWithoutNotify 适配 UGUI 的程序赋值。下面保留源码核心形状,省略所在类型:

Value = CreateProperty(Component.value,
    (oldValue, newValue) => Component.SetValueWithoutNotify(newValue));

void OnSliderValueChanged(float value)
{
    this.Value.Value = value;
    OnValueChanged.Invoke(value);
}

这里要分清两种通知。SliderElement.Value 是 BindableProperty<float>;它的 OnValueChanged 是属性变化通知。SliderElement.OnValueChanged 则是单独的 Command<float>,由底层控件事件触发。当前生成的 TwoWay 订阅前者,不是只订阅后者。

因此,程序同步不会经由这条 SetValueWithoutNotify 路径重新触发 UGUI 用户事件,但仍可能触发 FUI 属性层的反向处理。BindableProperty.SetValue 的相等值比较与 ViewModel 的通知语义共同参与终止反馈。不能把“控件静默”写成“整个绑定系统绝不反向调用”。

三种属性方向,加上一种操作意图

模式方向适用例子风险
OneWayVM → View标题、状态、进度派生属性漏通知
OneWayToSourceView → VM只采集输入的编辑控件初值和回显需另行处理
TwoWayVM ↔ ViewSlider、Toggle、输入框回环、校验、焦点冲突
Command事件 → 操作确认、购买、刷新重复执行、取消、异常

框架默认使用 TwoWay 看似省配置,实际会让只读属性意外拥有写入口。安全默认值应是 OneWay。

绑定描述需要哪些信息

下面是描述职责的教学模型,不是 FUI 当前 BindingDescriptor 类型:

public sealed record BindingDescriptor(
    string ElementName,
    string ElementMember,
    string ViewModelMember,
    BindingMode Mode,
    Type ConverterType);

生成器通过语义模型把它转成强类型代码。运行时不应再使用 PropertyInfo.GetValue/SetValue 解释成员路径。

这里的 ElementName 是声明给出的目标路径;C# 只能表达它,路径对应的 Element 是否真实存在,需要 Editor Validator 对照 Prefab 检查。

初值不是实现细节:零音量到底是谁说了算

设计新的 Binding 系统时,可以选择“取得目标 → 订阅源 → 静默同步初值 → 订阅用户事件 → 提交”这类事务顺序。另一种做法是先把所有监听建好,再进行一次局部同步。前者减小初始化时反向写入的窗口;后者可以统一建立连接,但更依赖静默赋值、相等比较和明确的初始化策略。

FUI 当前属于后一类,但还有一个必须知道的细节。BindingContext.Binding 建立本轮取消源和监听,执行 OnBinding 后进入 Bound;ViewInstance.Enable 随后调用 SynchronizeProperties,再调用 Presenter.OnOpen,最后对外标记 Enabled。同步失败由外围启用事务负责清理,不是简单把所有步骤塞在一个 Bind 方法里。

生成器对于带反向标志的绑定,还会在 OnBinding 中生成初值回填。下面是当前 InitV2VM 模板的教学化简,省略空目标检查与转换器:

void InitializeFromTarget()
{
    if (vm.Volume != default)
        return;

    vm.Volume = element.Value.Value;
}

这意味着当前实现不是无条件“VM 初值获胜”。假设配置里的音量已经是 0,Prefab 的 Slider 默认是 0.5,满足这一生成路径时,0 可能被当成尚未初始化,从 View 回填为 0.5。对 float 而言,default 既可能是缺省,也可能是合法的静音值;对 bool 而言,false 也可能是玩家主动关闭的选项。

这是从当前模板可以推导出的边界,本次没有运行对应 Prefab 的 Unity 用例。读者应该专门测试它,而不是只用 0.75 这样的非默认值跑 Demo。

如果继续演进框架,我更倾向于把初值来源变成显式策略:ViewModelWins、ViewWins 或有初始化标志的业务输入,而不是依赖 default 猜测。这些名字是设计建议,不是现有 FUI 枚举。短期用一致的 Prefab 初值能降低惊讶,但不能替代正式的初始化语义。

OneWay:血量文本没有反向改血的权力

血量来自战斗逻辑。HUD 的职责是把它投影成文本,而不是持有战斗对象并随意写回。下面是 FUI 的真实声明形态,省略工程级 ViewModule、资源配置和完整业务入口:

using FUI.Binding;
using FUI.Presentation;
using FUI.Rendering.UGUI.Elements;

[ViewContract("BattleHud")]
public partial class BattleHudViewModel : ViewModel
{
    [ObservableProperty]
    [Bind("Hp", nameof(TextElement.Content))]
    string hpText = "100 / 100";

    public void ApplyHealth(int current, int maximum)
    {
        HpText = $"{current} / {maximum}";
    }
}

外部业务调用 ApplyHealth,生成的 HpText 变化通知触发针对该属性的处理函数,再将值写入缓存的 TextElement.Content。它不是每帧去找节点,也不是每次通知都通过 PropertyInfo 解释路径。

下面是处理函数的结构化简,方法名和字段名不作为生成文件的逐字引用:

void OnHpTextChanged(object sender, string oldValue, string newValue)
{
    var element = cachedHpElement;
    if (element == null) return;
    element.Content?.SetValue(newValue);
}

OneWay 表示这条绑定不建立 View → VM 的回写,不等于整个应用不能修改 ViewModel。业务入口仍需自己决定谁可以改变战斗状态。对没有引擎对象依赖的 ViewModel,可以直接测试 ApplyHealth 的结果;Element 能否显示和布局则属于另一层测试。

另一个容易忽略的边界是线程。生成强类型赋值不会自动把后台线程通知调度到 Unity 主线程。异步数据在哪个线程提交,以及隐藏或缓存期间是否仍刷新,都要有明确约定。

TwoWay:音量编辑需要正反两条路径

音量滑块既要读取当前配置,也要接受用户拖动。对应的声明片段如下,需要放在已经配置 ViewContract 的 partial ViewModel 中:

[ObservableProperty]
[Bind("Volume", nameof(SliderElement.Value),
    bindingMode: BindingMode.TwoWay)]
float volume = 0.75f;

前向处理调用 Element 的绑定属性 SetValue。反向处理订阅 element.Value.OnValueChanged,必要时 ConvertBack,再写回 VM.Volume。下面是实际结构的教学化简:

void SubscribeTarget()
{
    element.Value.OnValueChanged += OnTargetValueChanged;
}

void OnTargetValueChanged(float oldValue, float newValue)
{
    vm.Volume = newValue;
}

void UnsubscribeTarget()
{
    element.Value.OnValueChanged -= OnTargetValueChanged;
}

不要把这段代码再手写一遍加入业务层,否则会和生成订阅叠加。生成器减少的不是一行 +=,而是正向、反向、初值和解绑分别维护时产生的规则分叉。

为什么 Clamp 以后还可能显示错误

假设业务已保存 1.0,某个自定义输入控件提交 1.2。业务把 1.2 归一化为 1.0,发现保存值没有变化,于是不发通知;控件仍显示 1.2。普通范围为 0~1 的 UGUI Slider 通常会自行限制输入,这里刻意用自定义控件说明边界。

一种可扩展方案是将“接受输入”与“属性赋值”分开,在用户提交后明确拿规范化值回显。下面是独立设计的伪代码,不是 FUI 当前生成器的既有行为:

void OnUserSubmitted(float raw)
{
    float accepted = model.AcceptVolume(raw);
    control.SetValueWithoutNotify(accepted);
}

这也解释了为什么金额、背包数量、购买次数不一定适合直接 TwoWay。编辑态允许暂时为空或非法,提交态必须满足业务规则。把输入缓存保存在独立的编辑模型,按确认按钮后校验并提交,往往比每敲一个字符就改真实业务状态更合适。

转换器放在哪里

音量 0.75f 显示为 75%,有三个选择:

ViewModel 派生属性

public string VolumeText => $"{Volume:P0}";

适合业务展示语义,最容易测试。但这个派生 getter 自身不会自动产生通知:Volume 变化后,还要让相关展示属性收到变化。仅仅写出 VolumeText 并不能保证绑定刷新。

Binding Converter

// 只演示正向转换职责,不是 FUI 完整接口。
public interface IForwardConverterExample<TSource, TTarget>
{
    TTarget Convert(TSource value);
}

适合同一数据在不同 View 上有不同表现。FUI 当前的 IValueConverter<TValueType, TTargetType> 同时组合正向与反向接口;双向转换还要考虑 ConvertBack 是否有定义,以及来回转换能否稳定。75% 文本允许空字符串时,直接转换回 float 不再是一个总能成功的函数。

View 自己格式化

适合纯视觉细节,例如颜色渐变或本地组件格式。

原则是:跨控件共享、影响业务可读性的格式放 ViewModel;特定控件适配放 Converter/Element。Converter 应轻量,避免在高频通知中创建闭包和临时字符串。

Command:确认是一种意图,不是一个待同步的 bool

购买按钮绑定 IsConfirmed 再监听它的变化,看起来也能提交。可第二次点击仍然是 true 怎么办?为了触发只好先写 false 再写 true,状态变量开始承担事件总线职责。Command 正好把“发生一次操作”从“值发生变化”中拆出来。

FUI 使用 Command Attribute 将 Element 的事件连接到方法。对于受支持的 Task 或 ValueTask 方法,生成代码通过 RunAsyncCommand 包装;BindingContext 为本次绑定持有取消源,解绑时发出取消,并观察异常。这个 Token 精确地从属于一次 Binding,不等于整个应用或业务请求的永久生命周期。

当前 RunAsyncCommand 没有自动提供所有业务策略。是否允许并发、按钮是否禁用、是否显示错误,需要业务或独立命令层决定。不能因为用了 Command 就宣称重复购买已被框架自动禁止。

下面是补充并发保护的完整教学类,不是 FUI 内置 AsyncCommand。假设从同一 UI 线程调用;execute 和状态回调不切换到不安全的线程,状态订阅者不抛异常。它不提供跨线程互斥:

using System;
using System.Threading;
using System.Threading.Tasks;

public sealed class SingleFlightAction
{
    readonly Func<CancellationToken, ValueTask> execute;
    bool running;
    public event Action<bool> RunningChanged;

    public SingleFlightAction(Func<CancellationToken, ValueTask> execute)
        => this.execute = execute ?? throw new ArgumentNullException(nameof(execute));

    public async ValueTask ExecuteAsync(CancellationToken token)
    {
        if (running) return;
        token.ThrowIfCancellationRequested();
        running = true;
        try
        {
            RunningChanged?.Invoke(true);
            await execute(token);
        }
        finally
        {
            running = false;
            RunningChanged?.Invoke(false);
        }
    }
}

FUI 的带 Command 方法可以把本轮 token 传给这样的业务协作者。异常仍向调用者传播,由 FUI 的异步命令观察路径记录;面向玩家的错误提示要由业务显式处理。禁用按钮只是反馈,running 才是这里的重复进入保护;涉及真实购买还需要服务端幂等,不能靠客户端防连点保证唯一交易。

取消也只是协作通知:服务忽略 Token,旧结果仍可能回来。页面关闭后的提交资格还要结合上一节系列文章讨论的版本或句柄检查。

解绑需要对应的是同一个委托

最隐蔽的错误,往往不是忘记写减号,而是减去了另一个对象:

// 错误:这两个 lambda 不是同一个订阅实例。
source.Changed += () => Refresh();
source.Changed -= () => Refresh();

命名方法或者保存下来的委托实例,才能建立可以追踪的对应关系。FUI 模板会对属性与 Command 分别生成订阅和退订操作;解绑时还会清空缓存 Element,避免复用时拿到旧引用。

BindingContext.Unbinding 在 finally 中移除基础 PropertyChanged 监听、取消并释放命令 Token、恢复 Unbound 状态。但不要把这理解成“任意用户清理抛异常都没有影响”:某个自定义清理回调中途失败,可能阻止该回调后续语句执行。逐项容错、聚合错误或约束清理不抛异常,仍然是扩展实现要考虑的事情。

单纯关闭 GameObject 并没有自动取消 C# 事件。页面生命周期必须实际走到框架的 Unbinding,才能兑现监听释放边界。完整的重复打开与列表复用问题留到第 09 篇。

列表绑定是独立课题

列表有三层生命周期:

  • 集合变化:Add/Remove/Move/Replace/Reset;
  • Cell 复用:Create/Bind/Unbind/Recycle;
  • Cell 内部 BindingContext。

简单实现可以只支持 Reset:

void OnItemsChanged()
{
    pool.ReleaseAll();
    foreach (var item in viewModel.Items)
        pool.Get().Bind(item);
}

但大型背包需要差量更新和虚拟化。不要在第一版框架里假装通用列表已经解决;应明确性能边界,并为 Cell 的 Unbind 写泄漏测试。

用最小测试揭开“静默”的边界

以下 NUnit 用例直接针对 FUI 的 BindableProperty,验证属性层并非无通知写入。不需要启动场景,但仍需要在引用 FUI 的测试程序集执行;本次只做源码核对,没有声称这些 Unity 测试已经运行通过。

using FUI.Binding;
using NUnit.Framework;

public class BindingDirectionTests
{
    [Test]
    public void SetValue_NotifiesOnce_WhenValueActuallyChanges()
    {
        var property = new BindableProperty<float>(0.75f);
        int count = 0;
        float observedOld = 0, observedNew = 0;
        property.OnValueChanged += (oldValue, newValue) =>
        {
            count++;
            observedOld = oldValue;
            observedNew = newValue;
        };

        property.SetValue(0.75f);
        property.SetValue(0.5f);
        property.SetValue(0.5f);

        Assert.That(count, Is.EqualTo(1));
        Assert.That(observedOld, Is.EqualTo(0.75f));
        Assert.That(observedNew, Is.EqualTo(0.5f));
        property.Dispose();
    }
}

这段测试不能证明 Slider 的用户事件不触发,也不能证明生成的 BindingContext 没有泄漏。下一层才是配置实际 SliderElement,分别测试程序赋值和模拟用户拖动;再上一层是打开、关闭、重新打开页面,检查事件与命令次数。

完整验收清单还应覆盖:

  • VM → View 同步不会经由 Slider 的静默赋值路径重触发 UGUI 用户事件;单独记录 FUI 属性层的反向回调。
  • 用户修改 TwoWay 控件后,两端最终收敛到预期值;包含保存值不变但显示值需要纠正的情况。
  • 检查 VM 外部变化时的实际回调次数,不用“至少能显示”掩盖重复副作用。
  • 第 N 条 Binding 失败,前 N-1 条全部退订。
  • Bind 两次不会叠加订阅;Unbind 两次不会抛异常。
  • 页面解绑时请求取消 Command;模拟服务忽略取消,确认业务的过期结果保护仍有效。
  • 缓存期间 ViewModel 更新的行为符合明确策略。
  • 对真实目标数量的列表项测量复用和分配,不把没有测量的性能结论写成框架保证。
  • 把 TwoWay 初值分别设置为 0、false、空字符串和非默认值,检查与 Prefab 初值冲突时谁获胜。

Binding 实现常见坑点

坑一:依赖反射路径处理所有情况

通用但把错误和开销推到运行时。稳定绑定应生成强类型赋值,动态表单再使用反射分支。

坑二:Binding 失败后继续运行

不能把缺失目标一律解释成相同错误。FUI 当前生成回调会检查缓存 Element 是否为 null,允许兼容 View 只提供需要的子集;默认 Route 所需元素的完整性由 Validator 检查。设计新框架时可以采用显式 Optional 策略,但这不是在宣称 FUI 存在某个 Optional Attribute。跳过 Validator,才容易把本应失败的完整页面变成半可用页面。

坑三:缓存页面时不解绑

“被上层遮挡”与“关闭后进入缓存”不是同一状态。不能把每次 SetActive(false) 都等价成应解绑,也不能关闭后只隐藏对象而不走框架生命周期;后者会留下全局监听和旧绑定。

坑四:属性通知使用魔法字符串

重命名后不报错。使用 nameof,或由生成器从符号生成。

坑五:每条绑定都捕获闭包

大量 Cell 中会产生可观分配。生成命名方法、复用转换器,并用 Profiler 验证。

绑定系统的易用性,来自把同类错误集中处理,而不是允许业务层随时伸手改控件。OneWay 限制写入口,TwoWay 需要明确初值与收敛策略,Command 划分操作和状态。生成器把连接规则集中起来,分层则让这些规则可以分别验证。

资料与源码索引

建议先跑属性通知的小测试,再接真实Element,最后验证重复打开与取消。每一层只证明自己负责的事实,不用单个Demo代替整条链路。

下一篇:FUI 绑定生命周期实战:可靠解绑、失败回滚与列表项换绑