AI 写代码时代,游戏 UI 架构为什么停在 MVP?
游戏周边 UI 本质上是套着游戏皮肤的 App。过去十年,App 开发靠 MVP 解决了"业务逻辑和视图纠缠"的问题,但游戏团队普遍没跟上来------不是不知道,是落地成本太高。现在 AI 改变了这个等式。**本文论证三件事:为什么游戏 UI 需要架构、为什么是 MVP 而不是更激进的方案、为什么现在做第一次变得划算。**
一、问题:游戏 UI 的可测试性赤字
先放下架构模式,看一个更根本的问题。
假设你有一个商城面板,里面有价格判断、库存校验、购买冷却、限购逻辑。你改了其中一条规则------比如"VIP 用户购买冷却减半"。你怎么确认改动没有破坏其他规则?
选项 A:启动游戏,点进商城,买一次,看结果。
选项 B:跑一条单元测试,0.1 秒出结果。
大多数游戏团队选 A,因为代码结构不支持 B。业务逻辑和 Cocos Component 的生命周期、节点树、事件系统绑在一起------脱离引擎根本跑不起来,更别说写测试。
这不是某个程序员的错。Cocos Creator 的教程、示例、社区最佳实践全部是 Component 直写。引擎的 @property + @ccclass 模式天然鼓励你把逻辑和视图写在同一个类里。你顺着引擎的设计走,自然走到"没法测试"的终点。
这就是游戏 UI 的"可测试性赤字"------不是不想测,是结构让你测不了。 而可测试性不只是测试本身的问题:不可测试的代码必然也是不可维护的代码,因为每次改动你都无法确认影响范围。
二、前例:Android 十年踩过的坑
移动 App 开发遇到过完全一样的问题,而且花十年走完了从坑到解的全过程。
Android 早期的 Activity 里塞满了网络请求、数据库操作、UI 更新、业务判断。一个 Activity 上千行是常态。改一行怕影响十处,测试靠手动点。这和今天游戏 Component 的状态一模一样。
推着 Android 走出这个泥潭的驱动力不是审美,是可测试性。Google 推出了单元测试框架,但 Activity 里的代码依赖 Android 运行时,无法脱离设备跑快速测试------于是必须把逻辑抽出来。MVP 是第一个能落地的方案:把逻辑抽到 Presenter,View 退化为接口。MVVM 后来走得更远(响应式绑定、数据流单向化),但那是在基础设施成熟之后的事。
| 模式 | 谁持有业务逻辑 | View 能脱离逻辑测试吗 | 需要什么基础设施 |
|---|---|---|---|
| MVC(早期 Android) | Activity 自己 | ❌ 不能 | 无 |
| MVP | Presenter(脱离 Activity) | ✅ 能------View 退化为接口 | 接口定义 + 依赖注入 |
| MVVM | ViewModel(脱离 Activity) | ✅ 能------View 只订阅 | 响应式框架 + 数据绑定 + LiveData/Flow |
关键洞察:Android 的每一步架构升级,都是被"先要能测,才想怎么拆"这个需求倒逼出来的。 可测试性不是架构的副产品,而是架构的驱动力。这个经验不需要依赖 Android 的具体实现------任何事件驱动、状态稠密的 UI 系统,面临的可测试性问题是同构的。
三、游戏 UI 的特殊性:为什么选了 MVP 而不是 MVVM
如果 Android 最终走到了 MVVM,我们为什么不一步到位?
因为游戏 UI 有一个 App UI 没有的维度:表现层的时序控制 。App 的数据变化通常直接映射到 UI------价格变了,Label 更新。但游戏 UI 经常需要:"数据变了 → 先播一段 tween 动画 → 动画播完再更新 UI → 同时触发粒子特效"。这不是简单的"数据变了就刷新",而是有时序编排的渲染指令序列。
在 MVVM 的声明式框架里做时序控制,要在数据绑定之外不断加副作用------Observable 变化触发动画、动画完成回调再改另一个 Observable。能做,但别扭,维护成本高。
MVP 的命令式接口天然适配这个场景。Presenter 调用 view.setPrice("已购买"),View 的实现里可以自由决定:是直接设置 Label.string,还是先播 scale 动画再设。Presenter 只说"展示什么",View 自己决定"怎么展示"------这个分工恰好对应了游戏 UI 的复杂度分布。
**MVP 不是"MVVM 的简化版",而是两种不同的适配策略。** MVVM 适合"数据变化频繁、映射关系稳定"的场景(如列表刷新);MVP 适合"单次交互触发多步渲染指令"的场景(如购买反馈、升级动画、奖励展示)------后者恰好是游戏 UI 的主流。
四、现实:大多数团队停在 MVP 之前
理论上的最优解是一回事,现实中大多数团队的代码是另一回事。真实代码长这样:
typescript
@ccclass('ShopPanel')
export class ShopPanel extends Component {
onBuyClicked() {
if (PlayerData.gold >= this._currentItem.price) {
PlayerData.gold -= this._currentItem.price;
Inventory.addItem(this._currentItem);
this.priceLabel.string = '已购买';
// 还有 50 行业务逻辑直接写在 View...
}
}
}
typescript
export class ShopController {
private _view: ShopView; // 具体 View 类,不是接口
onBuyClicked() {
if (this._model.gold >= this._model.currentItem.price) {
this._model.spendGold(this._model.currentItem.price);
this._view.refresh(this._model); // 直接引用具体 View 类
}
}
}
typescript
interface IShopView {
setPrice(text: string): void;
setBuyEnabled(enabled: boolean): void;
showTip(msg: string): void;
}
export class ShopPresenter {
constructor(private view: IShopView, private model: ShopModel) {}
onBuyClicked() {
if (this.model.gold < this.model.currentItem.price) {
this.view.showTip('金币不足');
return;
}
this.model.spendGold(this.model.currentItem.price);
this.view.setPrice('已购买');
}
}
text
无架构
┌──────────────┐
│ View + 逻辑 │ 逻辑和视图混在一起,拆不开、测不了
└──────────────┘
引入分层:把逻辑从 View 里抽出来
MVC ── 双向依赖,紧耦合 ──
┌──────┐ 事件 ┌──────────┐
│ View │ ────────▶ │Controller│
│ │ ◀──────── │ │──▶ ┌──────────┐
└──────┘ 持有具体类 └──────────┘ │ Model │
ShopView,换不了 └──────────┘
引入接口:把「持有具体类」变成「依赖接口」
MVP ── 单向依赖,松耦合 ──
┌──────┐ 事件 ┌───────────┐
│ View │ ────────▶ │ Presenter │──▶ Model
│ │ │ 定义 IView │
└──┬───┘ └─────┬─────┘
│ 实现 │ 依赖接口
▼ ▼
┌────────────────────────────┐
│ IView │
│ Presenter 定义契约 │
│ View 实现契约 │
│ Presenter → IView(依赖接口)│
│ View → IView(实现接口) │
│ View 不知道 Presenter │
└────────────────────────────┘
引入绑定:不再手动调用 View
MVVM ── 声明式,完全解耦 ──
┌──────┐ 双向绑定 ┌───────────┐
│ View │ ◀────────▶ │ViewModel │──▶ Model
└──────┘ (无引用) └───────────┘
区别不在代码量,在依赖方向:
- MVC :Controller 持有具体 View 类(
ShopView),换不了 View,也测不了 Controller------因为构造 ShopView 需要 Cocos 节点树 - MVP :Presenter 指向 View 接口(
IShopView),随便换 View,Presenter 可脱离引擎独立测试------传个 mock 对象即可 - MVVM:ViewModel 通过数据绑定通知 View,不需要持有 View 引用,但需要响应式框架支撑
形态一和形态二的共同点:都不可测试。形态一完全没分层,形态二分了层但 Controller 持有具体 View 类的引用------你要测 Controller 就必须构造一个真实的 ShopView,而 ShopView 又依赖 Cocos 节点树。测试的入口被堵死了。
形态三之所以不同,不是因为"更干净",而是因为Presenter 只依赖接口,不依赖具体类。测 Presenter 时,你传入一个 mock 的 IShopView------一个普通对象,不需要 Cocos、不需要节点树、不需要场景。这就是依赖反转原则(DIP)的核心:高层模块不依赖低层模块,两者都依赖抽象。
那为什么大多数团队没到这个状态?
| 架构化的驱动力 | App 开发 | 游戏开发 |
|---|---|---|
| 框架/教程引导分层 | ✅ Android SDK 原生引导 MVP/MVVM | ❌ Cocos 教程全是 Component 直写 |
| 测试工具链推动架构 | ✅ JUnit 倒逼分层 | ❌ 无标准测试框架,脱离引擎跑不起来 |
| 多人协作要求接口隔离 | ✅ 团队并行开发需要接口契约 | ❌ 一个面板一个人写,不需要接口 |
| 需求频繁变更 | ✅ 不分层改不动,倒逼架构 | ❌ 改不动就重写------活动面板用完即弃 |
核心矛盾:App 开发中推动架构演进的每一股力量,在游戏 UI 中要么不存在,要么方向相反。 引擎鼓励不分层、没有测试压力、协作不要求接口、面板用完即弃。
所以游戏 UI 架构的真实分布不是有序演进,而是:
**游戏 UI 架构的真实分布** - 无架构(一部分团队) - 两层 / MVC(不少团队)← **大多数人停在这一带** - MVP(极少数) - MVVM(极少)
五、拐点:AI 改变了成本等式
前面说了三个事实:①游戏 UI 需要架构来获得可测试性;②MVP 是当前阶段的最优解;③大多数团队没做是因为成本太高。
AI 改变了第三个。不是让 MVP"更正确"------正确性从来没被质疑过。是让**"做正确的事"第一次变得划算**。
MVP 的落地成本集中在四个环节。每个环节恰好是 AI 擅长的:
- 接口定义和 View 绑定层:把 20-50 个 @property 映射到 IView 接口方法。纯体力活,模式高度固定,零创造性------AI 直出率极高。
- Presenter 控制流:状态计算、分支判断、前置校验。脱离引擎依赖后是纯逻辑代码------Prompt 描述清楚业务规则即可,AI 擅长穷举分支。
- 契约同步:策划改一个字段(比如新增一个消耗类型),需要同步改 proto、Presenter 判断逻辑、View 绑定、测试用例------四个文件联动,人工最容易遗漏。AI 可以同时修改关联文件,大幅降低遗漏率。
- 单元测试:基于 Presenter 的显式控制流穷举边界条件------正常路径、异常路径、边界值、并发冲突。AI 的测试覆盖率天然超过人工。
更重要的是维护成本的结构性变化。泥球代码的维护成本随代码量加速攀升------每次改动要在 Component 里追踪散落的逻辑,改一处可能影响三处。MVP 代码的维护成本随代码量增长平缓------业务规则集中在 Presenter,改动位置可预测,测试告诉你有没有破坏其他规则。当代码存活超过一个版本周期,这个差异就决定了"改不动就重写"还是"改完跑测试就上线"。
**MVP 不是完美的方案。但它在"测试覆盖率 × 落地方便程度 × 游戏 UI 适配度"这三个维度的乘积上,是当前阶段的最优解。** 不是做不到更好,而是这一步的 ROI 已经到极致了。
六、结语
游戏 UI 的可测试性赤字不是一个技术问题------它是在引擎引导、团队习惯、成本约束三方合力下形成的均衡态。打破这个均衡需要的不是一个更好的架构理论,而是一个让架构成本低于混乱成本的拐点。
AI 就是那个拐点。
有了 AI,工程师的核心价值从"写代码"转移到"设计契约"------定义什么是对的,让 AI 在框架内生成。模式跟随契约,不是反过来。