系列第 13 篇,面向有 Unity/C# 经验、准备设计框架的开发者。目标是让一份登录校验逻辑连接 UGUI 与普通对象测试,预期验证输入、提交和关闭后的双向解绑;教学端口与FUI实际API分开说明。
把“同一登录逻辑”从“同一份绑定声明”拆开
我们真正想稳定的是用户名校验、登录用例、错误映射和请求有效期,而不是某个输入框的类名。可以先把这些逻辑提到不引用引擎的应用对象,再让 UGUI ViewModel/Presenter 和测试驱动分别调用它。实际项目中 FUI Presenter 接应用服务,生成 ViewModel 表达状态;下面只用同步用户名校验与请求快照演示边界,不实现认证服务,也不声称这一小段取代完整 FUI 绑定系统。
下面的 LoginLogic、ILoginPort、LoginConnection 都是本教程新增的普通 C# 示例,放在同一个文件即可。不需要 FUI 生成器,也不使用伪造的 FUI API。提交只记录规范化后的用户名,真实网络请求需另行处理取消、错误和过期响应。
using System;
public sealed class LoginLogic
{
public string UserName { get; private set; } = "";
public string Error { get; private set; } = "请输入用户名";
public bool CanSubmit => Error.Length == 0;
public string LastSubmitted { get; private set; }
public int SubmitCount { get; private set; }
public event Action Changed;
public void SetUserName(string value)
{
UserName = (value ?? "").Trim();
Error = UserName.Length == 0 ? "请输入用户名" : "";
Changed?.Invoke();
}
public void Submit()
{
if (!CanSubmit) return;
LastSubmitted = UserName;
SubmitCount++;
}
}
public interface ILoginPort
{
event Action<string> UserEdited;
event Action SubmitRequested;
void Render(string userName, string error, bool canSubmit);
}
public sealed class LoginConnection : IDisposable
{
readonly LoginLogic logic;
readonly ILoginPort port;
bool disposed;
public LoginConnection(LoginLogic logic, ILoginPort port)
{
this.logic = logic ?? throw new ArgumentNullException(nameof(logic));
this.port = port ?? throw new ArgumentNullException(nameof(port));
// 例中两个端口的事件访问器不会抛异常。
port.UserEdited += Edit;
port.SubmitRequested += Submit;
logic.Changed += Refresh;
try { Refresh(); }
catch { Dispose(); throw; }
}
void Edit(string value) { if (!disposed) logic.SetUserName(value); }
void Submit() { if (!disposed) logic.Submit(); }
void Refresh()
{
if (!disposed) port.Render(logic.UserName, logic.Error, logic.CanSubmit);
}
public void Dispose()
{
if (disposed) return;
disposed = true;
port.UserEdited -= Edit;
port.SubmitRequested -= Submit;
logic.Changed -= Refresh;
}
}
这里有意不用一个公开的 Root 属性。连接器只能订阅用户意图、渲染状态,既无法销毁页面,也无法随便遍历子节点。Dispose 只释放自己建立的三条订阅,不销毁端口或业务对象,因为两者都是外部传入,所有权没有转交给连接器。
还要留意示例的适用范围:事件在同一 UI 线程调用,端口实现的加减订阅不抛异常;如果扩展者提供自定义事件访问器,部分订阅失败就需要逐项登记并回滚。初次 Render 抛错的情况已在构造中回滚,但这不等于任意外部代码异常都自动安全。
第一种展示:真正连接 UGUI,而不是只画一个假接口
把下列脚本保存为 UguiLoginPort.cs,挂到登录面板,拖入用户名 InputField、错误 Text、提交 Button。这是教学用手写 UGUI 适配器,不是 FUI 内置 Element。它允许在展示边界持有 GameObject/组件;限制的是业务层,不是禁止所有代码使用引擎。
using System;
using UnityEngine;
using UnityEngine.UI;
public sealed class UguiLoginPort : MonoBehaviour, ILoginPort
{
[SerializeField] InputField userName;
[SerializeField] Text error;
[SerializeField] Button submit;
LoginConnection connection;
public event Action<string> UserEdited;
public event Action SubmitRequested;
void Awake()
{
if (userName == null || error == null || submit == null)
throw new InvalidOperationException("登录控件引用不完整");
userName.onValueChanged.AddListener(OnEdited);
submit.onClick.AddListener(OnSubmitted);
}
// 演示用组合入口。正式 FUI 项目由页面生命周期拥有连接。
public void Connect(LoginLogic logic)
{
connection?.Dispose();
connection = null;
connection = new LoginConnection(logic, this);
}
public void Disconnect()
{
connection?.Dispose();
connection = null;
}
public void Render(string value, string message, bool canSubmit)
{
userName.SetTextWithoutNotify(value);
error.text = message;
submit.interactable = canSubmit;
}
void OnEdited(string value) => UserEdited?.Invoke(value);
void OnSubmitted() => SubmitRequested?.Invoke();
void OnDestroy()
{
Disconnect();
if (userName != null) userName.onValueChanged.RemoveListener(OnEdited);
if (submit != null) submit.onClick.RemoveListener(OnSubmitted);
}
}
测试场景可用一个启动脚本在 Start 中取得 UguiLoginPort 并调用 Connect(new LoginLogic())。输入空格应显示“请输入用户名”且无法提交;输入 Alice 应允许提交。Connect 对重连先断旧连接,避免重复订阅。外部关闭但缓存面板时应调用 Disconnect,重新打开时再 Connect;这里没有把 OnDisable 当成统一关闭,因为遮挡、禁用和导航关闭不是同一件事。
如果把 Render 中的 SetTextWithoutNotify 改成普通事件写入,用户名修剪后的回填可能再次触发编辑事件。轻则重复校验,重则在规范化规则互相转换时循环。这也是端口契约必须写清“程序渲染不代表用户编辑”的原因,不能只保证两边都有一个 string 字段。
接回 FUI 时,不应该在已经生成绑定的输入框上再叠加这套连接器。正常路径仍是 InputFieldElement → 生成绑定 → ViewModel 状态/Command → Presenter或应用服务。把 LoginLogic 注入业务端,适配层负责把变化同步到生成属性即可。手写端口在此用于展示可替换边界和验证业务逻辑,不是建议绕开框架默认路径。
第二种展示:普通对象跑通相同用例
下面的 FakeLoginPort 不是冒充 InputFieldElement,而是明确实现我们自己刚定义的 ILoginPort。它可以在普通 C# 测试中构造。这种假展示验证的是登录逻辑与订阅契约,不证明 FUI 生成器、Prefab 或真实触摸输入已经通过测试。
using System;
public sealed class FakeLoginPort : ILoginPort
{
public event Action<string> UserEdited;
public event Action SubmitRequested;
public string Name { get; private set; }
public string Error { get; private set; }
public bool Enabled { get; private set; }
public int Renders { get; private set; }
public void Input(string value) => UserEdited?.Invoke(value);
public void Click() => SubmitRequested?.Invoke();
public void Render(string value, string error, bool canSubmit)
{
Name = value;
Error = error;
Enabled = canSubmit;
Renders++;
}
}
将上述纯 C# 类型与下面测试放进引用 NUnit 的测试工程即可运行。本文提供测试代码和预期断言,本次没有运行 Unity 或 NUnit,不把预期写成已通过的结果。
using NUnit.Framework;
public sealed class LoginBoundaryTests
{
[Test]
public void InputSubmitAndClose_HaveExplicitBoundaries()
{
var logic = new LoginLogic();
var port = new FakeLoginPort();
var link = new LoginConnection(logic, port);
Assert.That(port.Enabled, Is.False);
port.Click(); // 即使假端口强行发事件,逻辑仍要守住校验。
Assert.That(logic.SubmitCount, Is.Zero);
port.Input(" Alice ");
Assert.That(port.Name, Is.EqualTo("Alice"));
Assert.That(port.Enabled, Is.True);
port.Click();
Assert.That(logic.LastSubmitted, Is.EqualTo("Alice"));
Assert.That(logic.SubmitCount, Is.EqualTo(1));
var renders = port.Renders;
link.Dispose();
link.Dispose();
port.Input("Bob");
port.Click();
Assert.That(logic.UserName, Is.EqualTo("Alice"));
Assert.That(logic.SubmitCount, Is.EqualTo(1));
logic.SetUserName("Carol");
Assert.That(port.Renders, Is.EqualTo(renders));
}
[Test]
public void Reconnect_DoesNotKeepOldSubscriptions()
{
var logic = new LoginLogic();
var port = new FakeLoginPort();
var first = new LoginConnection(logic, port);
first.Dispose();
using (var second = new LoginConnection(logic, port))
{
port.Input("Alice");
port.Click();
Assert.That(logic.SubmitCount, Is.EqualTo(1));
}
}
}
注意测试不只断言正常提交,还强行从禁用状态发出点击。控件不可交互是展示反馈,不是业务合法性的唯一保证。关闭之后既测试“用户事件不再改模型”,也测试“模型变化不再渲染旧页面”,这两条清理方向缺一不可。
很多框架都说自己“解耦了 UI”,直到业务代码里出现 GameObject、Transform、Button、Canvas。这时即使接口名字叫 IViewService,核心逻辑仍然被某个展示后端的对象模型绑住了。
判断一个 UI 框架是否真的具有展示层扩展性,可以做一个很直接的思想实验:如果明天顶层页面不再由 UGUI 渲染,导航、ViewModel、路由策略和大部分测试能不能不改?
目标不是承诺所有后端零成本切换。真正有价值的是让改动集中在适配层,而不是沿着业务逻辑、导航和资源系统扩散。
最常见的“假抽象”
public interface ILoginView
{
GameObject Root { get; }
TMP_InputField UserName { get; }
Button Submit { get; }
}
它确实有接口,却把具体后端类型写进了公共契约。测试仍要创建引擎对象,换后端仍要修改所有调用者。
另一种错误是为了“完全通用”退回 object:
object GetElement(string path);
void SetValue(object target, string property, object value);
依赖看似消失,类型检查、重构能力和错误定位也一起消失。真正的解耦不是没有类型,而是核心层只依赖稳定、足够小的类型。
先画清依赖方向
FUI 当前的程序集边界提供了一个很好的观察点:当前 Core 和 Navigation 的 asmdef 都明确设置了 noEngineReferences: true,UGUI 实现在单独的 Rendering.UGUI 层。
理想依赖方向是:
业务 ViewModel / Route
↓
Core + Navigation contracts
↑
UGUI adapter / Test adapter / Future renderer
箭头表示编译依赖。核心定义 IView、IElement、资源 Lease 和导航语义;具体后端实现这些契约。核心不反向引用 UGUI。
这提供了不创建 GameObject 进行核心测试的条件,并不等于所有测试天然独立于引擎。具体绑定如果缓存的是 UGUI Element,依然要用相应对象或另外设计测试边界。程序集无引擎引用和整条业务链无后端依赖,是两项检查。
IElement 为什么要足够小
FUI 当前 IElement 只要求名称和父级关系:
public interface IElement
{
string Name { get; }
IElement Parent { get; }
}
文本、按钮、滑块等能力由更具体的 Element 类型表达。核心不会要求所有节点都实现 Text、Value、OnClick 这些彼此无关的功能。
接口太大会迫使每个后端做无意义实现;接口太弱又会退回反射和 object。比较稳妥的原则是:公共最小身份放在基础契约,具体可绑定能力放在细粒度 Element,生成器通过语义模型读取真实类型。
最容易忽略的边界:IElement 不等于鸭子类型
假设原绑定声明使用 nameof(InputFieldElement.Text)。生成器会解析成员所属类型和可绑定属性,在生成的上下文中缓存该目标类型,并通过 ViewExtensions.TryGetElement 查找。它不是看到另一个对象也有 Text 和 Changed 就自动适配。
以下是结构示意,不是可直接替代生成文件的完整代码:
// 实际生成器按声明类型创建强类型字段,再按路径查找。
InputFieldElement cachedInput;
cachedInput = ViewExtensions.TryGetElement<InputFieldElement>(view, "UserName");
因此,普通 C# FakeInputFieldElement 即使实现 IElement,并且也有 string Text,仍不等于 InputFieldElement。后者还属于 Rendering.UGUI,继承链包含 MonoBehaviour。给假的元素起同样名字,不能突破类型关系。生成器使用 TryGetElement 也意味着不能把所有缺失都说成必然当场抛错;默认 Route 的完整性验证与兼容视图的部分端点是不同语义。
这正是原稿需要补清的限制:FUI 当前已经分离核心与 UGUI 实现,但没有因此承诺现有 UGUI 绑定声明可直接接到任意普通对象。更换到另一个展示体系,需要适配 View、端点、创建释放与验证,也可能需要调整展示绑定声明。UI Toolkit 在这里是未来扩展方向,不是本文展示过的现成后端。
为什么业务层不应该拿到底层 View
开发者拿到 GameObject 后可以直接 SetActive(false)、改父节点、Destroy、寻找任意子节点。这些操作很方便,也会绕过 Navigator 的历史、缓存、绑定和资源所有权。
框架易用性的一个反直觉部分,是主动减少调用者权限。以嵌套投影为例,FUI 当前的 ProjectionHandle<TViewModel> 公开的业务能力为:
ViewModel;IsAlive;Activate/Deactivate;Dispose。
它不公开底层 View、Element 或 BindingContext。Dispose 先把内部实例引用置空,再调用释放动作;重复 Dispose 不重复释放,但释放动作抛错也不代表系统必然完成所有清理。业务代码能完成嵌套投影需要的生命周期操作,却不能通过此句柄取得内部装配。顶层导航的 ViewHandle 则是另一种类型:它是带 Value/IsValid 的生命周期标识,供 Navigator 接收,不具有上面这组 Activate/Deactivate 成员。不要混用两种句柄。
这不是为了阻止高级开发者,而是让团队默认写出的代码都经过同一条可观察、可测试的路径。确实需要底层能力时,应在 Rendering 适配层增加一个有边界的能力接口,而不是把内部对象图整体泄漏出去。
最小权限 API 怎样判断够不够
可以对每个公开成员追问:调用者完成自己的职责真的需要它吗?
| 能力 | 推荐暴露位置 | 原因 |
|---|---|---|
| 打开、关闭、返回 | Navigator/业务门面 | 需要统一维护状态与历史 |
| 读取 ViewModel | 受控 Handle | 业务可能需要观察页面状态 |
| 查找任意 Transform | 不进入核心公共 API | 会绕过 Element 与验证 |
| 播放后端动画 | ITransition 适配点 | 导航只关心完成、取消与失败 |
| 加载动态资源 | 页面 IAssetScope | 让资源归属当前页面 |
| Destroy 页面实例 | 仅 Provider/Navigator | 防止所有权被业务破坏 |
最小权限不是让 API 难用。相反,正确路径应该更短:业务打开 Route、保存 Handle、通过 ViewModel 传递状态;常见的释放、取消和恢复由框架完成。
换展示层时哪些代码应该改变
合理预期不是“一行不改”,而是改动边界清晰:
需要改变的通常包括:
IView/IElement的具体实现;- 节点查找与 Prefab/文档结构验证;
- Provider 如何创建、激活和释放 View;
- Transition 如何操作透明度、位置和输入;
- 特定控件的 Element 适配器。
不应该被迫改变的包括:
- 独立于具体绑定声明的业务逻辑与展示状态语义(已有具体 UGUI 类型的绑定声明仍可能需要适配);
- typed Route 与 RoutePolicy;
- Navigator 的历史、遮挡、缓存、依赖和句柄语义;
- 资源 Lease 的所有权规则;
- 不依赖具体控件事实的单元测试。
如果更换后端导致所有 ViewModel 都要新增 GameObject 或节点查询,说明依赖方向已经反了。
为什么不能过早设计“万能渲染抽象”
不同展示后端的布局、事件、文本、列表和动画能力并不完全相同。试图一开始就建立覆盖所有后端的巨大接口,通常得到最小公分母,或者到处是可选能力和 NotSupportedException。
更务实的演化方式是:
- Core 只定义已经被两个实现证明稳定的语义;
- UGUI 适配层可以拥有自己的高阶能力;
- 新后端先通过适配器验证缺口;
- 只有多个后端都需要的能力才上移;
- 后端特有功能留在局部,不污染所有调用方。
这种设计允许扩展,却不承诺抹平所有差异。
契约测试比接口文档更可靠
如果要接入第二个展示后端,应让所有实现跑同一组契约测试:
- 相同路径能稳定取得相同 Element 类型。
- 缺失或类型错误不会静默返回错误对象。
- 程序写值与用户事件能区分,避免 TwoWay 回环。
- View 关闭后,Element 事件不再进入旧 BindingContext。
IViewLease.Dispose()幂等,并释放页面动态资源作用域。- Transition 取消后状态收敛,不把输入永久锁住。
- 要求跨后端稳定的应用逻辑程序集不引用具体后端;展示绑定程序集允许局部引用 UGUI,不把它误称为完全独立的核心。
展示层解耦的价值,不只是在未来切换技术。它首先改善的是今天:逻辑可以单元测试,沿受控入口工作的团队成员更少绕过生命周期,资源与导航规则拥有唯一入口,新控件也可以通过契约接入而不是修改核心。
不是所有项目都要现在就做第二套后端
第一种方案是业务直接持有 UGUI 组件。原型、一次性活动和小团队短生命周期项目可以接受,它的优势是接线少、调试路径直接;代价是测试和生命周期规则更依赖场景与个人纪律。不能仅因为它不用抽象,就断言代码一定差。
第二种方案是每页一个 ILoginView 这样的行为接口。对登录这种稳定业务交互很清楚,也便于普通对象测试;但页面与控件很多时,手写适配与解绑量会增长。上面的端口教程展示了它的优势,也暴露了需要自己承担的工作。
第三种才是 FUI 当前选择的核心契约、Element 与编译期绑定:把寻址、类型装配和成对解绑收进统一路径,同时允许展示层实现具体能力。代价是需要生成器、资源契约与验证工具协作,还要理解绑定声明本身的依赖。选择它的理由不是“接口越多越高级”,而是团队重复承担的错误足够多,值得集中解决。
由源码和这些边界可以推断,FUI 的最小权限设计是在降低风格分叉:新手从 Route 和生成 ViewModel 开始,不必每次自选 Find、SetActive 或 Destroy 的时机。它不是安全沙箱。只要业务程序集仍引用 Unity,开发者仍可能自行全局查找对象;要进一步约束,应划分程序集、禁止越层依赖,并把违规访问纳入静态检查和代码评审。不能把“句柄没暴露”写成“绝无可能绕过”。
真实切换后端时,先选一个登录页做纵向切片,而不是先造覆盖所有控件的接口大全。保持 LoginLogic 和它的普通测试不动,替换展示适配,验证输入、错误、提交、关闭、重开,再接 FUI IView/Provider 与生成端点。第二个实现暴露的实际差异,才是决定哪些能力值得上移到 Core 的证据。
资料与源码索引
- FUI:FUI.Core.asmdef
- FUI:FUI.Navigation.asmdef
- FUI:IElement.cs
- FUI:IView.cs
- FUI:ViewExtensions.cs
- FUI:ProjectionHandle.cs
- FUI:ViewHandle.cs
- FUI:InputFieldElement.cs
- FUI:ContextInfoByAttributeGenerator.cs
- FUI:BindingContextGenerator.cs
- FUI:presenter.md
先运行普通对象测试,再连接场景输入,最后验证FUI真实生成与资源生命周期。本文测试未实际执行,不能拿教学端口通过的预期替代框架集成验收。