
系列第 13 篇。把 GameObject 藏进接口不等于解除对 UGUI 的依赖。这篇先检查 FUI 的程序集和生成绑定再用一个登录页说明哪些逻辑可以保持不动哪些适配仍然必须重写。很多框架都说自己“解耦了 UI”直到业务代码里出现GameObject、Transform、Button、Canvas。这时即使接口名字叫IViewService核心逻辑仍然被某个展示后端的对象模型绑住了。判断一个 UI 框架是否真的具有展示层扩展性可以做一个很直接的思想实验如果明天顶层页面不再由 UGUI 渲染导航、ViewModel、路由策略和大部分测试能不能不改目标不是承诺所有后端零成本切换。真正有价值的是让改动集中在适配层而不是沿着业务逻辑、导航和资源系统扩散。最常见的“假抽象”publicinterfaceILoginView{GameObjectRoot{get;}TMP_InputFieldUserName{get;}ButtonSubmit{get;}}它确实有接口却把具体后端类型写进了公共契约。测试仍要创建引擎对象换后端仍要修改所有调用者。另一种错误是为了“完全通用”退回objectobjectGetElement(stringpath);voidSetValue(objecttarget,stringproperty,objectvalue);依赖看似消失类型检查、重构能力和错误定位也一起消失。真正的解耦不是没有类型而是核心层只依赖稳定、足够小的类型。先画清依赖方向FUI 当前的程序集边界提供了一个很好的观察点当前 Core 和 Navigation 的 asmdef 都明确设置了noEngineReferences: trueUGUI 实现在单独的 Rendering.UGUI 层。理想依赖方向是业务 ViewModel / Route ↓ Core Navigation contracts ↑ UGUI adapter / Test adapter / Future renderer箭头表示编译依赖。核心定义IView、IElement、资源 Lease 和导航语义具体后端实现这些契约。核心不反向引用 UGUI。这提供了不创建 GameObject 进行核心测试的条件并不等于所有测试天然独立于引擎。具体绑定如果缓存的是 UGUI Element依然要用相应对象或另外设计测试边界。程序集无引擎引用和整条业务链无后端依赖是两项检查。IElement为什么要足够小FUI 当前IElement只要求名称和父级关系publicinterfaceIElement{stringName{get;}IElementParent{get;}}文本、按钮、滑块等能力由更具体的 Element 类型表达。核心不会要求所有节点都实现Text、Value、OnClick这些彼此无关的功能。接口太大会迫使每个后端做无意义实现接口太弱又会退回反射和object。比较稳妥的原则是公共最小身份放在基础契约具体可绑定能力放在细粒度 Element生成器通过语义模型读取真实类型。最容易忽略的边界IElement 不等于鸭子类型假设原绑定声明使用 nameof(InputFieldElement.Text)。生成器会解析成员所属类型和可绑定属性在生成的上下文中缓存该目标类型并通过 ViewExtensions.TryGetElement 查找。它不是看到另一个对象也有 Text 和 Changed 就自动适配。以下是结构示意不是可直接替代生成文件的完整代码// 实际生成器按声明类型创建强类型字段再按路径查找。InputFieldElementcachedInput;cachedInputViewExtensions.TryGetElementInputFieldElement(view,UserName);因此普通 C# FakeInputFieldElement 即使实现 IElement并且也有 string Text仍不等于 InputFieldElement。后者还属于 Rendering.UGUI继承链包含 MonoBehaviour。给假的元素起同样名字不能突破类型关系。生成器使用 TryGetElement 也意味着不能把所有缺失都说成必然当场抛错默认 Route 的完整性验证与兼容视图的部分端点是不同语义。这正是原稿需要补清的限制FUI 当前已经分离核心与 UGUI 实现但没有因此承诺现有 UGUI 绑定声明可直接接到任意普通对象。更换到另一个展示体系需要适配 View、端点、创建释放与验证也可能需要调整展示绑定声明。UI Toolkit 在这里是未来扩展方向不是本文展示过的现成后端。把“同一登录逻辑”从“同一份绑定声明”拆开我们真正想稳定的是用户名校验、登录用例、错误映射和请求有效期而不是某个输入框的类名。可以先把这些逻辑提到不引用引擎的应用对象再让 UGUI ViewModel/Presenter 和测试驱动分别调用它。实际项目中 FUI Presenter 接应用服务生成 ViewModel 表达状态下面只用同步用户名校验与请求快照演示边界不实现认证服务也不声称这一小段取代完整 FUI 绑定系统。下面的 LoginLogic、ILoginPort、LoginConnection 都是本教程新增的普通 C# 示例放在同一个文件即可。不需要 FUI 生成器也不使用伪造的 FUI API。提交只记录规范化后的用户名真实网络请求需另行处理取消、错误和过期响应。usingSystem;publicsealedclassLoginLogic{publicstringUserName{get;privateset;};publicstringError{get;privateset;}请输入用户名;publicboolCanSubmitError.Length0;publicstringLastSubmitted{get;privateset;}publicintSubmitCount{get;privateset;}publiceventActionChanged;publicvoidSetUserName(stringvalue){UserName(value??).Trim();ErrorUserName.Length0?请输入用户名:;Changed?.Invoke();}publicvoidSubmit(){if(!CanSubmit)return;LastSubmittedUserName;SubmitCount;}}publicinterfaceILoginPort{eventActionstringUserEdited;eventActionSubmitRequested;voidRender(stringuserName,stringerror,boolcanSubmit);}publicsealedclassLoginConnection:IDisposable{readonlyLoginLogiclogic;readonlyILoginPortport;booldisposed;publicLoginConnection(LoginLogiclogic,ILoginPortport){this.logiclogic??thrownewArgumentNullException(nameof(logic));this.portport??thrownewArgumentNullException(nameof(port));// 例中两个端口的事件访问器不会抛异常。port.UserEditedEdit;port.SubmitRequestedSubmit;logic.ChangedRefresh;try{Refresh();}catch{Dispose();throw;}}voidEdit(stringvalue){if(!disposed)logic.SetUserName(value);}voidSubmit(){if(!disposed)logic.Submit();}voidRefresh(){if(!disposed)port.Render(logic.UserName,logic.Error,logic.CanSubmit);}publicvoidDispose(){if(disposed)return;disposedtrue;port.UserEdited-Edit;port.SubmitRequested-Submit;logic.Changed-Refresh;}}这里有意不用一个公开的 Root 属性。连接器只能订阅用户意图、渲染状态既无法销毁页面也无法随便遍历子节点。Dispose 只释放自己建立的三条订阅不销毁端口或业务对象因为两者都是外部传入所有权没有转交给连接器。还要留意示例的适用范围事件在同一 UI 线程调用端口实现的加减订阅不抛异常如果扩展者提供自定义事件访问器部分订阅失败就需要逐项登记并回滚。初次 Render 抛错的情况已在构造中回滚但这不等于任意外部代码异常都自动安全。第一种展示真正连接 UGUI而不是只画一个假接口把下列脚本保存为 UguiLoginPort.cs挂到登录面板拖入用户名 InputField、错误 Text、提交 Button。这是教学用手写 UGUI 适配器不是 FUI 内置 Element。它允许在展示边界持有 GameObject/组件限制的是业务层不是禁止所有代码使用引擎。usingSystem;usingUnityEngine;usingUnityEngine.UI;publicsealedclassUguiLoginPort:MonoBehaviour,ILoginPort{[SerializeField]InputFielduserName;[SerializeField]Texterror;[SerializeField]Buttonsubmit;LoginConnectionconnection;publiceventActionstringUserEdited;publiceventActionSubmitRequested;voidAwake(){if(userNamenull||errornull||submitnull)thrownewInvalidOperationException(登录控件引用不完整);userName.onValueChanged.AddListener(OnEdited);submit.onClick.AddListener(OnSubmitted);}// 演示用组合入口。正式 FUI 项目由页面生命周期拥有连接。publicvoidConnect(LoginLogiclogic){connection?.Dispose();connectionnull;connectionnewLoginConnection(logic,this);}publicvoidDisconnect(){connection?.Dispose();connectionnull;}publicvoidRender(stringvalue,stringmessage,boolcanSubmit){userName.SetTextWithoutNotify(value);error.textmessage;submit.interactablecanSubmit;}voidOnEdited(stringvalue)UserEdited?.Invoke(value);voidOnSubmitted()SubmitRequested?.Invoke();voidOnDestroy(){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 或真实触摸输入已经通过测试。usingSystem;publicsealedclassFakeLoginPort:ILoginPort{publiceventActionstringUserEdited;publiceventActionSubmitRequested;publicstringName{get;privateset;}publicstringError{get;privateset;}publicboolEnabled{get;privateset;}publicintRenders{get;privateset;}publicvoidInput(stringvalue)UserEdited?.Invoke(value);publicvoidClick()SubmitRequested?.Invoke();publicvoidRender(stringvalue,stringerror,boolcanSubmit){Namevalue;Errorerror;EnabledcanSubmit;Renders;}}将上述纯 C# 类型与下面测试放进引用 NUnit 的测试工程即可运行。本文提供测试代码和预期断言本次没有运行 Unity 或 NUnit不把预期写成已通过的结果。usingNUnit.Framework;publicsealedclassLoginBoundaryTests{[Test]publicvoidInputSubmitAndClose_HaveExplicitBoundaries(){varlogicnewLoginLogic();varportnewFakeLoginPort();varlinknewLoginConnection(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));varrendersport.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]publicvoidReconnect_DoesNotKeepOldSubscriptions(){varlogicnewLoginLogic();varportnewFakeLoginPort();varfirstnewLoginConnection(logic,port);first.Dispose();using(varsecondnewLoginConnection(logic,port)){port.Input(Alice);port.Click();Assert.That(logic.SubmitCount,Is.EqualTo(1));}}}注意测试不只断言正常提交还强行从禁用状态发出点击。控件不可交互是展示反馈不是业务合法性的唯一保证。关闭之后既测试“用户事件不再改模型”也测试“模型变化不再渲染旧页面”这两条清理方向缺一不可。为什么业务层不应该拿到底层 View开发者拿到GameObject后可以直接SetActive(false)、改父节点、Destroy、寻找任意子节点。这些操作很方便也会绕过 Navigator 的历史、缓存、绑定和资源所有权。框架易用性的一个反直觉部分是主动减少调用者权限。以嵌套投影为例FUI 当前的ProjectionHandleTViewModel公开的业务能力为ViewModelIsAliveActivate/DeactivateDispose。它不公开底层 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 如何创建、激活和释放 ViewTransition 如何操作透明度、位置和输入特定控件的 Element 适配器。不应该被迫改变的包括独立于具体绑定声明的业务逻辑与展示状态语义已有具体 UGUI 类型的绑定声明仍可能需要适配typed Route 与 RoutePolicyNavigator 的历史、遮挡、缓存、依赖和句柄语义资源 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 的证据。资料与源码索引FUIFUI.Core.asmdefFUIFUI.Navigation.asmdefFUIIElement.csFUIIView.csFUIViewExtensions.csFUIProjectionHandle.csFUIViewHandle.csFUIInputFieldElement.csFUIContextInfoByAttributeGenerator.csFUIBindingContextGenerator.csFUIpresenter.md排查解耦程度时分别检查程序集引用、生成字段类型和释放责任。普通对象测试、FUI契约测试、真实场景测试应各自证明自己的范围。下一篇预告让错误在进游戏前暴露结合生成诊断、Prefab 验证与四层测试排查节点、类型和绑定边界错误。第 14 篇发布后补充同平台跳转。