游戏UI架构
引言
一个健壮、好用的架构,能让写业务的人专心写业务,不用把精力耗在那些零碎细节上。要解决的无非是解耦、复用、层级、性能、多端适配这几件事。
一、UI 层的三个特点
UI 层有三个绕不开的特点:
- 代码量最大(除引擎外)。
- 变更最频繁。策划改数值、UX 调布局、美术换资源,项目里任何一处变动,最后都会传导到 UI。
- 状态组合最多。加载、显示、隐藏、被遮挡、动画中、关闭、销毁,再加上各项目自己加的状态。
二、三个特点,三类故障
| 特点 | 典型故障 | 根因 |
|---|---|---|
| 代码量最大 | 一个 ListView 的 initialize、intoView、绑定/解绑事件,代码里几十份,区别只在里面的函数名 |
缺少可复用的基类,没有统一生命周期约定 |
| 变更最频繁 | 改 A 界面,把 B 界面改坏了 | 隐式引用,没做解耦 |
| 状态组合最多 | 界面二次打开数据不对(没刷新)、UI 显示不对(动画残留),而且偶发 | 生命周期职责没分清 |
前两个只是代码难看,肉眼能看出来;第三条是偶发 bug,复现不了也定位不到,头秃 +1。
三、架构要回答的唯一问题
UI 层架构其实只回答一件事:怎么把界面从业务逻辑里干净地剥出来,让两边各自演进而互不干扰。
四、边界:做啥,不做啥
有个简单的检验办法:把整个 UI 层删了,游戏还能不能跑。能跑,说明边界划对了;跑不了,说明同志还须努力了。
4.1 职责划分
| 职责 | 归属 | 说明 |
|---|---|---|
| 渲染 | UI | 把数据画成像素 |
| 交互响应 | UI | 玩家点了,做出反应 |
| 布局与适配 | UI | 位置、尺寸、安全区、分辨率 |
| 进出场过渡 | UI | 淡入、缩放、滑动 |
| 输入转译 | UI | 物理输入 → 语义动作 |
| 层级与栈管理 | UI | 谁盖着谁,谁能回退 |
| 资源引用持有 | UI | 界面用到的资源,用完要还 |
| 网络请求 | NetService | 服务器交互 |
| 数值计算 | Model | 伤害、得分、概率,数据变化 |
| 存档读写 | Storage | UI 只管什么时候触发保存 |
| 配置解析 | Config → Model | UI 只读解析好的配置对象 |
| 平台能力(登录/支付/分享/震动) | Platform | UI 只调接口,不碰 SDK |
4.2 两条推论
UI 层可以有数据,但只能存副本。在 UI 层改数据既不允许也没意义,数据本身归 Model 层管。
UI 层往外给的是意图,不是结果。玩家点了 Login,UI 表达的就是 Intent_Player_Login,至于账号密码校验、SDK 调用、网络请求,都跟 UI 没关系。
框架的所有设计,都是为了这两条推论服务的。
五、分层模型与依赖方向
┌───────────────────────────────────────────────
│ 业务层 Business
│ 玩法规则 / 数值 / 网络
│ ── 通过框架路由打开界面
│ ── 通过事件接收 UI 意图
└───────────────────┬───────────────────────────
│ 依赖 ↓
┌───────────────────▼───────────────────────────
│ UI 层
│ 渲染 / 交互 / 布局 / 过渡 / 埋点 / 资源引用
└───────────────────┬───────────────────────────
│ 依赖 ↓
┌───────────────────▼───────────────────────────
│ 底座层 Infrastructure
│ EventMgr / TimerMgr / ObjectPool
│ Log / Input / AssetMgr
└───────────────────────────────────────────────
5.1 依赖必须单向
跨边界只能走接口或者事件。底座层不能反过来依赖任何上层。
5.2 底座层能做到的三件事
- 能提前加载、不依赖任何界面存在;
- 内部怎么改,上层都无感,换实现不换接口;
- 既不管上层怎么迭代,也不管是谁在用自己。
六、框架层的数据模型
框架和业务之间唯一的契约,是一份元数据,可能是配置来的,可能是自己组织的。
6.1 框架层看到的
UIMetadata {
name 唯一标识
layer 属于哪一层
mode 同层策略:互斥 / 栈 / 单例
bundle 资源包名
assetPath 资产路径
resPaths 依赖的额外资产
cache 关闭后是否保留实例
}
框架不知道界面长什么样,也不管上面有几个按钮。它只干一件事:把这份描述变成一个能正确加载、挂载、显示、回收的界面。
框架不认识业务,认的就是这一份 meta。
6.2 业务层写的
SomePanel {
name : "SomeOne",
layer : eLayer.some,
mode : eStackMode.stack,
bundle : "main",
assetPath : "xxx/xx/x",
resPaths : {"x", "y"},
cache : true
}
6.3 框架层接口
UIFacade.Open(panelKey) // 业务只传一个标识,不传任何界面细节
UIFacade.Close(panelKey)
界面类是哪个、长什么样,框架一概不管。
七、核心抽象
整套架构真正要设计的就三个类型。
类型 A:策略容器 —— UIMeta、UIMode、DeviceTier
只是一些「配置项的集合」,本身不含行为。作用是把变化点从代码里抽出来变成配置。
类型 B:运行时管理者 —— UIManager、ResourceTracker、InputRouter、CoroutineRunner、UIPool、Store、ScreenAdapter
它们是有状态的常驻服务,全生命周期存在,负责管一批东西的生命周期、分配、回收。
类型 C:契约基类 —— UIPanel
它是业务代码的扩展点。业务继承它、覆写钩子。框架通过它把生命周期「注入」给业务。
八、一次完整流程
8.1 打开
1. UIFacade.Open(panelKey)
└─ 业务层只传标识,不传界面细节
2. 查配置表,取出该界面的 meta
3. 查重:是否已经打开?
└─ 是 → show() + refresh(),结束
4. 检查同层排他策略
└─ Exclusive → 先关闭同层所有旧界面
5. 插入过渡(黑屏)
6. 加载资产(prefab + resPaths)
└─ 引用计数 +1
7. 挂载到对应的 Layer 节点
8. show() → 触发 onShow(data)
9. 推入 stack / 登记缓存
10. 广播开启事件 → 埋点、日志、其他订阅者
第 4 步的位置很关键。排他检查必须在加载资产之前,先关旧的、释放旧资源,再加载新的。放到挂载之后,会出现新旧界面同时在内存里的瞬时峰值。
8.2 关闭(反向)
1. UIFacade.Close(panelKey)
2. 从 stack 中弹出,移出缓存登记
3. hide() → 触发 onHide()
4. 分支:
├─ cache 模式 → 保留实例,仅隐藏
└─ 非 cache → destroy()
├─ 触发 onDestroy()
├─ 自动解绑所有 bind 注册的订阅
└─ 释放 resPaths 声明的资源(引用计数 -1)
5. 广播关闭事件 → 埋点、日志、其他订阅者
所以架构到底是什么
我的答案是一句话:边界清晰、依赖单向、生命周期管理、通信、资源管理,这五样齐了,就是一个合格的脚手架。写业务的人只用管这个界面长什么样、玩家点了会怎样,剩下的交给其他人吧(Agent!)。
细节展开 游戏UI架构(二)。
浙公网安备 33010602011771号