系列第 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 再把 CanExecute 或 IsExecuting 映射到按钮状态。这样视觉反馈和行为约束来自同一份状态。
列表复用不是普通属性绑定的重复
一个采用视口虚拟化的排行榜可能只创建十个 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 不是通用修复:它可能移除不属于本层的监听,把所有权混乱变成误删其他功能。弱事件可以减轻长生命周期发布者对订阅者的强引用,却无法保证页面关闭后立刻停止副作用,也不会替你取消已经运行的异步任务。它们解决的问题与对称解绑并不相同。
最小测试矩阵
- 购买弹窗连续打开三次,每次点击只执行一次 Command。
- 第二条绑定故意失败,第一条已经建立的监听被回滚。
- Unbind 调用两次不会异常,也不会重复释放资源。
- 异步 Command 执行时关闭页面,记录何时解绑与请求取消;服务忽略取消时,业务的过期结果保护仍生效。
- 列表 Cell 从 A 复用为 B,A 的属性通知和头像结果不能修改 B。
- 按实际 CollectionUpdate 或替换语义调整列表顺序,验证 Item 身份、旧订阅与对象复用;不假定存在未实现的 Move 事件。
- 缓存恢复时,绑定策略与框架定义一致,不产生第二套监听。
可靠绑定的标准不是"页面打开时看起来同步了",而是任何失败、关闭、缓存和复用路径结束后,系统都能准确回答:还有哪些订阅活着,它们属于谁,什么时候会退出。
资料与源码索引
- FUI 仓库
- FUI:BindingContext.cs
- FUI:BindingContextUtility.cs
- FUI:ViewInstance.cs
- FUI:Navigator.Operations.cs
- FUI:RecyclingListElement.cs
- FUI:ScrollListElement.cs
- FUI:BindingContextGenerator.cs
- FUI:BindingContextGenerator.Template.cs
实现时先跑通一次绑定与一次退出,再测重复调用、半途失败、换绑失败和迟到结果。按层验证,比用一个滚动Demo推断整个列表系统可靠更具体。
下一篇预告:以英雄皮肤预览为例,讨论 Provider、Lease 与资源所有权,处理加载途中关闭和统一释放。第 10 篇发布后补充同平台跳转。