ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

UGUI登录页解耦实践:FUI职责边界、测试替身与权限边界落地

UGUI登录页解耦实践:FUI职责边界、测试替身与权限边界落地 上周调一个登录页卡死的问题我断点进去之后沉默了五分钟。一个登录按钮的点击事件里从上到下塞了字符串校验、PlayerPrefs 存档、发起网络请求、回调里切 UI、埋点上报、场景跳转六七种职责挤在一个方法里。这种代码谈不上什么 FUI 展示层解耦出事只是时间问题。我当天就决定拿登录页当试验田把 UGUI 的职责边界、测试替身、权限边界一次验证到位于是有了这篇实践记录。如果你也是做 Unity 客户端的大概率见过类似的登录页看起来能跑但改需求时心惊胆战想加个单元测试发现根本无从下手。这篇文章会用登录页这个最小单位拆解一套可落地的 UI 分层方案适合正在被 UI 代码腐化问题困扰、想给展示层建立测试防线和权限边界的朋友。1. 一个登录按钮里的七重职责成了解耦试验田1.1 问题现场登录页卡死背后的代码结构先看一段相当典型的“反面教材”我把那天线上卡死的关键代码简化到下面这个程度public void OnLoginButtonClicked() { var userName accountInput.text; var password passwordInput.text; if (string.IsNullOrEmpty(userName)) { tipText.text 请输入账号; return; } if (string.IsNullOrEmpty(password)) { tipText.text 请输入密码; return; } loginButton.interactable false; loadingRoot.SetActive(true); StartCoroutine(PostLoginRequest(userName, password, result { loadingRoot.SetActive(false); if (result.Code 0) { PlayerPrefs.SetString(last_user, userName); PlayerPrefs.Save(); DingTalkManager.Report(user_login, userName); LoadMainScene(); } else { tipText.text result.Message; loginButton.interactable true; passwordInput.text string.Empty; } })); }这段代码在当时能跑通真机上却出现了偶发卡死。定位下来问题出在回调里直接改 UI、直接调 PlayerPrefs 和场景加载几个操作在底层的时序上互相干扰。不过卡死只是表象真正的问题远比卡死严重这个方法把展示、校验、存储、埋点、路由全部耦合到了一起。把这段代码按职责拆开看漏洞一目了然代码段实际职责本应归属空值校验业务规则领域逻辑或应用协调层PlayerPrefs 记录账号本地存储应用服务DingTalkManager.Report数据上报基础设施LoadMainScene路由跳转应用协调器回调里改 tipText状态渲染展示层状态流1.2 解耦要回答的三个问题这类代码之所以大量存在是因为 Unity 的 UGUI 允许你一句话就把业务逻辑绑死在 Button 上没有人拦你。等回过神想重构又不知道该从哪一层开始拆。我给自己定了三个必须回答的问题UGUI 和上层业务之间到底应该由谁规定“点击按钮之后要做什么”后端接口还没就绪或者测试环境没有真实服务器时UI 逻辑怎么验证拆完了之后怎么防止新人或者三个月后的自己又把代码写回一坨这三个问题分别对应标题里的 UGUI、测试替身、权限边界。而登录页是所有 UI 页面里业务流程最简单、却又最容易写烂的页面正好拿来做验证。2. FUI 与 UGUI 的分工UI 只负责翻译和渲染2.1 UGUI 的职责边界以及它对事件派发的底层约束先说一个容易被忽略的事实UGUI 本身是一套输入事件处理和控件渲染系统。从底层看EventSystem 每一帧驱动 StandaloneInputModule向场景里所有 GraphicRaycaster 发出射线检测命中 UI 元素后通过 ExecuteEvents 派发 IPointerClickHandler、ISubmitHandler 这类接口回调。换句话说Button.onClick 能触发本质是引擎帮你做了一次“射线命中某个图形然后广播点击事件”的流程。它并不知道也不关心这个点击在业务上代表“提交登录”“切换服务器”还是“同意隐私协议”。如果业务代码直接写在 onClick 回调里等于把业务决策写死在输入系统的冒泡结果上。后续的问题是连锁的你要给登录按钮加埋点直接在回调里加要限制重复提交也在这个回调里加要支持回车键触发登录又把 KeyCode 判断塞进来。按钮最终会膨胀成一个什么都干的上帝方法。所以我在这次的 FUI 设计里给 UGUI 划了一条非常明确的线UGUI 负责控件创建、布局、接收原生输入事件、视觉表现。FUI 展示层负责把 UGUI 控件上发生的原生事件翻译成一种“不带控件概念”的语义命令同时把上层下发的状态翻译成控件属性。2.2 把控件事件翻译成语义化命令改造后的登录页 View 代码大概是这个样子public sealed class LoginView : FUIViewLoginViewState { [SerializeField] private InputField accountInput; [SerializeField] private InputField passwordInput; [SerializeField] private Button confirmButton; [SerializeField] private Text messageText; protected override void OnInit() { confirmButton.onClick.AddListener(() { var command new LoginByPasswordCommand( accountInput.text.Trim(), passwordInput.text); Publish(command); }); } protected override void OnStateChanged(LoginViewState state) { confirmButton.interactable !state.IsBusy; messageText.text state.Message; accountInput.text state.AccountHint; passwordInput.text string.Empty; } }注意一下onClick 回调里只做了一件事把 InputField 里的原始字符串包装成一个语义化命令 LoginByPasswordCommand然后通过 Publish 发出去。这里没有校验没有网络调用没有 PlayerPrefs没有场景切换。命令本身就是一个不可变的小结构体public readonly struct LoginByPasswordCommand { public string Account { get; } public string Password { get; } public LoginByPasswordCommand(string account, string password) { Account account; Password password; } }这么做的核心价值在于上层逻辑可以完全不知道界面上用的是 InputField 还是 TextMeshPro Input是点击按钮发出的命令还是按回车发出的命令。Button 只是“用户想要登录”这个意图的一个输入来源而不是意图本身。2.3 状态驱动渲染替代回调里直接改 UI很多 UI 代码难测的另一个原因是回调里直接访问控件属性导致控件状态和业务状态没有明确的对应关系。你无法在不挂载真实场景、不做真实输入的情况下回答“登录失败时按钮到底是什么状态”这个问题。我在这套方案里引入了不可变状态对象 LoginViewStatepublic readonly struct LoginViewState { public bool IsBusy { get; init; } public string Message { get; init; } public string AccountHint { get; init; } public static LoginViewState Empty new LoginViewState(); }View 只做一件事收到新的 state渲染到控件上。这很像前端圈流行的单向数据流在 Unity 里同样适用。原来回调里同时改 loadingRoot、loginButton.interactable、tipText 的逻辑全部收敛成一段“根据状态更新控件”的代码。而且状态不可变意味着不用担心多个回调并发修改 UI 属性导致状态不确定。3. 授权链路怎么写契约、状态机与命令流3.1 登录功能的四层结构解耦不是把代码到处乱放而是给每一行代码安排一个明确的归属。登录页重构后分了四层每一层只允许跟相邻层对话界面层LoginView只做事件翻译和状态渲染。应用协调层LoginCoordinator接收命令驱动状态机调用服务。领域/服务层ILoginService 的实现负责真实登录、参数校验。基础设施层网络 API、本地存储服务层依赖的具体实现。这四层对应的程序集关系我会在权限边界部分展开。这里先看契约接口它是整个解耦能否成立的支点public interface ILoginService { TaskLoginResult LoginAsync(LoginCredentials credentials, CancellationToken ct); } public readonly struct LoginCredentials { public string Account { get; } public string Password { get; } } public readonly struct LoginResult { public bool Success { get; } public int ErrorCode { get; } public string Message { get; } public string DisplayName { get; } }ILoginService 里没有任何 UGUI 类型没有 MonoBehaviour没有 UnityEngine.UI 的引用。这意味着它可以在纯 C# 环境里被测试可以被另一套实现无缝替换也可以被更上层的业务模块复用——比如自动登录流程也调用同一个接口只是换一种凭据来源。3.2 折叠起来的登录状态机登录过程看起来只有一个按钮实际上是一台微小的状态机。public enum LoginStep { Idle, LoginPending, Succeeded, FailedNeedRetry, Cancelled }正常流程是状态界面表现按钮可用性Idle显示账号输入框可点击LoginPending显示 loading隐藏输入框敏感信息禁止点击Succeeded显示欢迎语准备跳转禁止点击FailedNeedRetry显示错误文案清空密码可点击Cancelled回到 Idle可点击LoginCoordinator 是这个状态机的唯一驱动者代码逻辑大致如下public sealed class LoginCoordinator { private readonly ILoginService loginService; private readonly CancellationTokenSource cts new CancellationTokenSource(); private LoginStep step LoginStep.Idle; public LoginCoordinator(ILoginService loginService) { this.loginService loginService; } public async Task ExecuteAsync(LoginByPasswordCommand command) { if (step LoginStep.LoginPending) return; // 防重入 step LoginStep.LoginPending; Render(new LoginViewState { IsBusy true, Message 登录中... }); var credentials new LoginCredentials(command.Account, command.Password); var result await loginService.LoginAsync(credentials, cts.Token); if (result.Success) { step LoginStep.Succeeded; Render(new LoginViewState { IsBusy false, Message 登录成功 }); return; } step LoginStep.FailedNeedRetry; Render(new LoginViewState { IsBusy false, Message result.Message, AccountHint command.Account }); } private void Render(LoginViewState state) { ViewStateChanged?.Invoke(state); } public event ActionLoginViewState ViewStateChanged; }从命令发起到界面反馈完整链路是用户在 LoginView 点击按钮。View 构造 LoginByPasswordCommand 并 Publish。LoginCoordinator 接收到命令检查当前状态是否允许执行。调用 ILoginService.LoginAsync。根据结果计算新状态。View 收到状态变化统一更新控件。3.3 防重入和取消是状态机的隐含需求真实项目里登录按钮最常见的 bug 就是用户可以连续点击导致同一账号发出多个登录请求。在重构前的代码里这个问题的处理方式是改 loginButton.interactable。一旦状态分散在多个回调里这里设了 false另一个分支又设回 true很容易出错。状态机的做法更严谨无论 UI 状态如何LoginCoordinator 自己先挡住重复命令。只要 step 是 LoginPending后续命令全部忽略。这样即使未来有人绕过 UI 直接调用 ExecuteAsync也不会造成重复请求。取消的问题同样值得注意。Task 异步回来的时候页面可能已经跳转或者销毁了。如果 View 不在了Render 事件依然触发轻则空引用重则把状态写进一个即将销毁的界面。处理方式是给每个登录流程挂一个 CancellationTokenSourceView 销毁时主动调用 Cancelprotected override void OnClose() { coordinator.Cancel(); base.OnClose(); }Cancel 之后LoginAsync 内部会抛出 OperationCanceledExceptionLoginCoordinator 的状态机会进入 Cancelled不再继续渲染。4. 测试替身没有服务器也能把登录场景测明白4.1 UI 逻辑难测的病根做 Unity 开发的同学对“UI 不好测”应该深有体会。难点通常来自这三处逻辑粘在 MonoBehaviour 生命周期里编辑器外跑不了。依赖真实网络、服务器配置、数据库测试环境几小时都搭不起来。回调里直接操作控件断言很困难总不能真的去模拟鼠标点击。改造之后登录页的核心逻辑已经不在 View 里而转移到了 LoginCoordinator。LoginCoordinator 是纯 C# 类不依赖 UnityEngine只依赖 ILoginService 接口。这就为测试替身创造了条件。所谓测试替身Test Double就是在测试里用一个受控的假实现替换真实依赖。后端登录接口没有 ready 时我们不需要等后端真实网络不稳定我们也不需要真实网络。只要替身遵守 ILoginService 的契约测试就能跑。4.2 Stub / Fake / Mock 在登录场景里的分工按我自己的使用习惯登录场景下三种替身各司其职类型登录场景用法适用目标Stub返回预设的登录成功/失败结果验证 Coordinator 在不同结果下是否产生正确的 View 状态Fake内存版账号库本地就能“假登录”开发期 Demo、自动化冒烟测试Mock记录调用次数和传入参数验证防重入逻辑、参数传递是否正确Stub 是最常用的。登录成功后 Coordinator 是否把状态改成“非忙碌”登录失败后是否显示错误文案这些场景用一个 Stub 就能覆盖。public sealed class StubLoginService : ILoginService { public LoginResult Result { get; set; } public int CallCount { get; private set; } public string ReceivedAccount { get; private set; } public TaskLoginResult LoginAsync(LoginCredentials credentials, CancellationToken ct) { CallCount; ReceivedAccount credentials.Account; return Task.FromResult(Result ?? new LoginResult(false, -1, 未配置测试结果, string.Empty)); } }4.3 单元测试示例三种核心登录分支用 Unity Test Framework 的 EditMode 就能跑这些用例不需要进入 PlayMode。成功分支[Test] public async Task 登录成功_状态变为非忙碌_并携带欢迎信息() { var stub new StubLoginService { Result new LoginResult(true, 0, ok, Alice) }; var coordinator new LoginCoordinator(stub); LoginViewState latest default; coordinator.ViewStateChanged state latest state; await coordinator.ExecuteAsync(new LoginByPasswordCommand(alice, 123456)); Assert.IsFalse(latest.IsBusy); StringAssert.Contains(Alice, latest.Message); }失败分支[Test] public async Task 登录失败_状态位非忙碌_展示错误信息() { var stub new StubLoginService { Result new LoginResult(false, 1001, 密码错误, string.Empty) }; var coordinator new LoginCoordinator(stub); LoginViewState latest default; coordinator.ViewStateChanged state latest state; await coordinator.ExecuteAsync(new LoginByPasswordCommand(alice, wrong)); Assert.IsFalse(latest.IsBusy); Assert.AreEqual(密码错误, latest.Message); }防重入分支[Test] public async Task 连续发送两次登录命令_只调用一次服务() { var stub new StubLoginService { Result new LoginResult(true, 0, ok, Alice) }; var coordinator new LoginCoordinator(stub); var first coordinator.ExecuteAsync(new LoginByPasswordCommand(alice, 123456)); var second coordinator.ExecuteAsync(new LoginByPasswordCommand(alice, 123456)); await Task.WhenAll(first, second); Assert.AreEqual(1, stub.CallCount); }这三个用例加起来不到两百行却把登录页最核心的三条分支全部锁死。以后谁把防重入逻辑删了或者把失败分支的错误文案改掉测试会立即报警。4.4 测试替身使用边界测试替身虽好但有一个很常见的反模式把替身写得太聪明。比如在 Stub 里加逻辑根据输入参数动态返回不同结果。这样做的后果是测试失败时你根本分不清是产品逻辑坏了还是替身行为变化了。我踩过这个坑之后给自己定了条规矩Stub 只做简单数据容器输入什么返回什么Fake 可以用来实现简单业务行为但只放在独立测试程序集里Mock 只记录交互不做断言。断言永远放在测试用例里不放替身里。另外替身和真实实现必须同时遵守 ILoginService 的契约。契约不仅仅是签名还包括行为约定。例如“异步方法必须可取消”如果替身完全没有处理 CancellationToken就测不出取消场景的代码问题。5. 权限边界不是嘴上说说程序集、可见性与代码巡检落地5.1 asmdef 之间的关系设计“权限边界”这个词在 UI 解耦的场景里不是指服务器权限而是指代码结构上“谁允许访问谁”的约定。如果只靠团队约定三个月后就没人遵守了。必须借助 Unity 的程序集定义Assembly Definition把它变成编译期约束。我划分了四个程序集程序集存放内容引用方向GameApp.ContractLoginByPasswordCommand、LoginViewState、ILoginService 等契约不引用任何 UI 相关程序集FUI.RuntimeFUIView 基类、事件发布、状态分发框架引用 ContractGameApp.Features.LoginLoginCoordinator、LoginService 实现引用 ContractGameApp.CompositionRootView 和 Service 的绑定注册引用以上全部关键在 GameApp.Features.Login 的 asmdef 配置。它只能引用 GameApp.Contract不能引用 UnityEngine.UI。这样就强制了 LoginService 的实现无法直接操作 InputField、Button 这些控件。{ name: GameApp.Features.Login, rootNamespace: GameApp.Features.Login, references: [ GameApp.Contract ], includePlatforms: [], noEngineReferences: false, autoReferenced: true }5.2 用 C# 可见性把实现藏起来程序集引用只是第一道防线。同一个程序集内部类与类之间还需要可见性控制。LoginService 的具体实现类完全没必要被程序集外部看到所以声明为 internalinternal sealed class LoginService : ILoginService { private readonly IAuthApi authApi; private readonly IAccountLocalStore localStore; public LoginService(IAuthApi authApi, IAccountLocalStore localStore) { this.authApi authApi; this.localStore localStore; } public async TaskLoginResult LoginAsync(LoginCredentials credentials, CancellationToken ct) { // 本地校验 // 远端登录 // 落账如果不希望 UI 感知就放在这里 } }对外只有 ILoginService 是 public。想手动 new 一个 LoginService 的人会直接编译失败。如果测试程序集需要访问 internal 类型做集成测试可以在 AssemblyInfo.cs 里配置[assembly: InternalsVisibleTo(GameApp.Features.Login.Tests)]这个机制让“谁可以看见实现细节”完全可控。生产代码看不到 internal 实现测试代码可以外部代码无法触碰。5.3 轻量级代码巡检补上最后一道防线程序集和可见性解决的是“编译期能否引用”但解决不了“开发过程中会不会绕路”。比如有人图省事在 View 里直接 new 了一个 HttpClient或者调了 PlayerPrefs 读账号。这些写法在程序集层面不一定违规但会让 View 重新变得难以测试。我在项目里加了一个非常轻量的代码巡检脚本挂在本地 Git 钩子上。它只做两件事检查 UGUI 相关程序集是否被非 UI 程序集意外引用。用正则扫描 Laws 文件里是否出现 PlayerPrefs、WWW、UnityWebRequest、Debug.Log 等基础设施调用。#!/bin/bash # 示例脚本检测 Features 层是否引用了 UnityEngine.UI FILES$(find GameApp.Features -name *.cs) REGEXUnityEngine\.UI|PlayerPrefs|UnityWebRequest for file in $FILES; do if grep -nE $REGEX $file; then echo 违规引用: $file exit 1 fi done写这个脚本的初衷不是限制任何人写代码而是把“权限边界”变成可以自动验证的规则。越权调用的代码提交时会直接报错而不是等 CodeReview 时靠人眼去揪。6. 重构完登录页之后我踩过的坑和收敛6.1 事件爆炸与命令收敛刚开始把解耦做过头了一个登录页的交互几乎每个动作都发一个命令输入框获得焦点发一个命令失去焦点发一个命令光标移动都恨不得发一个命令。结果登录页的类数量从 5 个膨胀到 20 多个改一个输入框高亮逻辑要跨十几处代码。后来逐渐意识到命令不是廉价的它是系统行为的意图表达。我把命令的筛选标准改成一句话只有“改变系统走向”的交互才配成为命令。登录、重试、切换服务器、同意协议这些可以。输入框聚焦高亮、密码显隐切换、密码输入框里点击清除按钮这些属于纯视觉反馈内部消化就好。收敛之后类数量少了一半理解成本也降下来了。6.2 异步回调与 View 生命周期登录页重构后第一版踩了个典型的坑在 LoginView 里用 async void 直接处理登录回调页面还没跳转完异步结果已经回来了于是出现“界面还在 loading后台已经执行完登录成功”的混乱时序。问题本质上不是异步不能用而是没有给异步操作一个统一的归属。最后我坚持一个原则所有可能改变状态的异步操作都只能通过 LoginCoordinator 进入和退出View 不接触 Task。页面关闭时 Coordinator 统一 Cancel任何异步回调都走不到已销毁的控件上。如果团队里没有这样的强制约束异步和 UI 之间的竞态迟早会变成线上事故。6.3 测试替身写得太聪明我最早写的一个 Stub 会去判断“如果账号等于 admin 就返回成功否则失败”模拟了一套伪业务规则。第一次跑测试全绿后来需求变化导致真实登录逻辑改了stub 里的规则没同步积分测试红了一周。排查半天发现不是产品代码的问题而是 Stub 替身自己定义了一套行为。从此我接受了“替身是一种谎言”这个设定它的作用不是模拟真实世界而是提供一个确定性的答案。真正负责登录逻辑的是 LoginService 的单测Stub 只负责“如果服务返回成功/失败/取消Coordinator 行为是否正确”这三件事。测试替身的职责越少维护成本就越低。6.4 架构约束不能只靠文档做了这么多程序集、可视性和巡检脚本最重要的一个体会是如果没有强约束任何架构设计都会被“赶进度”击穿。一开始我在团队的 Wiki 里写了一篇《登录页分层规范》文档写得很漂亮但一周后有人就在 View 里直接读 PlayerPrefs——因为这样写最快。后来把约束下沉到了工具层情况才真正改观。asmdef 挡住编译期引用internal 挡住程序集外部访问Git 钩子挡住兜底的路径。文档依然需要但它不再是第一道防线。如果你也在做 UI 层解耦建议第一天就配置好 asmdef不要等代码写完再补。最后分享一个小技巧。每次重构完一个页面我会在提交说明里留一张文字版的“当前页面职责清单”把哪些是 View 的事、哪些是 Coordinator 的事、哪些是 Service 的事列清楚。后来任何接手登录页的同事都能在五分钟内定位问题从哪一层开始查。这个习惯比任何架构方法论都更值钱。
返回列表