# UI 五层架构设计规范(UseCase / RawData / Controller / Context / View) ## 1. 文档目标 本文定义一套可长期复用的 UI 分层设计方案,用于约束 UI 模块的职责边界、依赖方向、通信方式和测试策略。 - 本文描述的是规范,不是对某个项目现状的总结 - 项目内目录或基类只作为落地示例,不改变本文的抽象约束 - 本文重点约束 UIForm 级模块;子组件(`Item`、`Area` 等)可只实现 `Context + View` - Unity GameFramework 的底层细节不在本文展开,本文只约束项目内 UI 代码组织 补充说明(插件化落地): - 本规范通过 `Assets/Plugins/UIModule/` 以**可选插件**方式落地 - 插件无独立程序集,通过 `.asmref` 融入原项目各程序集: - `SepCore.Base.UIModule.asmref`:`Base/` → `SepCore.Base` - `SepCore.Runtime.UIModule.asmref`:`Runtime/` → `SepCore.Runtime` - `SepCore.Presentation.UIModule.asmref`:`UI/` → `SepCore.Presentation` - `Runtime/` 内 `SepCore.UI` 命名空间放置基础抽象、UseCase、RawData;`SepCore.CustomComponent` 放置 `UIRouterComponent` - `UI/` 内 `SepCore.UIModule` 命名空间放置 Controller / Context / View - 不启用插件时,项目可继续使用原有基座 UI 路线 - 启用插件时,必须通过 `UIRouterComponent` 统一管理 `Controller / UseCase` - 通过 `UIRouterComponent` 打开的 UIForm 必须配置对应 Controller,缺少绑定视为接入错误,不回退到原始 UI 打开路径 - 当前插件对外 UI 生命周期接口为 async-first:`OpenUIAsync / CloseUIAsync` ## 2. 核心原则 ### 2.1 单向数据职责 UI 的职责链路固定为: ```text 外部流程 -> Controller -> UseCase -> RawData / Result -> BuildContext -> View View --(UI 专用事件)--> Controller ``` 说明: - `UseCase` 只负责业务规则、状态推进和纯业务数据生成 - `Controller` 是 UI 编排中心,也是唯一允许构建 `Context` 的层 - `View` 只负责渲染和抛出交互,不直接处理业务状态 ### 2.2 严格分离业务数据与展示数据 - `RawData` 默认是业务传输模型,不承载展示模型。 - 轻量 UI 可携带回调委托,但回调只允许由 Controller 注册和触发,不得进入 Context / View。 - `Context` 是纯展示数据,不进入 `UseCase` - `UseCase` 不能返回 `Context` - `View` 只能消费 `Context` ### 2.3 Controller 是 UI 唯一外部入口 对于 UIForm 级模块,外部流程只能通过 `Controller` 驱动 UI: - 异步打开 UI - 异步关闭 UI - 绑定 `UseCase` - 刷新 UI - 响应 UI 专用事件 `View` 不作为外部流程的直接依赖对象。 ## 3. 五层职责定义 ### 3.1 UseCase 层 职责:封装 UI 对应的业务用例,负责业务规则、状态推进、校验和纯业务结果输出。 约束: - 实现 `IUIUseCase` - 命名:`XXXUseCase` - 对外提供语义化方法,例如 `CreateInitialModel`、`TryRefresh`、`Select`、`Confirm` - 返回值只能是 `RawData` 或纯业务结果对象,例如 `XXXResult`、`XXXActionResult` - 不依赖 `Context`、`View`、`UGuiForm`、`MonoBehaviour` 等 UI 类型 - 不负责 UI 资源加载、文本拼装、颜色选择、图标转换等展示处理 - 不发布 UI 专用事件 适用场景: - UI 会读写领域状态 - UI 存在明确业务规则、条件分支、校验或状态推进 - UI 的交互结果需要被测试和复用 ### 3.2 RawData 层 职责:承载 `UseCase -> Controller` 的纯业务传输模型。 约束: - 命名:`XXXRawData` - 只描述业务数据,不包含 UI 展示行为 - 可以包含领域对象、配置对象、标识符、枚举、数值和纯数据集合 - **轻量场景下可携带回调委托**,由 Controller 在构建 Context 前完成注册 - 不允许依赖 `Context`、`View`、`Sprite`、`TMP_Text` 等展示相关类型 - 不允许直接使用 `XXXItemContext`、`XXXContext` 作为字段类型 说明: - `RawData` 的目标是表达“业务上发生了什么” - `Context` 的目标是表达“界面应该怎么显示” - `RawData` 可以携带行为(如回调),`Context` 只承载展示数据,不允许携带回调 ### 3.3 Controller 层 职责:UI 编排层,负责连接外部流程、`UseCase`、`View`,并统一管理 UI 生命周期与展示状态。 约束: - 对启用 `UIRouterComponent` 管理的 UIForm,必须存在可实例化的 Controller 绑定;未绑定时 Router 应直接失败并输出 Error,不允许 fallback 到 `GameEntry.UI.OpenUIForm(...)` - 命名:`XXXFormController` - 可基于 `UIControllerBase` 实现 - 通过 `BindUseCase(IUIUseCase)` 注入用例并做类型校验 - 当前对外入口为 `OpenUIAsync(object userData = null, float timeout = 30f)` 与 `CloseUIAsync(...)` - `OpenUIAsync` 只允许外部传入 `RawData` 或可转换为 `RawData` 的参数,不接受外部传入 `Context` - 当 `userData == null` 且 UI 绑定了 UseCase 时,Controller 应通过 UseCase 构造初始 RawData,再转换为 Context - 当 `userData == null` 且 UI 没有 UseCase 时,Controller 可按 UI 类型决定构造默认 RawData / 默认 Context,或返回失败并输出 warning - 负责 `RawData / Result -> Context` 的转换,常见形式为 `BuildContext` - 负责事件订阅与解除订阅,且必须成对出现 - 负责全量刷新与局部刷新策略 - 在同类 UI 可能多实例时,负责通过 `sender`、`serialId` 或其他等价标识限制事件只作用于当前 UI 实例 - **推荐子类显式组织业务时序**:参数校验、Context 构造、临时状态缓存、关闭后续动作等都直接写在具体 Controller 中;基类只提供共用开关窗机制,不隐藏子类业务时机 允许职责: - 将业务数据转换为展示友好的文本、图标、颜色、列表状态 - 在必要时查询本地化、资源映射或展示适配逻辑 禁止职责: - 在 `Controller` 中堆叠大段领域业务规则 - 绕过 `Context` 直接把业务对象塞给 `View` - 直接修改其他 UI 的内部 `View` - **直接调用 View 的公开方法进行局部刷新**;应通过更新 `Context` 并设置刷新粒度控制字段,再调用 `RefreshUI` 让 View 自行决定刷新内容 ### 3.4 Context 层 职责:承载“可直接驱动 UI 展示”的上下文数据。 约束: - 继承 `UIContext` - 命名:`XXXContext`、`XXXItemContext`、`XXXAreaContext` - 只能由 `Controller` 构建和更新 - 字段以展示友好为目标,例如标题、描述、图标、颜色、状态、列表、按钮文案 - **不允许携带回调委托或行为**,交互行为由 Controller 注册,View 通过 UI 专用事件通知 Controller - 允许组合子 `Context` - 不进入 `UseCase` - **允许提供构造函数**,但只能由对应的 `Controller` 调用,用于封装从 `RawData` 到展示数据的转换逻辑 - **允许提供刷新粒度控制字段**(如 `NeedRefreshXxx` 布尔字段),用于支持局部刷新;View 根据这些字段决定刷新哪些部分,Controller 在更新数据后设置对应字段并调用 `RefreshUI` 说明: - `Context` 可以包含展示层需要的最终数据 - `Context` 可以是“已格式化”的显示数据 - 但这些数据必须由 `Controller` 负责准备,而不是 `UseCase` - `Context` 的构造函数可以包含轻量的展示逻辑转换(如格式化文本、选择图标),但不应包含复杂业务规则 - 刷新粒度控制字段的典型用法:Controller 更新 Context 数据后,设置 `NeedRefreshXxx = true`,然后调用 `RefreshUI`;View 在 `RefreshUI` 中检查这些字段,只刷新需要更新的部分,最后将字段重置为 `false` ### 3.5 View 层 职责:纯表现层,负责控件绑定、渲染刷新、动画触发和交互事件抛出。 约束: - Form 类继承 `UGuiForm`,子组件通常继承 `MonoBehaviour` - 命名:`XXXForm`、`XXXItem`、`XXXArea` - 提供打开、关闭、刷新入口: - `UGuiForm`:`OnOpen(object userData)`、`OnClose(bool isShutdown, object userData)`、`RefreshUI(Context)` - `MonoBehaviour` 子组件:可统一设计为 `OnOpen(Context)`、`OnClose()`、`RefreshUI(Context)` - 只消费 `Context` - 用户交互通过 UI 专用事件通知 `Controller` - 不承载业务规则、流程推进、数据筛选和领域状态修改 - 不订阅全局业务事件 允许职责: - 本地控件显隐 - 动画播放 - 一次性的纯视觉状态缓存 禁止职责: - 直接调用 `UseCase` - 直接修改领域状态 - 订阅或处理全局业务事件 - 将自己作为业务逻辑的入口 ## 4. UI 类型分级 ### 4.1 标准五层 UI 组成:`UseCase + RawData + Controller + Context + View` 适用条件: - UI 需要读写领域状态 - UI 存在明确业务规则或分支 - UI 交互会改变游戏流程、角色状态、背包、战斗结果等业务对象 - UI 行为需要被自动化测试覆盖 默认规则: - 新增业务型 UI,优先使用完整五层 ### 4.2 轻量 UI 组成:`Controller + Context + View` 说明: - `UseCase` 对轻量 UI 不强制 - `RawData` 也不是强制层,可按需要补充 - 轻量 UI 仍然必须通过 `Controller` 驱动,不能让 `View` 直接承担外部入口 适用条件: - 只承担展示、导航、确认、提示等轻量职责 - 没有独立的业务规则或状态推进 - 只需要把已有参数转换成界面展示 升级规则: - 一旦轻量 UI 开始承载业务规则、校验或状态推进,应升级为标准五层 UI ## 5. 依赖方向约束 允许依赖: - `UseCase -> 领域对象 / 纯业务服务 / RawData / Result` - `Controller -> UseCase + RawData + Result + Context + View + UI 专用事件` - `Context -> 子 Context / 纯展示值对象` - `View -> Context + UI 专用事件` 禁止依赖: - `UseCase -> Context / View / Unity 具体展示组件` - `RawData / Result -> Context / View` - `Context -> View / UseCase` - `View -> UseCase` - `View -> 全局业务事件` - `View -> 领域状态修改` ### 5.1 插件化分层建议 建议分层如下: - `SepCore.Base`:UI 专用事件(`SepCore.Event`) - `SepCore.Runtime`:基座与业务流程层,同时融入插件 `Runtime/` 中的基础抽象、UseCase、RawData 与 `UIRouterComponent` - `SepCore.Presentation`:Controller、Context、View(`SepCore.UIModule`) - Editor:`UIModule.Editor`,代码命名空间当前为 `SepCore.UIModule.Editor` 插件目录与程序集映射: - `Base/` → `SepCore.Base` - `Runtime/` → `SepCore.Runtime` - `UI/` → `SepCore.Presentation` 建议依赖方向: ```text SepCore.Base(UI 专用事件) ↑ SepCore.Runtime(基础抽象 + UseCase + RawData + Router) ↑ SepCore.Presentation(Controller + Context + View) ``` 约束: - `SepCore.Base` / `SepCore.Runtime` 不反向依赖 Presentation 层 - UI 专用事件虽在 `SepCore.Event` 命名空间,但语义上仍归属对应 UI 模块,禁止被业务模块复用 - 业务流程层通过插件入口(如 Router)调用五层 UI 能力 - 插件内无独立程序集,通过 `.asmref` 文件融入原项目编译 ## 6. 事件通信规范 ### 6.1 UI 与 Controller 的通信方式 `View` 与 `Controller` 的通信通过当前 UI 模块专用事件完成。 约束: - UI 专用事件只服务于当前 UI 模块 - 这些事件不是业务公共事件 - 业务模块、流程模块、领域模块不应复用这些事件 - UI 专用事件可统一放在 `SepCore.Event` 命名空间下,但事件语义仍归属对应 UI 模块,禁止被业务模块复用 - 如果底层使用全局事件总线实现,也只能把它当作“传输通道”,不能把事件语义扩散成全局契约 ### 6.2 事件边界 - `View -> Controller`:使用 UI 专用事件 - `Controller -> View`:通过刷新 `Context` 或调用 `View` 的渲染接口 - `Controller -> UseCase`:直接方法调用 - `UseCase -> Controller`:通过返回 `RawData / Result`,不通过 UI 事件反推界面 - `外部流程 -> Controller`:只传入 `RawData` 或可转换为 `RawData` 的参数,不传入 `Context` ### 6.3 事件安全要求 - 同类 UI 可同时存在时,事件必须只作用于当前窗体实例;可通过 `sender`、`serialId` 或其他等价标识实现 - UI 专用事件命名应体现模块归属,避免语义过宽 - 同一 UI 可以按需要使用“多个精细事件”或“单一事件 + 子类型/按钮编号”的方式建模;例如当前 Dialog 使用 `DialogEventArgs + ButtonId` ### 6.4 UseCase 与编排层(Procedure)的通信方式 **推荐方式:接口式引用(主要方式)** UseCase 与编排层(Procedure)的通信采用 **依赖倒置原则(DIP)**,通过接口进行解耦。 #### 核心模式 ```text Procedure (编排层) ↓ 实现接口 IProcedureXXX ↓ 注入(构造函数传 this 作为接口) UseCase ↓ 通过接口回调 Procedure.ConfirmXXX(...) ``` #### 实现规范 1. **定义接口契约** - 命名:`IProcedureXXX`(`XXX` 对应用户或 UI 名称) - 位置:`Runtime/ProcedureInterface/` 目录 - 内容:只包含 UI 真正需要的方法,遵循接口隔离原则(ISP) ```csharp // 示例:IProcedureMenu.cs namespace SepCore.Procedure { public interface IProcedureMenu { void ConfirmSelectRole(int roleId); } } ``` 2. **Procedure 实现接口** - Procedure 类实现对应接口 - 在 `OnEnter` 中创建 UseCase 并注入自身(`this`) - 接口方法中实现流程编排(状态变更、场景切换、数据传递等) ```csharp // 示例:ProcedureMenu.cs public class ProcedureMenu : ProcedureBase, IProcedureMenu { private bool _startGame = false; private int _selectedRoleId = 0; public void ConfirmSelectRole(int roleId) { _selectedRoleId = roleId; _startGame = true; } protected override void OnEnter(ProcedureOwner procedureOwner) { base.OnEnter(procedureOwner); var useCase = new SelectRoleUseCase(this); // 注入接口 GameEntry.UIRouter.BindUIUseCase(UIFormType.SelectRoleForm, useCase); } } ``` 3. **UseCase 接收接口** - UseCase 构造函数只接收接口类型,不直接依赖具体 Procedure - 业务完成时通过接口回调编排层 - UseCase 内部不保存 Procedure 的具体引用 ```csharp // 示例:SelectRoleUseCase.cs public class SelectRoleUseCase : IUIUseCase { private readonly IProcedureMenu _procedureMenu; // 依赖接口,而非具体类 public SelectRoleUseCase(IProcedureMenu procedureMenu) { _procedureMenu = procedureMenu; } public bool ConfirmSelectedRole() { // ... 业务校验逻辑 ... _procedureMenu.ConfirmSelectRole(SelectedRoleId); // 通过接口回调 return true; } } ``` #### 接口式引用的核心优势 | 优势 | 说明 | |------|------| | **依赖倒置** | UseCase 依赖抽象接口,而非具体实现,符合 DIP 原则 | | **接口隔离** | 接口只暴露 UI 需要的能力,Procedure 内部能力不泄露 | | **可测试性** | 单元测试时可用 Mock 实现接口,无需启动整个 Procedure | | **复用性** | 不同 Procedure 可实现同一接口,复用同一个 UseCase | | **边界清晰** | 明确定义 UI 对编排层的操作权限,避免 UseCase 越权 | | **可扩展性** | 新增回调方法时直接在接口添加,无需修改构造函数签名 | #### 备选方式:委托回调(快速实现) 对于简单场景或快速原型,可使用 `Action` / `Func` 委托作为轻量替代: ```csharp // 委托方式示例(适用于单一回调场景) public class LevelUpUseCase : IUIUseCase { private readonly Action _onCompleted; public LevelUpUseCase(Player player, Action onCompleted) { _onCompleted = onCompleted; } } ``` **适用场景**: - 只有 1-2 个简单回调 - 临时功能或快速原型 - 回调逻辑不需要复用 **注意**:当回调超过 2 个或语义复杂时,应升级为接口方式。 #### 决策矩阵 | 场景 | 推荐方式 | |------|----------| | 复杂流程,多个回调点 | 接口式引用 ✅ | | 需要单元测试 UseCase | 接口式引用 ✅ | | 多个 Procedure 复用同一 UI | 接口式引用 ✅ | | 单一简单回调(如关闭通知) | 委托回调 | | 临时功能或快速原型 | 委托回调 | | 回调参数复杂(多参数、泛型) | 接口式引用 ✅ | ## 7. 标准交互流程 ### 7.1 有 UseCase 的标准流程 ```text Procedure / GameState -> BindUseCase -> await OpenUIAsync(rawData or null) Controller -> 如果外部传入 RawData:直接使用该 RawData -> 如果外部未传入 RawData:UseCase.BuildRawData() / CreateInitialModel() -> BuildContext(rawData) -> View.RefreshUI(context) View --(UI 专用事件)--> Controller Controller -> UseCase.Action(...) -> Result / RawData -> BuildContext / PartialRefresh -> View.RefreshUI(...) ``` 说明: - 外部流程不传入 `Context` - `Context` 只在 Presentation 层内由 Controller 构建 - `OpenUIAsync(null)` 对有 UseCase 的 UI 是合法入口,表示由 UseCase 构造初始 RawData ### 7.2 无 UseCase 的轻量流程 ```text 外部流程 -> await OpenUIAsync(rawData) Controller -> BuildContext(rawData) -> View.RefreshUI(context) View --(UI 专用事件)--> Controller Controller -> 处理轻量逻辑或路由动作 -> 更新 Context / 打开其他 UI / 关闭当前 UI ``` 说明: - 轻量 UI 如果没有默认数据来源,`OpenUIAsync(null)` 可以失败并输出 warning - Dialog 这类提示型 UI 属于必须由外部传入 RawData 的轻量 UI - 即使是轻量 UI,外部也不传入 `Context`,Context 仍由 Controller 构建 ### 7.3 关闭流程 1. 外部流程或 `Controller` 调用 `CloseUIAsync(...)` 2. `Controller` 解除事件订阅 3. `Controller` 清理本次交互缓存,例如回调、临时 `UserData` 和局部状态 4. `View.OnClose` 清理本地视觉状态 5. 下次打开时重新按 `Context` 初始化 ## 8. 目录与命名规范 目录示例以插件目录为例(`Assets/Plugins/UIModule`)。 - 插件 `Base/Event//`:UI 专用事件(命名空间 `SepCore.Event`) - 插件 `Runtime/Base/`:五层 UI 基础抽象(命名空间 `SepCore.UI`) - 插件 `Runtime//`:UseCase、RawData(命名空间 `SepCore.UI`) - 插件 `Runtime/` 根目录:`UIRouterComponent`(命名空间 `SepCore.CustomComponent`) - 插件 `UI//`:Controller、Context、View(命名空间 `SepCore.UIModule`) - 五层同名前缀保持一致,例如 `ShopForm*`、`LevelUpForm*` - 子组件上下文命名:`RoleItemContext`、`RewardItemContext`、`DisplayAreaContext` - 结果对象命名:`XXXResult`、`XXXActionResult` - 轻量 UI 可以省略 `UseCase` 和 `RawData`,但不省略 `Controller`、`Context`、`View` ## 9. 测试规范 ### 9.1 自动化测试范围 如果一个 UI 具备 `UseCase`,并且需要补自动化测试,则统一使用 EditMode 测试。 优先覆盖: - 初始化模型生成 - 业务分支与校验 - 用户动作对应的结果对象 - 边界条件和非法输入 ### 9.2 Controller / View 的验证策略 `Controller` 和 `View` 以人工验收为主,重点验证: - 首次打开 - 交互刷新 - 局部刷新 - 关闭重开 - 非法参数或空数据输入时的表现 ## 10. 落地检查清单 新增 UI 时,至少检查以下事项: 1. 先判断该 UI 属于标准五层还是轻量 UI 2. 使用 `UIRouterComponent` 管理的 UIForm 必须配置可实例化的 Controller 绑定 3. 如果存在业务规则或状态推进,必须引入 `UseCase` 4. 外部打开 UI 时只传入 `RawData` 或可转换为 `RawData` 的参数,不传入 `Context` 5. `OpenUIAsync(null)` 对有 UseCase 的 UI 应通过 UseCase 构造初始 RawData 6. `RawData` 中不得出现 `Context` 类型 7. `Context` 只能在 `Controller` 中构建 8. `View` 不得订阅全局业务事件 9. `View` 的交互只能通过 UI 专用事件上报 10. `Controller` 必须成对管理事件订阅与解除订阅 11. `Controller` 必须在关闭时清理本次交互缓存 12. 有 `UseCase` 且需要补自动化测试时,测试写入 EditMode ## 11. 非目标说明 - 本文不讨论历史实现,也不为历史写法背书 - 本文不要求所有 UI 必须强制引入 `UseCase` - 本文不展开底层 UI 框架或事件系统实现细节