vampire-like/docs/UI-5层架构设计规范.md

20 KiB
Raw Permalink Blame History

UI 五层架构设计规范UseCase / RawData / Controller / Context / View

1. 文档目标

本文定义一套可长期复用的 UI 分层设计方案,用于约束 UI 模块的职责边界、依赖方向、通信方式和测试策略。

  • 本文描述的是规范,不是对某个项目现状的总结
  • 项目内目录或基类只作为落地示例,不改变本文的抽象约束
  • 本文重点约束 UIForm 级模块;子组件(ItemArea 等)可只实现 Context + View
  • Unity GameFramework 的底层细节不在本文展开,本文只约束项目内 UI 代码组织

补充说明(插件化落地):

  • 本规范通过 Assets/Plugins/UIModule/可选插件方式落地
  • 插件无独立程序集,通过 .asmref 融入原项目各程序集:
    • SepCore.Base.UIModule.asmrefBase/SepCore.Base
    • SepCore.Runtime.UIModule.asmrefRuntime/SepCore.Runtime
    • SepCore.Presentation.UIModule.asmrefUI/SepCore.Presentation
  • Runtime/SepCore.UI 命名空间放置基础抽象、UseCase、RawDataSepCore.CustomComponent 放置 UIRouterComponent
  • UI/SepCore.UIModule 命名空间放置 Controller / Context / View
  • 不启用插件时,项目可继续使用原有基座 UI 路线
  • 启用插件时,必须通过 UIRouterComponent 统一管理 Controller / UseCase
  • 通过 UIRouterComponent 打开的 UIForm 必须配置对应 Controller缺少绑定视为接入错误不回退到原始 UI 打开路径
  • 当前插件对外 UI 生命周期接口为 async-firstOpenUIAsync / 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 是纯展示数据,不进入 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
  • 对外提供语义化方法,例如 CreateInitialModelTryRefreshSelectConfirm
  • 返回值只能是 RawData 或纯业务结果对象,例如 XXXResultXXXActionResult
  • 不依赖 ContextViewUGuiFormMonoBehaviour 等 UI 类型
  • 不负责 UI 资源加载、文本拼装、颜色选择、图标转换等展示处理
  • 不发布 UI 专用事件

适用场景:

  • UI 会读写领域状态
  • UI 存在明确业务规则、条件分支、校验或状态推进
  • UI 的交互结果需要被测试和复用

3.2 RawData 层

职责:承载 UseCase -> Controller 的纯业务传输模型。

约束:

  • 命名:XXXRawData
  • 只描述业务数据,不包含 UI 展示行为
  • 可以包含领域对象、配置对象、标识符、枚举、数值和纯数据集合
  • 轻量场景下可携带回调委托,由 Controller 在构建 Context 前完成注册
  • 不允许依赖 ContextViewSpriteTMP_Text 等展示相关类型
  • 不允许直接使用 XXXItemContextXXXContext 作为字段类型

说明:

  • RawData 的目标是表达“业务上发生了什么”
  • Context 的目标是表达“界面应该怎么显示”
  • RawData 可以携带行为(如回调),Context 只承载展示数据,不允许携带回调

3.3 Controller 层

职责UI 编排层,负责连接外部流程、UseCaseView,并统一管理 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 可能多实例时,负责通过 senderserialId 或其他等价标识限制事件只作用于当前 UI 实例
  • 推荐子类显式组织业务时序参数校验、Context 构造、临时状态缓存、关闭后续动作等都直接写在具体 Controller 中;基类只提供共用开关窗机制,不隐藏子类业务时机

允许职责:

  • 将业务数据转换为展示友好的文本、图标、颜色、列表状态
  • 在必要时查询本地化、资源映射或展示适配逻辑

禁止职责:

  • Controller 中堆叠大段领域业务规则
  • 绕过 Context 直接把业务对象塞给 View
  • 直接修改其他 UI 的内部 View
  • 直接调用 View 的公开方法进行局部刷新;应通过更新 Context 并设置刷新粒度控制字段,再调用 RefreshUI 让 View 自行决定刷新内容

3.4 Context 层

职责:承载“可直接驱动 UI 展示”的上下文数据。

约束:

  • 继承 UIContext
  • 命名:XXXContextXXXItemContextXXXAreaContext
  • 只能由 Controller 构建和更新
  • 字段以展示友好为目标,例如标题、描述、图标、颜色、状态、列表、按钮文案
  • 不允许携带回调委托或行为,交互行为由 Controller 注册View 通过 UI 专用事件通知 Controller
  • 允许组合子 Context
  • 不进入 UseCase
  • 允许提供构造函数,但只能由对应的 Controller 调用,用于封装从 RawData 到展示数据的转换逻辑
  • 允许提供刷新粒度控制字段(如 NeedRefreshXxx 布尔字段用于支持局部刷新View 根据这些字段决定刷新哪些部分Controller 在更新数据后设置对应字段并调用 RefreshUI

说明:

  • Context 可以包含展示层需要的最终数据
  • Context 可以是“已格式化”的显示数据
  • 但这些数据必须由 Controller 负责准备,而不是 UseCase
  • Context 的构造函数可以包含轻量的展示逻辑转换(如格式化文本、选择图标),但不应包含复杂业务规则
  • 刷新粒度控制字段的典型用法Controller 更新 Context 数据后,设置 NeedRefreshXxx = true,然后调用 RefreshUIView 在 RefreshUI 中检查这些字段,只刷新需要更新的部分,最后将字段重置为 false

3.5 View 层

职责:纯表现层,负责控件绑定、渲染刷新、动画触发和交互事件抛出。

约束:

  • Form 类继承 UGuiForm,子组件通常继承 MonoBehaviour
  • 命名:XXXFormXXXItemXXXArea
  • 提供打开、关闭、刷新入口:
    • UGuiFormOnOpen(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.BaseUI 专用事件(SepCore.Event
  • SepCore.Runtime:基座与业务流程层,同时融入插件 Runtime/ 中的基础抽象、UseCase、RawData 与 UIRouterComponent
  • SepCore.PresentationController、Context、ViewSepCore.UIModule
  • EditorUIModule.Editor,代码命名空间当前为 SepCore.UIModule.Editor

插件目录与程序集映射:

  • Base/SepCore.Base
  • Runtime/SepCore.Runtime
  • UI/SepCore.Presentation

建议依赖方向:

SepCore.BaseUI 专用事件)
   ↑
SepCore.Runtime基础抽象 + UseCase + RawData + Router
   ↑
SepCore.PresentationController + Context + View

约束:

  • SepCore.Base / SepCore.Runtime 不反向依赖 Presentation 层
  • UI 专用事件虽在 SepCore.Event 命名空间,但语义上仍归属对应 UI 模块,禁止被业务模块复用
  • 业务流程层通过插件入口(如 Router调用五层 UI 能力
  • 插件内无独立程序集,通过 .asmref 文件融入原项目编译

6. 事件通信规范

6.1 UI 与 Controller 的通信方式

ViewController 的通信通过当前 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 可同时存在时,事件必须只作用于当前窗体实例;可通过 senderserialId 或其他等价标识实现
  • UI 专用事件命名应体现模块归属,避免语义过宽
  • 同一 UI 可以按需要使用“多个精细事件”或“单一事件 + 子类型/按钮编号”的方式建模;例如当前 Dialog 使用 DialogEventArgs + ButtonId

6.4 UseCase 与编排层Procedure的通信方式

推荐方式:接口式引用(主要方式)

UseCase 与编排层Procedure的通信采用 依赖倒置原则DIP,通过接口进行解耦。

核心模式

Procedure (编排层)
   ↓ 实现接口
IProcedureXXX
   ↓ 注入(构造函数传 this 作为接口)
UseCase
   ↓ 通过接口回调
Procedure.ConfirmXXX(...)

实现规范

  1. 定义接口契约

    • 命名:IProcedureXXXXXX 对应用户或 UI 名称)
    • 位置:Runtime/ProcedureInterface/ 目录
    • 内容:只包含 UI 真正需要的方法遵循接口隔离原则ISP
    // 示例IProcedureMenu.cs
    namespace SepCore.Procedure
    {
        public interface IProcedureMenu
        {
            void ConfirmSelectRole(int roleId);
        }
    }
    
  2. Procedure 实现接口

    • Procedure 类实现对应接口
    • OnEnter 中创建 UseCase 并注入自身(this
    • 接口方法中实现流程编排(状态变更、场景切换、数据传递等)
    // 示例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 的具体引用
    // 示例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 委托作为轻量替代:

// 委托方式示例(适用于单一回调场景)
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 的标准流程

Procedure / GameState
   -> BindUseCase
   -> await OpenUIAsync(rawData or null)

Controller
   -> 如果外部传入 RawData直接使用该 RawData
   -> 如果外部未传入 RawDataUseCase.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外部也不传入 ContextContext 仍由 Controller 构建

7.3 关闭流程

  1. 外部流程或 Controller 调用 CloseUIAsync(...)
  2. Controller 解除事件订阅
  3. Controller 清理本次交互缓存,例如回调、临时 UserData 和局部状态
  4. View.OnClose 清理本地视觉状态
  5. 下次打开时重新按 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*
  • 子组件上下文命名:RoleItemContextRewardItemContextDisplayAreaContext
  • 结果对象命名:XXXResultXXXActionResult
  • 轻量 UI 可以省略 UseCaseRawData,但不省略 ControllerContextView

9. 测试规范

9.1 自动化测试范围

如果一个 UI 具备 UseCase,并且需要补自动化测试,则统一使用 EditMode 测试。

优先覆盖:

  • 初始化模型生成
  • 业务分支与校验
  • 用户动作对应的结果对象
  • 边界条件和非法输入

9.2 Controller / View 的验证策略

ControllerView 以人工验收为主,重点验证:

  • 首次打开
  • 交互刷新
  • 局部刷新
  • 关闭重开
  • 非法参数或空数据输入时的表现

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 框架或事件系统实现细节