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

系列第 09 篇,面向有 Unity UI 经验、准备设计框架的开发者。主案例是购买弹窗重复打开,补充排行榜 Cell 从 A 换绑为 B 的迟到头像问题。

目标是追踪一条订阅从建立、失败回滚到退出的完整路径。本文提供独立 NUnit 用例与集成验收步骤;FUI 当前源码、教学伪代码和虚拟化扩展建议分别说明,不把未运行的 Unity 测试算作完成。

一个"看起来已经解绑"的错误例子

csharp 复制代码
void OnEnable()
{
    var boundProductId = productId; // 每次启用捕获本次商品
    buyButton.onClick.AddListener(() => Buy(boundProductId));
}

void OnDisable()
{
    buyButton.onClick.RemoveListener(() => Buy(productId));
}

这个例子每次启用都会创建捕获本次商品的闭包,而关闭时使用的是另一处 Lambda。退订不是按代码文本匹配,而要匹配对应的委托目标与方法;对象引用不同本身并非完整的委托相等性规则。不能指望重新写一遍 Lambda 就退订原来的监听。

最先验证的应该是"一个点击产生了几次业务调用",而不是立刻认定服务端真的扣了多次钱。业务服务若有幂等保护,重复调用也可能只执行一次交易;这是另一层防线,不能掩盖 UI 订阅错误。

下面是局部修复,使用同一委托并先解除旧绑定。省略所在 MonoBehaviour 与 Buy 方法,只演示订阅生命周期:

csharp 复制代码
UnityAction buyAction;

void Bind()
{
    Unbind();
    var boundProductId = productId;
    buyAction = () => Buy(boundProductId);
    buyButton.onClick.AddListener(buyAction);
}

void Unbind()
{
    if (buyAction == null) return;
    buyButton.onClick.RemoveListener(buyAction);
    buyAction = null;
}

但框架还需要回答更多问题:第五条绑定失败时,前四条怎么办?页面进入缓存是解绑还是保持?异步 Command 正在执行时关闭页面怎么办?列表项换了一份数据,旧数据通知还会不会改到新格子?

一个不启动场景的最小闭环测试

下面使用独立的 C# 事件发布者与绑定对象测试"重复绑定不叠加,解绑后不再调用"。它是 NUnit 教学用例,不是 FUI 自带测试类型;本次发布只核对源码,没有执行 Unity 测试。真实 FUI 集成还要额外覆盖生成 BindingContext、ButtonElement 与导航路径。

csharp 复制代码
using System;
using NUnit.Framework;

public sealed class ListenerLifetimeTests
{
    sealed class ClickSource
    {
        public event Action Clicked;
        public void Click() => Clicked?.Invoke();
    }

    sealed class PurchaseBinding : IDisposable
    {
        readonly ClickSource source;
        readonly Action handler;
        bool bound;

        public PurchaseBinding(ClickSource source, Action handler)
        {
            this.source = source;
            this.handler = handler;
        }

        public void Bind()
        {
            if (bound) return;
            source.Clicked += handler;
            bound = true;
        }

        public void Dispose()
        {
            if (!bound) return;
            source.Clicked -= handler;
            bound = false;
        }
    }

    [Test]
    public void RepeatedOpen_DoesNotAccumulateHandlers()
    {
        var source = new ClickSource();
        int calls = 0;
        var binding = new PurchaseBinding(source, () => calls++);

        for (int i = 1; i <= 3; i++)
        {
            binding.Bind();
            binding.Bind();
            source.Click();
            Assert.That(calls, Is.EqualTo(i));

            binding.Dispose();
            binding.Dispose();
            source.Click();
            Assert.That(calls, Is.EqualTo(i));
        }
    }
}

这个测试的价值在于同时覆盖"活着时一次"和"退出后零次"。只测前者,错误实现也可能在第一次打开时通过。不要将它扩大解释成已经验证 UnityEvent 的全部细节、交易幂等或真实列表性能。

再给排行榜准备可控的头像加载器,让 A 的任务故意晚于 B 完成。测试依次执行绑定 A、切换到 B、完成 B、完成 A,最后断言 Cell 仍显示 B,且 A 返回的资源被交还真正的所有者。取消、版本校验与释放需要同时验证:只丢弃画面赋值但没有释放结果,会留下另一种泄漏。

绑定是一笔事务

下面是说明半绑定风险的教学伪代码,view.Get、OnClick 事件等不是 FUI 当前 API:

csharp 复制代码
void Bind()
{
    title = view.Get<TextElement>("Title");
    vm.TitleChanged += OnTitleChanged;

    buy = view.Get<ButtonElement>("Buy"); // 这里抛异常
    buy.OnClick += OnBuy;
}

如果第二个节点缺失,第一个订阅已经建立,但 BindingContext 没有进入完整可用状态。后续 Close 可能认为 Bind 从未成功而跳过 Unbind,最终留下半条活着的关系。

设计新框架时,可以选择下面这类顺序。这是设计方案,不是 FUI 当前逐行执行顺序:

text 复制代码
解析并缓存所有 Element
→ 校验必要节点与类型
→ 建立 ViewModel 到 View 的订阅
→ 写入初始值
→ 建立 View 到 ViewModel 与 Command 订阅
→ 标记 Bound

过程中每获得一项可释放责任,就登记清理动作。只有全部成功才提交;任何异常都尝试逆序解除已建立的订阅。还要考虑清理本身会失败:如果第一项 undo 抛异常就停止,后面的动作仍然泄漏。

下面是教学伪代码:

csharp 复制代码
public void Bind()
{
    if (isBound) return;

    var cleanup = new Stack<Action>();
    try
    {
        CacheAndValidateElements();

        vm.PriceChanged += OnPriceChanged;
        cleanup.Push(() => vm.PriceChanged -= OnPriceChanged);

        OnPriceChanged(vm, vm.Price, vm.Price);

        buyButton.OnClick += OnBuy;
        cleanup.Push(() => buyButton.OnClick -= OnBuy);

        committedCleanup = cleanup;
        isBound = true;
    }
    catch
    {
        // 这个精简版本假设所有 undo 都不抛异常。
        // 若扩展回调可能抛异常,应逐项捕获并汇总。
        while (cleanup.TryPop(out var undo)) undo();
        ClearElementReferences();
        throw;
    }
}

FUI 的 BindingContext 由生成器产出正向订阅、反向订阅、命令绑定、初始同步和解除代码。生成代码的价值不仅是少写 +=/-=,还在于绑定与解绑来自同一份声明,降低两边逐渐不对称的概率。

FUI 实际怎样处理绑定失败

FUI 并没有把上面的清理栈原样放进 BindingContext。当前实现由一个小状态机守住边界:Unbound、Binding、Bound、Unbinding。重复 Binding 在 Bound 或 Binding 状态下返回;重复 Unbinding 在 Unbound 或 Unbinding 状态下返回。这种幂等检查是在同一调用模型下避免重复执行,不意味着它是线程安全锁。

Binding 开始时创建本轮 Command 取消源,订阅基础 PropertyChanged,再调用 OnBinding。只有 OnBinding 成功才进入 Bound;失败时调用 OnUnbinding 撤回连接,并在 finally 中清理基础监听和取消源。

生成模板的 OnBinding 会缓存目标并建立各类订阅;OnSynchronize 是单独入口,由 ViewInstance.Enable 在 Binding 之后调用。因此同步异常由外层启用事务继续清理。不能把"初始同步早于所有反向订阅"的理想顺序当成当前实现。

OnUnbinding 中,生成代码执行对应的退订、清除外部值同步源、清空 Element 字段,再调用基类。最重要的收益是连接和断开来自同一条 Bind 或 Command 声明,业务代码不用每页重新维护两份清单。

不过,finally 不是绝对可靠的魔法。如果自定义 OnUnbinding 中途抛异常,该方法后面的清理语句仍可能没执行;取消回调抛异常,也会影响同一 finally 内后续步骤。框架已经提供失败退出路径,但扩展层仍需避免抛异常的清理,或采用逐项执行、聚合错误的策略。文章不能把它包装成"任意异常都保证所有资源完全释放"。

下面是支持逐项继续清理的独立辅助方法,属于扩展示例,不是 FUI 当前源码。业务层收到错误后应记录或汇总报告,不能直接忽略:

csharp 复制代码
static List<Exception> Drain(Stack<Action> cleanup)
{
    var errors = new List<Exception>();
    while (cleanup.Count > 0)
    {
        var undo = cleanup.Pop();
        try { undo(); }
        catch (Exception error) { errors.Add(error); }
    }
    return errors;
}

缓存页面到底要不要解绑

不能只看 GameObject 是否激活,要看框架如何定义缓存。

  • 如果缓存保留完整页面实例和 ViewModel,绑定可以继续存在,但必须暂停不该继续的输入或外部工作;
  • 如果缓存只保留可复用 View,BindingContext 和 Presenter 必须释放,下次用新 ViewModel 重新装配;
  • 如果页面已经终态关闭,绑定和命令令牌都必须退出,不能等待垃圾回收兜底。

以上是不同框架可以采用的缓存策略,不代表 FUI 允许关闭后任意保留绑定。FUI 当前 CompleteCloseOperationAsync 先执行 ViewInstance.DisableAsync,再尝试 TryCacheInstance。Disable 会调用 Unbinding,所以关闭后进入缓存仍然要退出本次绑定,不能把"对象没有销毁"理解为"监听可以继续存在"。

关闭动画与异步 OnClose 完成前,解绑不一定已经发生。导航先进入 Closing 并限制交互,之后才走 Disable;因此不能仅凭用户点击了关闭就假定所有 Command 立刻取消。需要立即停止某项业务工作时,应显式设计该时点。

被上层弹窗遮挡又是另一种状态,不等于终态关闭。业务层不应该自行决定"先 SetActive(false),以后再说",否则框架无法确认当前页面究竟隐藏、关闭还是等待复用。

Command 解绑还不够,还要取消执行

下面是仍有生命周期漏洞的异步购买教学片段:即使方法接收 Token,返回后也没有检查当前提交资格,finally 还可能修改已经复用的展示状态。shop 等协作者是示意,不是 FUI API:

csharp 复制代码
async ValueTask BuyAsync(CancellationToken token)
{
    IsSubmitting = true;
    try
    {
        await shop.BuyAsync(ProductId, token);
        Message = "购买成功";
    }
    finally
    {
        IsSubmitting = false;
    }
}

页面关闭后,移除按钮监听只能阻止新的执行,已经运行的任务仍然存在。FUI BindingContext 为本次绑定持有 CancellationTokenSource,在 Unbinding 中取消;生成的受支持异步 Command 会经过 RunAsyncCommand 和异常观察路径。完成后提交 UI 状态前,业务仍需要确认请求归属与当前代际有效,尤其是服务忽略取消或页面处于退出过程时。

对于支付、领奖等不可简单撤销的业务操作,取消 UI 等待不等于取消服务端事实。正确做法通常是:页面停止接收回调,业务请求仍由领域服务保证幂等,重新进入页面后从服务端状态恢复,而不是让 UI 生命周期决定交易是否发生。

防重复执行应该放在哪里

只在 View 中把按钮设为不可点击不够,因为命令可能被快捷键、自动化或其他入口调用。命令本身可以有执行门。下面是单 UI 线程调用假设下的教学片段,不是跨线程锁,也不是 FUI Command 自动附带的能力:

csharp 复制代码
if (isExecuting)
    return;

isExecuting = true;
try
{
    await ExecuteCoreAsync(token);
}
finally
{
    isExecuting = false;
}

View 再把 CanExecuteIsExecuting 映射到按钮状态。这样视觉反馈和行为约束来自同一份状态。

列表复用不是普通属性绑定的重复

一个采用视口虚拟化的排行榜可能只创建十个 Cell,却能滚动展示上百名玩家。这里先讨论设计目标,不代表 FUI 的 ScrollListElement 当前自动按视口只创建十个对象。某个 Cell 先显示玩家 A,滚动后改为玩家 B。如果它没有解除 A 的头像加载、属性通知和点击命令,A 的迟到回调就会改到 B 的格子上。

一个列表项至少有三层身份:

text 复制代码
Cell 对象身份
绑定代际
当前 Item 身份

只比较 Cell 对象没有用,因为对象本来就会复用。每次 Rebind 都应该终止旧代际。以下是顺序伪代码,PlayerRowViewModel、lifetime 和头像服务均为案例概念:

csharp 复制代码
public void Bind(PlayerRowViewModel next)
{
    UnbindCurrent();
    bindVersion++;
    item = next;
    item.NameChanged += OnNameChanged;
    // 该辅助方法内部需观察取消与异常,不能遗留未观察任务。
    _ = LoadAvatarAndObserveAsync(item.Id, bindVersion, lifetime.Token);
}

仅在 Bind 里递增版本不够:Unbind 即使后面没有新的 Bind,也应使旧版本失效。头像回来时同时检查版本和 Item ID。UnbindCurrent 要取消旧任务、解除集合/属性通知、释放当前动态资源 Lease,再清空引用。

集合变化为什么需要专门的适配器

普通属性变化是"新值覆盖旧值",列表则可能是 Add、Remove、Move、Replace、Reset。每次都重建整个列表最简单,但大型列表会带来实例化、布局和 GC 压力;做增量更新又必须维护索引、复用池和 Item 身份。

这是设计通用列表时常见的事件集合,不是 FUI 当前接口逐字定义。FUI 的 BindingContextUtility 实际连接 CollectionAdd、CollectionRemove、CollectionReplace、CollectionUpdate 四种事件,解绑时逐一解除;它没有在这里声明 CollectionMove 或 CollectionReset。排序变化如何表达,需要按当前集合实现处理,不要直接套用其他集合接口的语义。

因此列表绑定应被视为独立子系统,而不是让通用 SetValue 猜测集合:

  • 小列表可以 Reset 全量重建;
  • 长列表需要增量事件和可视区域复用;
  • 对需要跨排序或异步核对身份的列表,Item 应有稳定业务 Key;这不等于 FUI 强制每个 Item 实现某个 Key 接口;
  • Cell 复用必须走统一 Rebind/Unbind;
  • 动态资源应归明确的页面资源作用域或 Cell 子租约,不能只丢给对象池等待销毁。这是所有权建议,具体接口以项目 Provider 契约为准。

FUI 的列表复用,具体复用了什么

先看 RecyclingListElement。移除一项时,它把对应 ViewInstance Disable,移到可复用位置,而不是只隐藏 GameObject。替换数据时,如果新旧 ViewModel 的运行时类型一致,会调用 UpdateViewModel;类型不一致则释放原项投影并重新创建。

BindingContext.UpdateViewModel 会先退出旧绑定,再切换引用;之前处于 Bound 时才绑定新数据。新绑定失败,会尝试恢复旧 ViewModel 与旧连接;恢复也失败则抛出包含两次错误的 AggregateException。这比"直接把 item 字段改成 B"多承担了一层事务责任。

同一份 Item 数据仍在使用时,列表更新可以调用 SynchronizeProperties 刷新当前投影。这里要区分更新现有项、换绑另一个项、重新创建另一种类型三个动作,不能全部命名成 Refresh 后靠条件猜测。

ScrollListElement 继承 RecyclingListElement,额外适配 ScrollRect 的位置、速度、方向与事件。当前 RecyclingListElement 的 MaximumVisibleItems 默认是 int.MaxValue,ScrollListElement 没有据此实现完整的视口窗口算法。因此,"支持对象复用"不等于"长列表只创建可见 Cell",更不能据此声称任意规模排行榜都没有布局和分配成本。

如果继续增加视口虚拟化,需要额外处理可见索引范围、占位高度、不同高度项、滚动时换绑,以及异步图片请求所有权。这是合理的扩展方向,但不应为了强调框架优势而省掉这些代价。

监听泄漏为什么不一定表现为内存上涨

如果长生命周期 Service 持有页面委托,页面无法回收,会表现为内存泄漏。如果页面持有全局事件的订阅,可能同时出现重复逻辑。如果发布者和订阅者都随页面销毁,对象最终也许能回收,但重复打开期间仍会出现多次执行。

因此"Profiler 没看见明显上涨"不能证明解绑正确。行为测试更直接:打开、关闭、再打开后,发布一次事件只能触发一次处理。

为什么不直接 RemoveAllListeners 或换成弱引用

手动成对订阅适合很小、关系少的界面,优点是直观,没有额外设施;代价是每个开发者都要记住相同的清理规则。关系一多,就需要评审逐行证明没有漏项。

页面级订阅容器可以统一登记释放动作,适合动态创建、无法预先生成的关系。它仍然要求容器生命周期正确,并处理清理动作异常;把所有页面共享一个全局容器,只是把泄漏换了一个位置。

Source Generator 更适合声明稳定的关系。FUI 从同一份绑定描述产出正反两面,让框架承担容易漏写的工作。代价是生成规则本身必须有测试,开发者也需要能查看生成结果。手工动态订阅依然存在时,应走明确的资源作用域,不能假定生成器会找到业务代码里任意一条 +=。

RemoveAllListeners 不是通用修复:它可能移除不属于本层的监听,把所有权混乱变成误删其他功能。弱事件可以减轻长生命周期发布者对订阅者的强引用,却无法保证页面关闭后立刻停止副作用,也不会替你取消已经运行的异步任务。它们解决的问题与对称解绑并不相同。

最小测试矩阵

  1. 购买弹窗连续打开三次,每次点击只执行一次 Command。
  2. 第二条绑定故意失败,第一条已经建立的监听被回滚。
  3. Unbind 调用两次不会异常,也不会重复释放资源。
  4. 异步 Command 执行时关闭页面,记录何时解绑与请求取消;服务忽略取消时,业务的过期结果保护仍生效。
  5. 列表 Cell 从 A 复用为 B,A 的属性通知和头像结果不能修改 B。
  6. 按实际 CollectionUpdate 或替换语义调整列表顺序,验证 Item 身份、旧订阅与对象复用;不假定存在未实现的 Move 事件。
  7. 缓存恢复时,绑定策略与框架定义一致,不产生第二套监听。

可靠绑定的标准不是"页面打开时看起来同步了",而是任何失败、关闭、缓存和复用路径结束后,系统都能准确回答:还有哪些订阅活着,它们属于谁,什么时候会退出。

资料与源码索引

实现时先跑通一次绑定与一次退出,再测重复调用、半途失败、换绑失败和迟到结果。按层验证,比用一个滚动Demo推断整个列表系统可靠更具体。

下一篇预告:以英雄皮肤预览为例,讨论 Provider、Lease 与资源所有权,处理加载途中关闭和统一释放。第 10 篇发布后补充同平台跳转。

相关推荐
_zhourui_h_1 小时前
Unity 资源热更新极限拆解:几万个文件,怎么做到只下载真正变动的那几个?
unity3d
爱吃苹果的日记本15 小时前
数据结构第一课
c语言·数据结构·数据库·学习·c#
唐青枫16 小时前
C#.NET PInvoke 深入解析:封送、内存布局与原生互操作实战
c#·.net
软件黑马王子20 小时前
24.Editor 资源加载:主要作用和基本原理
开发语言·前端框架·c#
慧都小妮子20 小时前
用 Python 批量把 PDF 参考文献排成 MLA 格式:Spire.Doc for Python 实战
python·pdf·c#·spire.doc·mla格式·参考文献排版·pdf批量处理
UIU1141 天前
补码运算与整数溢出(上
学习·c#·补码·补码运算
SmalBox1 天前
01-05-认知篇-基础-Unity资源管理痛点分析
unity3d·游戏开发
曹牧1 天前
C#:模态对话框
开发语言·c#
咕白m6251 天前
C# 如何限制 PDF 复制、打印权限?
c#·.net