UGUI登录页解耦实践:FUI职责边界、测试替身与权限边界落地
2026/9/18 1:58:12 网站建设 项目流程

上周调一个登录页卡死的问题,我断点进去之后沉默了五分钟。一个登录按钮的点击事件里,从上到下塞了字符串校验、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 : FUIView<LoginViewState> { [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 代码难测的另一个原因是:回调里直接访问控件属性,导致控件状态和业务状态没有明确的对应关系。你无法在不挂载真实场景、不做真实输入的情况下,回答“登录失败时,按钮到底是什么状态”这个问题。

我在这套方案里引入了不可变状态对象 LoginViewState:

public 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 { Task<LoginResult> 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 Action<LoginViewState> ViewStateChanged; }

从命令发起到界面反馈,完整链路是:

  1. 用户在 LoginView 点击按钮。
  2. View 构造 LoginByPasswordCommand 并 Publish。
  3. LoginCoordinator 接收到命令,检查当前状态是否允许执行。
  4. 调用 ILoginService.LoginAsync。
  5. 根据结果计算新状态。
  6. View 收到状态变化,统一更新控件。

3.3 防重入和取消,是状态机的隐含需求

真实项目里,登录按钮最常见的 bug 就是用户可以连续点击,导致同一账号发出多个登录请求。在重构前的代码里,这个问题的处理方式是改 loginButton.interactable。一旦状态分散在多个回调里,这里设了 false,另一个分支又设回 true,很容易出错。

状态机的做法更严谨:无论 UI 状态如何,LoginCoordinator 自己先挡住重复命令。只要 step 是 LoginPending,后续命令全部忽略。这样即使未来有人绕过 UI 直接调用 ExecuteAsync,也不会造成重复请求。

取消的问题同样值得注意。Task 异步回来的时候,页面可能已经跳转或者销毁了。如果 View 不在了,Render 事件依然触发,轻则空引用,重则把状态写进一个即将销毁的界面。处理方式是给每个登录流程挂一个 CancellationTokenSource,View 销毁时主动调用 Cancel:

protected override void OnClose() { coordinator.Cancel(); base.OnClose(); }

Cancel 之后,LoginAsync 内部会抛出 OperationCanceledException,LoginCoordinator 的状态机会进入 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 Task<LoginResult> 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 基类、事件发布、状态分发框架引用 Contract
GameApp.Features.LoginLoginCoordinator、LoginService 实现引用 Contract
GameApp.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 的具体实现类完全没必要被程序集外部看到,所以声明为 internal:

internal 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 Task<LoginResult> 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") REGEX="UnityEngine\.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 的事列清楚。后来任何接手登录页的同事,都能在五分钟内定位问题从哪一层开始查。这个习惯比任何架构方法论都更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询