15 KiB
15 KiB
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.BaseSepCore.Runtime.UIModule.asmref:Runtime/→SepCore.RuntimeSepCore.Presentation.UIModule.asmref:UI/→SepCore.Presentation
Runtime/内SepCore.UI命名空间放置基础抽象、UseCase、RawData;SepCore.CustomComponent放置UIRouterComponentUI/内SepCore.UIModule命名空间放置 Controller / Context / View- 不启用插件时,项目可继续使用原有基座 UI 路线
- 启用插件时,必须通过
UIRouterComponent统一管理Controller / UseCase - 通过
UIRouterComponent打开的 UIForm 必须配置对应 Controller,缺少绑定视为接入错误,不回退到原始 UI 打开路径 - 当前插件对外 UI 生命周期接口为 async-first:
OpenUIAsync / CloseUIAsync
2. 核心原则
2.1 单向数据职责
UI 的职责链路固定为:
外部流程
-> Controller
-> UseCase
-> RawData / Result
-> BuildContext
-> View
View
--(UI 专用事件)--> Controller
说明:
UseCase只负责业务规则、状态推进和纯业务数据生成Controller是 UI 编排中心,也是唯一允许构建Context的层View只负责渲染和抛出交互,不直接处理业务状态
2.2 严格分离业务数据与展示数据
RawData默认是业务传输模型,不承载展示模型。- 轻量 UI 可携带回调委托,但回调只允许由 Controller 注册和触发,不得进入 Context / View。
Context是纯展示数据,不进入UseCaseUseCase不能返回ContextView只能消费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<TContext, TForm>实现 - 通过
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
3.4 Context 层
职责:承载“可直接驱动 UI 展示”的上下文数据。
约束:
- 继承
UIContext - 命名:
XXXContext、XXXItemContext、XXXAreaContext - 只能由
Controller构建和更新 - 字段以展示友好为目标,例如标题、描述、图标、颜色、状态、列表、按钮文案
- 不允许携带回调委托或行为,交互行为由 Controller 注册,View 通过 UI 专用事件通知 Controller
- 允许组合子
Context - 不进入
UseCase
说明:
Context可以包含展示层需要的最终数据Context可以是“已格式化”的显示数据- 但这些数据必须由
Controller负责准备,而不是UseCase
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 / ResultController -> UseCase + RawData + Result + Context + View + UI 专用事件Context -> 子 Context / 纯展示值对象View -> Context + UI 专用事件
禁止依赖:
UseCase -> Context / View / Unity 具体展示组件RawData / Result -> Context / ViewContext -> View / UseCaseView -> UseCaseView -> 全局业务事件View -> 领域状态修改
5.1 插件化分层建议
建议分层如下:
SepCore.Base:UI 专用事件(SepCore.Event)SepCore.Runtime:基座与业务流程层,同时融入插件Runtime/中的基础抽象、UseCase、RawData 与UIRouterComponentSepCore.Presentation:Controller、Context、View(SepCore.UIModule)- Editor:
UIModule.Editor,代码命名空间当前为SepCore.UIModule.Editor
插件目录与程序集映射:
Base/→SepCore.BaseRuntime/→SepCore.RuntimeUI/→SepCore.Presentation
建议依赖方向:
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
7. 标准交互流程
7.1 有 UseCase 的标准流程
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 的轻量流程
外部流程
-> 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 关闭流程
- 外部流程或
Controller调用CloseUIAsync(...) Controller解除事件订阅Controller清理本次交互缓存,例如回调、临时UserData和局部状态View.OnClose清理本地视觉状态- 下次打开时重新按
Context初始化
8. 目录与命名规范
目录示例以插件目录为例(Assets/Plugins/UIModule)。
- 插件
Base/Event/<UIName>/:UI 专用事件(命名空间SepCore.Event) - 插件
Runtime/Base/:五层 UI 基础抽象(命名空间SepCore.UI) - 插件
Runtime/<UIName>/:UseCase、RawData(命名空间SepCore.UI) - 插件
Runtime/根目录:UIRouterComponent(命名空间SepCore.CustomComponent) - 插件
UI/<UIName>/: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 时,至少检查以下事项:
- 先判断该 UI 属于标准五层还是轻量 UI
- 使用
UIRouterComponent管理的 UIForm 必须配置可实例化的 Controller 绑定 - 如果存在业务规则或状态推进,必须引入
UseCase - 外部打开 UI 时只传入
RawData或可转换为RawData的参数,不传入Context OpenUIAsync(null)对有 UseCase 的 UI 应通过 UseCase 构造初始 RawDataRawData中不得出现Context类型Context只能在Controller中构建View不得订阅全局业务事件View的交互只能通过 UI 专用事件上报Controller必须成对管理事件订阅与解除订阅Controller必须在关闭时清理本次交互缓存- 有
UseCase且需要补自动化测试时,测试写入 EditMode
11. 非目标说明
- 本文不讨论历史实现,也不为历史写法背书
- 本文不要求所有 UI 必须强制引入
UseCase - 本文不展开底层 UI 框架或事件系统实现细节