1. 项目概述:Unity程序员为什么绕不开“模式”这个坎
干了这么多年Unity开发,我越来越觉得,设计模式不是面试八股文,而是商业项目里真刀真枪解决问题的工具箱。你接手一个项目,Leader甩过来一句“用状态机重构一下BOSS战斗逻辑”,或者策划满嘴跑火车地要求“每种敌人都有三种攻击形态,后续还要加新种类”,这时候如果你脑子里没有一套成熟的“模式”体系,代码就会从第三天开始失控。
标题里说的“模式相关的开发需求”,我理解下来,大概分两层。狭义上,是指设计模式在Unity中的落地,比如单例、状态、观察者、工厂、对象池、命令模式这些;广义上,还包括Unity引擎自带的“模式化”机制,比如Animator的有限状态机、协程和异步模型、脚本生命周期回调,甚至Addressables的资源加载模式。这两层你得融会贯通,才能在商业项目里游刃有余。
这篇文章不是教科书式的理论复述,而是把我这些年从中小型团队、独立Demo到线上运营项目的实践总结一遍:什么场景用哪个模式、为什么选它而不是另一个、核心代码长什么样、以及我在真实项目中踩过哪些坑。无论你是刚转Unity两三个月的初级程序员,还是在做中大型项目想梳理代码架构的资深开发,这篇文章应该都能给你一点可落地的参考。
我始终有个观点:设计模式是为了解决特定问题而存在的,不是为了炫耀技术。脱离业务场景谈模式是耍流氓,具体怎么理解,下面拆开讲。
2. 需求拆解与方案选型:模式不是越多越好,而是越准越好
2.1 先看清“模式化需求”到底长什么样
展开写方案之前,得先把需求看清楚。我总结了商业项目里最常见的几类“模式相关需求”,你对照一下,基本不会跑偏:
第一类,对象状态切换类需求。典型的就是角色行为——待机、移动、攻击、受击、死亡,或者战斗流程——回合开始、玩家行动、敌方行动、回合结束。这类需求的特点是状态之间切换逻辑复杂,条件判断散落在各处,用一堆if-else写到最后连自己都改不动。
第二类,对象创建与管理类需求。比如怪物波次生成、子弹发射、UI弹窗、飘字特效。这类需求的核心矛盾是:对象数量动态变化,创建销毁频繁,直接new和Destroy会导致性能抖动和GC压力,同时不同种类对象的创建流程差异大,代码容易重复。
第三类,模块间通信类需求。比如战斗模块打掉一个怪,UI界面要刷新任务进度,音效模块要播放击杀音效,成就系统要累计计数。如果每个模块都直接互相引用,耦合度会瞬间爆炸,改一个功能牵扯一片。
第四类,流程控制与管理类需求。比如新手引导、副本流程、登录流程,这些流程有严格的先后顺序,且每一步都可能被突发事件打断——玩家断线、点击跳过、强制退出。普通写法是嵌套回调,写到最后就是传说中的“回调地狱”。
2.2 方案选型背后的取舍逻辑
针对上面四类需求,对应的模式选型其实有相对成熟的组合。这里直接给结论,附上我的理由:
| 需求类型 | 推荐模式组合 | 为什么不选其他方案 |
|---|---|---|
| 状态切换 | 状态模式(State)+ 有限状态机(FSM) | 用Enum+Switch写状态切换,新加一个状态要改原方法,违背开闭原则 |
| 对象创建管理 | 对象池(Object Pool)+ 工厂模式(Factory) | 直接Instantiate频繁操作,性能和内存都不稳;工厂类把创建逻辑收敛,配合池子批量管理 |
| 模块通信 | 观察者模式(Observer)/ 事件中心 | 模块之间互持引用,整个项目变成蜘蛛网,没法做热更和单元测试 |
| 流程控制 | 命令模式(Command)或异步状态机 | 嵌套回调逻辑不直观,出错后堆栈全乱,而且中途打断很难处理 |
这个选型逻辑,本质上是在回答三个问题:这个需求的变化点在哪里?这段代码的复用点是哪里?改动频率最高的地方能不能被隔离?
2.3 为什么我建议“先做减法”
我在早期带项目的时候,犯过一个典型错误:刚学完设计模式,恨不得把二十三种模式全塞进项目里。结果就是代码抽象层级过多,一个简单的攻击逻辑绕了五六个类,新来的同事根本看不懂,调试起来想骂人。
后来我学乖了。设计模式在项目里应该按“成本收益比”来取舍。比如一个功能可能写死不再改,那直接用最简单的方式实现就好,没必要硬套模式。但如果需求文档里明确写了“后续会新增角色种类”、“可能有多种攻击方式”,那就值得花一天时间把模式设计好。
记住一句话:模式是应对变化的手段,没有变化需求就蒸蒸日上不需要模式。这个行业很多项目的代码腐烂,不是因为程序员不懂设计模式,恰恰是因为过度设计导致没人敢改、没人能懂。
3. 核心技术点解析:五个高频Unity设计模式实战详解
这五个模式是我从业以来使用频率最高的,几乎每个商业项目都会用到。我分别从“它解决什么问题”、“核心原理长什么样”、“Unity环境下的实现细节”三个角度讲透。
3.1 单例模式:服务管理器的利与弊
单例大概是Unity开发者最早接触的模式,因为它解决了一个很现实的问题:有一些对象全局唯一,而且到处都要访问——音频管理器、UI管理器、网络管理器、存档管理器。每个地方都传引用太麻烦,不如搞个全局访问点。
C#里最典型的实现就是你经常见到的:
public class AudioManager : MonoBehaviour { private static AudioManager _instance; public static AudioManager Instance { get { if (_instance == null) { _instance = FindObjectOfType<AudioManager>(); if (_instance == null) { var go = new GameObject("AudioManager"); _instance = go.AddComponent<AudioManager>(); DontDestroyOnLoad(go); } } return _instance; } } }这段代码把几个要点都覆盖了:懒加载、场景中自动查找、没有则自动创建、跨场景不销毁。很多初学者抄完就完了,但有几个坑你得注意。
第一个坑是单例的销毁时机。游戏退出时,Unity会销毁所有场景对象,静态字段不会自动置空。如果此时有异步回调访问单例,会出现“对象已销毁但静态引用还在”的诡异错误。我的习惯是重写OnDestroy时把_instance置空,并加一个_isQuitting标志位,在回调里判断。
第二个坑是单例的初始化顺序。A单例在Awake里访问B单例,如果B还没初始化,结果就是空引用。我踩过这个坑之后,项目里统一规定:所有单例的初始化放在Start或者另一个管理器的Init流程里,禁止在Awake里互相访问。
第三个坑,也是最关键的认知——单例不是万能的。它在小型项目里好用,但当你有一堆单例像“音效管理”、“任务管理”、“背包管理”、“成就管理”,它们内部还在互相调用,你就等于把全局耦合穿了一条线。所以现在业界更推荐用依赖注入或服务定位器来替代单例,至少让访问关系显式化。Unity新版本的Dependency Injection包,就是干这个用的。
注意:如果项目里单例数量超过5个,而且存在互相调用关系,你就要考虑用服务定位模式或者DI容器重构了。否则后期维护成本会指数级上升。
3.2 状态模式与有限状态机:角色AI和战斗流程的最佳搭档
状态模式在Unity里的应用数量,我敢说排第二没人敢排第一。Animator Controller本质就是一个可视化有限状态机,但代码层面的状态机依然不可或缺,因为复杂的战斗逻辑、AI逻辑,单靠动画层根本表达不了。
我常用的状态机基类设计长这样:
public abstract class StateBase { protected StateMachine machine; public StateBase(StateMachine machine) { this.machine = machine; } public virtual void OnEnter() { } public virtual void OnUpdate() { } public virtual void OnExit() { } } public class StateMachine { private StateBase _currentState; private Dictionary<System.Type, StateBase> _states = new Dictionary<System.Type, StateBase>(); public void AddState(StateBase state) { _states[state.GetType()] = state; } public void ChangeState<T>() where T : StateBase { var next = _states[typeof(T)]; _currentState?.OnExit(); _currentState = next; _currentState.OnEnter(); } public void Update() { _currentState?.OnUpdate(); } }这套设计的核心思路是:每个状态是一个独立的类,状态之间的切换通过ChangeState触发,新加一个状态不影响已有逻辑,只需要AddState并调用切换即可。
举个例子,一个普通的近战单位AI,一般就这几个状态:待机、巡逻、追击、攻击、受击、死亡。用状态机的写法,每个状态的自逻辑内聚在一个类里,逻辑边界非常清晰。如果你需要新增一个“狂暴”状态,只需要写一个RageState类,然后在满足条件的地方调用ChangeState (),完事。
但这里有个实战中非常重要的细节,状态机里的状态切换条件不能散落在Update里到处判断。我见过很多同学把切换条件写在每个状态的OnUpdate里,结果状态一多,切换关系变成一张乱七八糟的网,你根本理不清谁是谁的上级。我的做法是:把切换条件的判断集中在一个地方,比如状态机的“Update”里先收集外部事件,再做统一裁决。或者用带“过渡条件”的状态机框架,每个状态声明自己能过渡到哪些状态以及对应的条件委托。
另外,如果你的需求只是动画切换,不是复杂逻辑切换,优先用Animator Layer和动画参数,不要用代码状态机重造轮子。只有逻辑复杂度超过动画表达范围时,才值得引入代码状态机。
3.3 观察者模式与事件中心:解耦通信的神兵利器
模块通信耦合这个事,是项目规模上来之后第一个爆发的问题。我记得有一段时间,项目里所有系统都持有其他模块的引用,UI要直接调战斗模块的方法,战斗模块要直接改玩家的血条UI,网络模块又要回调战斗模块……有一次改背包上限,崩了三个UI界面,排查了半天,最后发现是背包模块在Awake里调用了UI模块的方法,而UI模块还没初始化。
观察者模式就是为了解决这种“广播-订阅”关系诞生的。Unity里最直白的实现就是C#的event,或者一个全局事件中心。我带项目时习惯用后者,因为event在多模块解耦的同时,也带来一个问题:监听者的生命周期管理极其麻烦。你订阅了事件,如果忘了退订,或者对象销毁了但event列表里还留着引用,轻则内存泄漏,重则调用已销毁对象的委托直接抛异常。
我这里提供一个简化版事件中心的思路,核心点在于支持“带参事件”以及“弱引用或安全移除”:
public class EventCenter { private static Dictionary<string, System.Action<object>> _listeners = new Dictionary<string, System.Action<object>>(); public static void Subscribe(string eventName, System.Action<object> callback) { if (!_listeners.ContainsKey(eventName)) _listeners[eventName] = null; _listeners[eventName] += callback; } public static void Unsubscribe(string eventName, System.Action<object> callback) { if (_listeners.ContainsKey(eventName)) _listeners[eventName] -= callback; } public static void Broadcast(string eventName, object data = null) { if (_listeners.TryGetValue(eventName, out var callbacks)) callbacks?.Invoke(data); } }你用的时候,打怪模块只需要广播一个“OnMonsterKilled”事件,附带怪物ID和击杀坐标;UI的成就监听者订阅它,音效的监听者订阅它,任务的监听者订阅它。模块之间完全不知道对方的存在,这解耦效果立竿见影。
但观察者模式也有它的软肋——你很难从代码里直观地看出来“谁在听这个事件”。有时候一个事件被改了参数,所有监听的模块都要跟着测一遍,漏掉一个就是线上事故。所以我的建议是:
- 事件名不要用模糊的字符串,尽量定义一个静态常量类,集中管理所有事件名。
- 事件参数不要用一个裸的object,这样接收方每次都要强转还容易错。定义一个事件参数基类,每个事件有明确的参数类型。
- 订阅行为尽量在OnEnable里做,在OnDisable里退订,确保生命周期安全。
3.4 对象池模式:性能优化的必修课
对象池的原理你在leetcode上刷题也见过——预先创建一批对象,用的时候取,用完了还回去,避免反复创建销毁。在Unity里的价值尤其大,因为Instantiate和Destroy的成本不只是CPU,还会造成内存碎片和GC压力,在移动端上表现非常明显。
我的经验是,凡是频繁生成且生命周期短暂的对象,必须进对象池。典型的有子弹、粒子特效、飘字、小怪、掉落物。但也不是所有对象都需要池化,比如一次游戏只创建一次的UI面板,池化的收益基本可以忽略。
这里给一个比较通用的对象池实现思路:
public class ObjectPool<T> where T : Component { private Stack<T> _pool = new Stack<T>(); private T _prefab; public ObjectPool(T prefab, int preloadCount = 0) { _prefab = prefab; for (int i = 0; i < preloadCount; i++) { var obj = CreateNew(); obj.gameObject.SetActive(false); _pool.Push(obj); } } private T CreateNew() { var obj = Object.Instantiate(_prefab); obj.gameObject.SetActive(false); return obj; } public T Get() { T obj = _pool.Count > 0 ? _pool.Pop() : CreateNew(); obj.gameObject.SetActive(true); return obj; } public void Return(T obj) { obj.gameObject.SetActive(false); _pool.Push(obj); } }用起来也简单,射击的时候从池子里Get一颗子弹,设置位置和方向;子弹击中或者飞出边界后,把自身Return回池子。整个过程中不会产生GC压力,帧率稳定很多。
不过有几个细节得提醒你:
- 注意池子里的对象不要跨场景残留。项目里如果有场景切换,池里的对象如果挂在DontDestroyOnLoad节点下,场景A的对象跑到场景B就会出问题。我的做法是场景切换时清空池子,或者为每个场景维护独立的池子。
- 对象池不要设置得太大。预创建太多对象会拖慢启动时间,太少又频繁创建。我一般取项目里的峰值并发数量的1.2倍左右,具体数值靠性能测试调。
- 必须做“回收不彻底”的自查。有些对象被池化后,内部的协程、动画、Shader属性没有重置,就会出现“第二发子弹没有特效”“第三次使用后位置不对”的诡异问题。给每个池化对象写一个统一的Reset方法,Get时强制调用。
3.5 工厂模式:对象创建逻辑的“封装大师”
工厂模式和对象池天生一对。对象池负责“复用”,工厂负责“创建”。但在Unity项目中,工厂模式真正的价值是——隐藏对象创建的细节,屏蔽不同类型的构造差异。
举个例子,假设游戏里有近战怪、远程怪、精英怪、BOSS四种敌人,它们的预制体不同、初始属性不同、挂载的AI组件也不同。如果每次都在战斗模块里写:
GameObject go = Instantiate(meleePrefab); go.GetComponent<MeleeEnemy>().Init(level);那么战斗模块就要知道所有敌人的细节,一旦新增一个“法师怪”,你就得跑到战斗模块里加一条逻辑,这违反了开闭原则。用工厂模式改一版:
public interface IEnemyFactory { EnemyBase CreateEnemy(EnemyType type, Vector3 position, int level); } public class EnemyFactory : IEnemyFactory { private Dictionary<EnemyType, GameObject> _prefabMap; public EnemyBase CreateEnemy(EnemyType type, Vector3 position, int level) { GameObject prefab = _prefabMap[type]; var enemyObj = Object.Instantiate(prefab, position, Quaternion.identity); var enemy = enemyObj.GetComponent<EnemyBase>(); enemy.ReInit(level); return enemy; } }用工厂模式的好处是:新增敌人类型时,只需要注册对应的预制体到字典里,或者在工厂里加一个case分支,调用方(波次系统)的代码完全不用动。这个设计在“系统持续扩展”的场景下,省下的维护成本相当可观。
在我合作的某公司项目中,他们的怪物系统就是这个思路:策划在表格里配置怪物类型ID,工厂根据ID从配置表加载预制体、属性、AI模块,程序完全不需要为每个新怪物写创建代码。这就是工厂+数据驱动结合的威力。
实战心得:工厂模式很容易和对象池模式混用,创建逻辑放工厂,复用逻辑放池子。二者组合后的完整流程是:工厂Get对象时先尝试从池子拿,拿不到再走Instantiate。归还时主动回池。这套组合拳打出来,性能和扩展性就都有了。
4. 商业项目实践记录:从需求到落地的完整过程拆解
4.1 真实需求场景复盘
某跨平台项目曾遇到一个典型的“模式化开发需求”,我拿这个案例做一次全流程复盘。需求背景:游戏中有一种资源副本玩法,玩家每天可以挑战三次,副本中有若干波怪物,怪物死亡后掉落道具,副本结束弹出结算界面。
乍一看这是个稀松平常的玩法,但如果你往深处想,会发现里面嵌套着好几层设计问题:
第一层,波次怪物的生成机制。每一波的怪物种类和数量由策划配置表动态决定,有些波次会混合出三种以上的怪物,有些波次会出现精英怪。而且玩家在打怪过程中,副本内部可能会触发“暴走”事件,额外生成一波怪。
第二层,怪物行为的状态机。怪物的行为要区分待机、追击、攻击、受击、死亡。每种怪物攻击方式还不一样,近战怪近身攻击,远程怪保持距离释放技能。
第三层,系统通信。怪物被击杀时,副本计时器要更新、任务系统要计数、UI要飘字、音效要播放、成就系统要检查进度。这些模块之间不能相互硬引用。
第四层,结算流程。副本打完之后,需要按顺序播放胜利动画——发放奖励——刷新界面——弹出结算UI,期间还有可能被玩家点击跳过打断。
4.2 模式选型与架构设计
根据需求,我用一张表把方案的选型逻辑列出来:
| 模块 | 选择 | 理由 |
|---|---|---|
| 怪物生成 | 工厂模式 + 对象池 | 怪物种类差异大但是创建流程统一,生成频率高必须池化 |
| 怪物行为 | 状态模式 + FSM | 行为逻辑复杂且后续会加新怪、新行为,状态机天然抗扩展 |
| 跨模块通信 | 事件中心(观察者模式) | 战斗、UI、音效、任务系统互不引用,全靠事件解耦 |
| 副本流程 | 状态机(或用UniTask写流程编排) | 顺序逻辑清晰,方便插入打断点 |
这个架构的漂亮之处在于:每个模块只关心自己的事。波次系统根据配置调工厂生成怪物,不用关心怪物内部逻辑;怪物死亡时广播一个事件,不用关心谁在听;UI和音效各自订阅事件,不用关心战斗流程。
4.3 核心代码落地拆解
以怪物生成为例,我先把关键的生成器简化一下。这里要注意的是,代码层面做了两件很重要的事:读配置表驱动创建,以及把业务实体和预制体解耦。
public class MonsterSpawner : MonoBehaviour { [SerializeField] private MonsterFactory _factory; [SerializeField] private ObjectPool<MonsterBase> _pool; public void SpawnWave(WaveConfig waveConfig) { foreach (var item in waveConfig.monsterItems) { for (int i = 0; i < item.count; i++) { MonsterBase monster = _factory.CreateMonster(item.monsterId); monster.transform.position = GetSpawnPosition(); monster.OnDeath += HandleMonsterDeath; } } } private void HandleMonsterDeath(MonsterBase monster) { monster.OnDeath -= HandleMonsterDeath; _pool.Return(monster); EventCenter.Broadcast("OnMonsterKilled", monster.Data); } }这段代码的含金量在哪里?首先,创建怪物交给了工厂,把生成逻辑收敛到一个点;其次,怪物死亡后自动回池,不需要业务方关心“回收”这件事;最后还有,通过事件广播把“怪物死亡”这一信息广播出去,UI、任务、成就自己决定要不要订阅。代码整洁度和可扩展性都在线。
再贴一段怪物状态机的简化框架,你看下状态类内部是怎么组织的:
public class MeleeAttackState : StateBase { private MonsterBase _monster; public MeleeAttackState(StateMachine machine, MonsterBase monster) : base(machine) { _monster = monster; } public override void OnEnter() { _monster.PlayAnimation("Attack"); } public override void OnUpdate() { if (_monster.Target == null || !_monster.Target.IsAlive) { machine.ChangeState<IdleState>(); return; } if (!_monster.IsInAttackRange) { machine.ChangeState<ChaseState>(); } } }这套状态机在实际战斗中被验证过效果:近战怪的“追击-攻击-追击”来回切换,远程怪的“保持距离-蓄力-释放”组合,全都靠不同状态类的组合实现,逻辑清晰,调试时用Log打一下状态切换就能定位问题。
4.4 上线后复盘:模式设计的收益与成本
这个副本玩法上线后,我做过一次复盘。收益非常明显:第一,后续加了三种新怪物,没有改任何战斗模块代码,只新增状态类和在工厂里注册预制体;第二,性能抖动消失了,怪潮生成时帧率稳定;第三,UI和逻辑彻底解耦,策划调UI布局不碰逻辑代码。
但成本也有。状态类和工厂类的数量增多,文件查找变得困难。为了缓解这个问题,我们制定了文件夹组织规范:状态类按角色分文件夹,工厂类按业务模块分文件夹,公共组件放在共享目录。这些规范不是设计模式教的,是项目经验沉淀出来的。
5. 常见问题排查与避坑技巧实录
这部分内容是我最想分享的,因为设计模式的书里和文章不会写这些,全是实战踩出来的血泪。
5.1 状态机陷入“状态抖动”怎么办
最常见的问题是所谓状态抖动(State Jitter):两个状态之间的切换条件同时满足,导致同一帧内反复切换。比如追击状态切攻击状态的时候,攻击距离判断边缘正好抖动,角色就会疯狂抽搐。
我的解法:加一个“最小切换间隔”,状态机内部记录上一次切换的时间,如果距离上次切换小于一定帧数,直接忽略切换请求。另外,状态进入时把条件“锁住”一帧,避免OnEnter内部立刻触发条件判断。
5.2 事件中心导致的对象泄漏
前面用了事件中心后,还有个经典的坑:UI面板关闭时,如果忘了退订事件,闭包会一直持有面板的引用。这时候你在场景里隐藏了面板,但内存里它永远活着,重复打开关闭十几遍,内存飙到爆炸。
排查思路很直接:注册中心在Subscribe的时候,记录订阅对象的Type;Unsubscribe检查一次。或者直接写一段代码,在Editor模式下定期扫描所有事件监听器的引用对象是否已被销毁。我现在的项目里甚至上线了一个“事件泄漏报警器”,开发模式下打印警告。
5.3 对象池“回收不干净”导致数据错乱
池化的对象复用后,如果内部有上次使用残留的数据,就会产生各种幽灵Bug。比如怪物的血量没重置,第二次出来只有50%血;子弹的方向还是上一次的,发射角度全错。
解决方式是强制约定一个IReset接口,所有池化对象必须实现。Get的时候调用一次Reset,Return的时候调一次。这个接口不能省,省了就是给自己埋雷。
5.4 过度设计的边界感
最后想聊一个和设计模式本身关系不大,但又密切相关的实操问题:怎么判断当前需求值不值得应用设计模式。
我的判断标准有三个:
- 如果这个功能写死就再也不改,直接用最简单的方式,别上模式。
- 如果需求文档里明确出现“后续会有多种…”,“未来要支持自定义…”,那值得上模式。
- 如果团队里其他人对这套模式不熟悉,你需要先花半天时间做一个培训分享,否则代码写出来没人能维护。
设计模式是团队的沟通语言,不是个人炫技的资本。一个人写得飞起,全组看不懂,这个成本比代码本身的结构问题更致命。
6. 最后分享我的几个习惯
项目做多了,其实能沉淀下来的方法论就那么几条。我个人的体会是,设计模式的运用不是靠背,而是靠一次次重构练出来的。你第一次写一个战斗系统可能全是if-else,第二次写的时候发现相同逻辑出现多处,自然而然就抽出了状态机,第三次再遇到类似需求,你会直接开始规划状态接口——这个过程就是“模式化思维”的养成。
另外一个很实用的建议:每次做技术方案选型的时候,把你选择的模式名称写进代码注释里。比如在类注释上写“Class: MonsterStateMachine,此处使用状态模式管理怪物行为,便于后续扩展新状态”。这能帮你和后来的维护者快速建立上下文,比单独写一份文档有用得多。
对了,还有一个小技巧:Unity的新版本内置了部分设计模式的官方实现思路,比如Addressables里的资源句柄思想和对象池思路是互通的。你学设计模式的时候,如果能跟引擎底层的实现方式对上号,理解会深很多。比如协程本质上是一个迭代器模式,ResourceManager的资源加载本质上是一个代理模式,AssetBundle的依赖关系本质上是组合模式。
最后回答一个很多人会问的问题:设计模式要学到什么程度才能进商业项目?我的答案很简单——不要求你会背二十三种模式的全部定义,但以下六种必须能熟练写出且讲清原理:状态、观察者、单例、工厂、对象池、命令。这六种覆盖了我遇到的大概80%的业务场景。其他模式遇到了再学,带着问题学得更快。
就写到这里,希望对正在做Unity开发的你有点帮助。模式这东西,用起来才是你的,光看文章永远都是别人的。