游戏开发架构选型:第1篇|什么是架构?从进球事件看 MVC 的局限
同样是写一个"进球",为什么有的代码越改越崩溃,有的代码加功能像拼乐高?本文从三种主流架构的原理出发,用同一个足球游戏场景做实战对比,帮你理解"选架构"这件事到底选的是什么。
1 架构要解决的核心问题
写一个游戏功能不难。难的是这个功能在三个月后还能改得动。
游戏开发中,架构要解决的本质问题只有三个:
- 模块间如何通信?------记分牌更新了,音效怎么知道该播?摄像机怎么知道该切?
- 数据如何流转?------球员属性改了,比赛引擎要不要通知 UI?通知了 UI,UI 要不要再通知渲染层?
- 变化如何被隔离?------策划说"加一个成就系统",能不能只加一个文件而不动已有的 20 个文件?
三种主流架构------MVC、MVVM、事件驱动------分别给出了不同的答案。
需要说明的是,下面这些代码都来自一个真实跑起来的足球游戏项目------不是玩具示例,而是经过完整迭代的生产级代码。这套架构设计经历过从单机到联网、从原型到完整项目的完整验证。
2 场景引入:一个进球,五个模块同时响应
当一个球员在比赛中进球时,至少有五个模块需要同时响应:
| 模块 | 响应动作 |
|---|---|
| 记分牌面板 | 更新比分(主队 +1 / 客队 +1) |
| 进球回放面板 | 显示进球球员头像和进球时间 |
| 音效系统 | 播放进球欢呼声 |
| 摄像机系统 | 切换到进球回放视角 |
| 比赛引擎 | 切换到 Freeze 暂停状态 |
如果这个需求都写不清楚,后面再加裁判播报、成就系统、比赛统计、队伍战术,项目很快就会变成"改一处炸一片"。
3 MVC 模式:分层的初心与现实的变异
MVC(Model-View-Controller)是游戏开发中最古老的架构思想之一。它的初衷非常朴素:把数据、显示、逻辑拆开。
三种角色各司其职:
| 角色 | 职责 | 举例 |
|---|---|---|
| Model | 持有数据,数据变时通知订阅者 | HomeScore 从 1 变成 2 |
| View | 渲染显示,监听 Model 变化 | 记分牌 Text 从 "1" 变成 "2" |
| Controller | 处理输入,把用户操作转为 Model 调用 | 玩家按下射门键 |
理想很美好,但 Unity 社区里落地 MVC 时,出现了两种截然不同的变体。理解它们的区别,比理解 MVC 本身更重要。
变体 A:上帝 Controller(Unity 社区最普遍的"伪 MVC")
在 Unity 里,很多人会把一个 MonoBehaviour 当成"总调度中心"------它持有所有模块的引用,任何事件发生时逐行调用:
痛点 :Controller 持有 5 个模块的具体类型引用。策划说"加个成就系统"------你得在 GoalMatchController 里加第六行代码,还得在 Inspector 拖一个新的引用。策划说"裁判播报也要响应进球"------再加第七行。Controller 越来越大,最终变成谁都不敢动的上帝类。
"上帝 Controller"的本质:把"谁响应什么"的决策逻辑硬编码在一个中心类里。这不是架构,这是把所有耦合集中到了一个地方。
变体 B:经典 MVC(Model → View 观察者模式)
回到 Smalltalk-80 定义的经典 MVC:Model 改变时通过观察者模式直接通知 View,Controller 只处理用户输入。这在 Unity 中的实现如下:
痛点:表面上看起来比上帝 Controller 好------Controller 瘦了。但实际上引入了新问题:
- View 直接知道 Model 的类型------每个 View 都必须持有
MatchModel引用,这和"Model 不依赖 View"的初衷矛盾(实际上变成了 Model ↔ View 双向知道) - Model 的 event 订阅者列表膨胀------当 50+ 个 View 都订阅
OnGoalScored,这个委托链就是一条串行 50 次的调用链,无法按优先级排队 - Model 被动承担了调度职责------Model 的职责是比赛数据,它不应该区分"进球后摄像机干嘛 vs 音效的干嘛",但委托让它间接承担了调度逻辑------这本质上是把中枢从显式的 Controller 变成了隐式的 Model 内部委托
两种变体的本质区别
两者都没有真正解决耦合问题。区别只在于:耦合集中在一个类(上帝 Controller),还是散落在 Model 的委托链里(经典 MVC)。
4 MVVM 模式:数据驱动的双向绑定
MVVM(Model-View-ViewModel)的核心思路是:在 View 和 Model 之间插入一个 ViewModel,通过数据绑定自动同步状态 。
ViewModel 持有 Observable<int> 这样的可观察属性,View 绑定到这些属性上。当 Model 的数据变化时,ViewModel 更新 Observable.Value,UI 自动刷新------不需要手动调用 scoreText.text = "1"。
在 Unity 中落地 MVVM 需要自己造轮子,或者引入第三方库:
痛点:MVVM 在 WPF、Android、iOS 中非常成熟,但在 Unity 中面临几个现实困境:
- Unity 原生不支持数据绑定框架------得自己造
Observable<T>,或者引入 UniRx/R3 等第三方库,增加打包体积和学习成本 - 绑定关系变成"看不见的网"------改了某个字段会触发谁?靠 grep 找
.OnChanged +=调用链,比事件总线更难调试 - 不适合一次性触发场景------进球是一次性动作,但
Observable.Value = newVal每次都触发OnChanged,多了一层不必要的调度;比赛中的分支事件(普通进球 / 乌龙球 / 点球)需要给每个分支单独建字段,不如事件驱动发不同事件类型灵活
5 事件驱动:发布/订阅的松耦合哲学
事件驱动(Pub/Sub)的核心思想只有一句话:发布者只管发事件,订阅者各管各的,中间用一个事件总线中转 。
实战代码:一行 Trigger,各模块独立订阅
优势:
- MatchEngine 不知道 5 个订阅者是谁------它只知道"触发一次"
- 新增订阅者零侵入:成就系统只需要新建一个 MonoBehaviour + 在
OnEnable里加一行EventManager.Subscribe<GoalScoredEvent>(...),MatchEngine 完全不用动 - 解绑自然:每个 MonoBehaviour 在
OnDisable里UnSubscribe,对象销毁即失联,不存在野指针或内存泄漏 - 静态类型安全:泛型约束
where T : IBaseEvent保证订阅者拿到的就是目标事件,IDE 跳转和重构都友好 - 天然支持条件分支:
PenaltyScoredEvent、OwnGoalEvent各自只订阅自己关心的事件,互不干扰
发布者和订阅者互不知晓。 进球判定模块不知道自己触发的事件会被谁处理,记分牌模块不知道是谁触发了进球事件。
三种方案的耦合度对比
结语:架构不是为了"写得像样",而是为了"后面还能改得动"
如果你把一个进球事件看成一个真实的工程问题,就会发现:
- 上帝 Controller 的问题,不是它"写得大",而是它"函数调用变成结构耦合"
- 经典 MVC 的问题,不是它没有分层,而是它把事件调度压进 Model
- MVVM 的问题,不是绑定不对,而是它在 Unity 里的隐式依赖太强
- 事件驱动的优点,不是"很流行",而是它严格把"发射事件"和"处理事件"的职责分开了
下一篇,我们就继续往前:把 MVC、MVVM 和事件驱动放进一张表里做定量对比,再结合真实项目做一次架构取舍。