设计模式这个东西,在Unity项目里是个挺微妙的话题。你说它重要吧,很多小项目从头到尾就用了个单例,跑得也挺好;你说它不重要吧,面试必问,项目一复杂,代码腐化速度第一个收拾的就是你。尤其是“其他设计模式”这个部分,往往是很多人的知识盲区——单例、工厂这些用得勤,但观察者、命令、状态、备忘录、中介者这些,平时写业务代码很少主动去碰,真到要用的时候又不知道从哪儿下手。这篇就把Unity里那些“不常用但关键时刻能救命”的设计模式捋一遍,结合游戏开发的实际场景讲清楚它们的应用价值,顺便把常见坑也一并解决掉。
我用Unity做客户端开发有十来年了,大大小小的项目都趟过。说实话,设计模式这东西,纯粹背概念一点用都没有,必须放到具体场景里才能理解它在解决什么问题。比如说观察者模式,如果你没接过一个“几十个UI界面都要监听同一份数据变化”的需求,你就很难体会事件系统有多香。再比如说对象池模式,你要是没被频繁实例化特效卡到掉帧,也不会明白“复用”这两个字在游戏优化里的分量。
这篇文章主要适合这几类人看:刚学完C#和Unity基础、准备进阶的初中级开发者,被面试官问过“你用过哪些设计模式”但答不出花来的求职者,以及在项目里代码越写越乱、正琢磨怎么重构的客户端程序员。我会从实际项目视角出发,不仅讲每个模式是什么,更会重点拆解它在Unity里的实现方式、为什么用这种方式、以及哪些坑是文档里不会写的。
1. 内容整体设计与思路拆解
1.1 为什么Unity项目特别需要“其他设计模式”
很多Unity开发者对设计模式的理解停留在“单例模式是全局访问”“工厂模式是创建对象”这个层级。这种理解在面对具体需求时是撑不起来的,因为Unity本身有一套自己的生命周期和组件系统,直接套用传统的面向对象设计模式,有时候不仅不能解决问题,反而会制造新的麻烦。
举几个很典型的例子。观察者模式在纯C#项目里用事件委托很容易做,但放到Unity里就要考虑GameObject销毁后事件是否还持有引用,处理不好就是内存泄漏。命令模式在处理玩家输入时很好用,可以轻松实现撤销、重做和按键重映射,但如果不考虑Input System的异步回调,代码就会变得非常割裂。状态模式在写角色行为AI时几乎是必选项,但Unity的MonoBehaviour生命周期和状态切换之间怎么协同,很多人第一次写都会栽跟头。
这就是“其他设计模式”存在的意义——它是为应对复杂度而生的工具箱。单例解决的是“全局唯一”的问题,工厂解决的是“创建过程复杂”的问题,但一个真实游戏项目里,还有大量问题是关于“对象之间怎么通信”“行为怎么切换”“历史状态怎么保存”的。这些问题如果不用合适的模式去组织代码,随着功能增加,代码耦合度会指数级上升,最后变成改一行崩三处的地狱现场。
1.2 模式选择背后的逻辑:少即是多
实际开发中,最怕的就是为了用模式而用模式。我刚带团队那会儿,有个同事把项目里所有系统都提成了接口,每个接口都配了一个实现类,还搞了个服务定位器,看起来架构特别“干净”,结果加个新功能要改十几个文件,维护成本比不用模式还高。
所以我在这篇文章里不会把24种设计模式全讲一遍,那没有意义。我要讲的是在Unity项目里真正有使用场景、能直接提升代码质量的六种模式:观察者模式、命令模式、状态模式、对象池模式、备忘录模式、中介者模式。这些模式有一个共同特征——它们都是在应对游戏开发里特别常见的那几类复杂问题,而不是为了体现“面向对象”这个概念而存在的。
选这几个模式还有一层考量:它们之间有很强的互补性。观察者模式解决通信,命令模式解决操作记录,状态模式解决行为切换,对象池模式解决性能瓶颈,备忘录模式解决存档回溯,中介者模式解决多系统协作。组合起来用,基本能覆盖一个中型Unity项目里70%以上的架构设计需求。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 观察者模式:Unity事件系统的正确打开方式
2.1 事件驱动到底在解决什么问题
观察者模式的核心思想很简单:当一个对象的状态发生变化时,所有依赖它的对象都能收到通知。在Unity里最常见的实现方式就是事件委托、UnityEvent和C#的event关键字。
但如果你只知道这个定义,那你还是没理解它真正的价值。观察者模式解决的是“反向依赖”的问题——数据的持有者不需要知道谁在关心它的变化,关心它的人自己来订阅通知。这在UI系统里尤其重要,比如玩家的血量变化,可能有血条UI、伤害数字、成就系统、任务追踪、音效管理器等多个系统需要响应,如果让角色类直接持有这些系统的引用,耦合度直接爆炸。用事件的话,角色类只需要声明一个OnHealthChanged事件,谁想监听谁自己订阅,互不干扰。
2.2 三种事件实现方式的选型对比
在Unity里实现事件驱动,常用的方案有三种:
| 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| C# event | 语法简洁,性能好,支持 +=/-= | 无序列化能力,编辑器里不可视 | 纯逻辑层,非MonoBehaviour类之间的通信 |
| UnityEvent | 可在Inspector里拖拽绑定,支持持久化监听 | 有额外开销,调试困难,GC压力较大 | UI按钮点击等需要可视化配置的场景 |
| 委托+自定义事件中心 | 解耦彻底,全局可访问 | 滥用会导致架构混乱,定位问题难 | 跨系统广播,全局性的游戏事件 |
实际项目里我推荐混合使用:模块内部的通信用C# event,跨模块的广播用事件中心。UnityEvent尽量少用,只保留给UI按钮这种要在Inspector里配的场景。原因很简单,UnityEvent的性能在频繁调用的时候会有明显压力,而且断点调试的时候追踪起来特别费劲,一旦事件链复杂起来,根本不知道是哪一步掉的链子。
2.3 事件系统的内存泄漏陷阱
这个坑值得单独拎出来说。在Unity里用事件,最经典的内存泄漏场景就是:A对象订阅了B对象的事件,然后A被销毁了,但B还活着,B触发事件时就会调到一个已经销毁的对象的方法。如果A是MonoBehaviour还好,调一下== null判断还能发现;如果A是纯C#对象,这个过程完全是静默的,内存泄漏就这么积累下来了。
解决这个问题,核心思路是两条。第一条是做到“谁订阅谁退订”,在OnDestroy里把所有订阅关系都解除。第二条是善用WeakReference或者更现代的IDisposable模式,让订阅关系可以随生命周期回收。我自己的习惯是写一个自定义的EventBus,内部用Dictionary<Type, Delegate>存储事件回调,同时在包装层处理监听者的生命周期,一旦监听者被标记为销毁就自动退订。这么做虽然多写一些代码,但能避免大量线上内存泄漏的问题。
提示:写事件监听时,一定要用
-=退订。这个道理谁都懂,但一忙起来就忘。后来我定了条团队规范——凡是在OnEnable里订阅的,必须在OnDisable里退订,而不是OnDestroy里。因为OnEnable/OnDisable是配对的,而且可以保证在非激活状态下不会被事件调用。
3. 命令模式:从输入系统到存档回放的通杀方案
3.1 命令模式在Unity里的典型应用
命令模式的定义是把请求封装成对象,这样你就可以用不同的请求对客户端进行参数化,并且支持请求的排队、记录和撤销。在Unity游戏开发里,这个模式最常见的三个应用场景是:
第一个是输入处理。玩家按“跳跃”键,不是直接让角色跳,而是创建一个“跳跃命令”交给处理器去执行。这样按键和动作之间就完全解耦了,改键位不需要动游戏逻辑。
第二个是撤销/重做系统。比如关卡编辑器里,每次操作都生成一个命令对象,命令对象里有Execute()和Undo()方法,撤销的时候只要调用Undo()再把它从栈里弹出去就行。
第三个是录像回放和AI模拟。把玩家的操作序列化存储下来,回放的时候按时间戳依次执行命令,就能实现类似“死亡回放”的功能。这个思路在《我的世界》这类建筑类游戏里用得很多,服务器端就是用命令模式做方块操作的记录和同步的。
3.2 一个可直接落地的输入缓冲实现
我拿“输入缓冲”这个具体需求来展示命令模式的落地方式。玩过格斗游戏或动作游戏的人应该知道,玩家搓招的时候经常会有“我明明先按了键但动作没出来”的情况。输入缓冲就是为了解决这种问题——把一段短时间内的输入缓存起来,等角色当前动作结束后立即执行最近的有效输入。
用命令模式实现的话,先定义一个抽象命令接口ICommand,它有Execute()方法。然后具体的角色动作,比如攻击、跳跃、防御,都实现这个接口。输入管理器负责监听玩家按键,将按键映射为具体的命令实例,存入一个环形缓冲区,同时给每个命令打上时间戳。角色状态机每帧检查缓冲区,把超时的命令丢弃,取出最新的有效命令执行。
csharp复制public interface ICommand
{
void Execute(PlayerController player);
}
public class AttackCommand : ICommand
{
public void Execute(PlayerController player)
{
player.PerformAttack();
}
}
public class InputBuffer
{
private Queue<(float timestamp, ICommand command)> _buffer = new();
private readonly float _bufferDuration;
public InputBuffer(float bufferDuration)
{
_bufferDuration = bufferDuration;
}
public void Push(float currentTime, ICommand command)
{
_buffer.Enqueue((currentTime, command));
}
public bool TryGetLatestValidCommand(float currentTime, out ICommand command)
{
while (_buffer.Count > 0)
{
var entry = _buffer.Peek();
if (currentTime - entry.timestamp > _bufferDuration)
{
_buffer.Dequeue();
continue;
}
command = entry.command;
_buffer.Clear();
return true;
}
command = null;
return false;
}
}
这段代码里的关键设计是缓冲区用了Queue结构,先进先出,然后每次取出的是最新的有效命令之前把老的都清掉。这么做的好处是玩家连招时不会出现“按了两下攻击,第二次攻击排队等第一次结束才执行”的生硬感,而是只响应最近的那一下输入。
3.3 命令模式跟Input System怎么配合
Unity新的Input System出来以后,很多人不知道事件回调和命令模式怎么结合。其实思路很清晰:Input System负责收集原始输入,触发performed回调,我们在回调里创建命令对象,投入命令队列或缓冲区。这里注意不要直接在performed回调里执行游戏逻辑,因为回调的时机在物理帧和渲染帧之间,状态不一定安全。正确做法是只做入队操作,等到Update里统一消费命令。
这样的好处是,你可以很轻松地实现按键重映射功能。玩家设置界面里改一个键位映射表,底层逻辑完全不用动,只要改“物理按键→命令”的映射关系就行。这也是命令模式“参数化请求”的本质——映射关系的修改不影响执行逻辑。
4. 状态模式:别再用疯狂的if else写角色行为
4.1 状态爆炸问题是怎么产生的
写游戏角色AI最原始的做法是在Update里写一堆if (state == Idle)、if (state == Running)这样的判断。刚开始还好,角色就三两个状态,逻辑清清楚楚。但一旦加入攻击、受击、防御、跳跃、下落、死亡这些状态后,就开始乱了。每个状态的进入条件、退出条件、状态内的行为全堆在一个方法里,变量一多,一不小心就把上一个状态的残留数据带到了下一个状态。
状态模式就是用来解决这个问题的。它的核心思想是把每个状态封装成独立的类,类里定义进入状态时做什么、状态中每帧做什么、离开状态时做什么。角色本身只持有一个当前状态对象的引用,切换状态时销毁旧状态、创建或激活新状态即可。这样一来,“角色当前的完整行为”就被封装到了一个类里,可读性和可维护性都会大有提升。
4.2 用FSM实现一个角色状态机的完整示例
我用一个简单的角色控制器来演示。先定义一个IState接口,包含Enter、Update、Exit三个方法。
csharp复制public interface IPlayerState
{
void Enter(PlayerController player);
void Update(PlayerController player);
void Exit(PlayerController player);
}
然后实现具体状态类。以待机状态为例:
csharp复制public class IdleState : IPlayerState
{
public void Enter(PlayerController player)
{
player.Animator.Play("Idle");
}
public void Update(PlayerController player)
{
if (player.Input.Horizontal != 0)
{
player.StateMachine.ChangeState(new MoveState());
}
if (player.Input.IsJumpPressed)
{
player.StateMachine.ChangeState(new JumpState());
}
}
public void Exit(PlayerController player) { }
}
状态机本身比较简单,维护一个当前状态引用就行:
csharp复制public class PlayerStateMachine
{
private IPlayerState _currentState;
public void ChangeState(IPlayerState newState)
{
_currentState?.Exit(_player);
_currentState = newState;
_currentState.Enter(_player);
}
}
4.3 状态模式在Unity里的高级用法与坑
基础的状态模式很容易理解,但Unity里的状态切换有几个细节必须处理到位,否则后面必出问题。
第一个是状态切换时机。不要在状态类的Update方法里直接实例化新状态对象,那样每帧都会创建新对象,GC压力会很大。可以给状态机加一个延迟切换队列,在Update结束时统一处理状态变更。
第二个是状态与动画的衔接。Unity的动画系统有CrossFade、Trigger、Bool等机制,状态切换和动画播放必须精确同步。我见过太多项目,状态已经切换了,但动画还在播上一段的结尾帧,表现就很奇怪。建议状态机的ChangeState方法里统一处理动画层的优先权和淡入淡出逻辑,而不是在每个状态里各写各的。
第三个是子状态机的需求。简单角色用扁平的状态机就够了,但像Boss战这样需要“二阶段”“三阶段”切换的,就需要用分层状态机,把每个阶段做成独立的子状态机。Unity官方其实有StateMachineBehaviour,配合AnimatorController可以做可视化状态机,但它跟代码状态机是两套思路,各有利弊,用的时候要心里有数。
注意:在状态对象内部引用PlayerController,实际上是一种「状态持有上下文」的写法。用得好可以极大简化状态之间的数据共享,用不好就容易出现循环依赖。我的经验是:高频共享的数据放到PlayerController上,只被单一状态使用的数据放在状态类自己的字段里,不要试图让状态共享太多变量,否则又回到“大杂烩”的老路上去了。
5. 对象池模式:把游戏性能从卡顿边缘拉回来
5.1 频繁实例化为什么是性能杀手
学过Unity基础的人都知道,Instantiate和Destroy是开销很大的操作。它们要做的事情包括但不限于:分配内存、初始化组件、注册到场景的各种管理器、触发引擎内部的回调。频繁执行,后果就是帧率波动、GC峰值、甚至出现卡顿。这在移动端上尤其明显,低端机的内存带宽和CPU性能都有限,一次几十个物体的批量实例化就能把帧率打到个位数。
子弹、敌人、特效、飘字、拾取物——这些高频率生成和销毁的对象,都应该考虑用对象池来管理。对象池的核心思想是:用空间换时间,提前创建一批对象放在池子里,要用的时候从池子里拿,用完了还回去继续复用,而不是销毁。
5.2 从最简单的池到泛型池
最简单的对象池实现是用一个Queue存放实例。取对象时如果队列里有,就取出激活;如果没有,就实例化一个新的。归还时把对象扔回队列,同时取消激活。
但实际项目里,我更推荐写一个泛型对象池类,最好能支持预制体绑定、预热和扩展上限。核心接口大概长这样:
csharp复制public interface IPool<T>
{
T Spawn();
void Despawn(T item);
void Prewarm(int count);
}
为什么一定要Prewarm预热?因为池子的目标就是控制实例化发生的时机和频率。与其在游戏运行中途卡一下来创建一批对象,不如在加载界面或场景开始时提前把对象创建好。就拿射击游戏里的子弹来说,玩家开枪时才创建子弹,因为一瞬间可能同时存在几十发,每发都走Instantiate,肯定卡;如果开局就预创建100发放进池子,开枪时只是取出激活,代价小到可以忽略。
5.3 对象池配合Addressables使用的最佳实践
现代Unity项目都倾向于用Addressables做资源管理,这时候对象池也要跟着适配,不能直接用Resources.Load或者预制体引用,那样会破坏整个资源系统的统一性。
我的做法是让对象池持有AssetReference的引用,从Addressables.InstantiateAsync加载实例,然后正常走池化流程。关键点是释放策略:如果池子里的对象超过一定数量还没被使用,就主动Addressables.ReleaseInstance释放掉,防止内存膨胀。
还有一个容易被忽视的细节:池子里的物体在场景卸载时必须统一释放。否则场景A加载时池子里有一批对象,切到场景B后这些对象还在占用内存,容易出资源加载冲突。
对象池模式在面试里几乎是必问的。面试官一般会先问你了解哪些设计模式,你提到对象池后,他会追问“池子的扩容策略怎么设计”“对象池能解决GC问题吗”。这些问题都不需要背标准答案,关键是展示你对Unity底层机制的思考。比如GC问题,对象池能减少堆分配,但池子本身如果管理不当,遍历和查询也可能产生GC。用Queue避免List随机删除的拷贝开销,用struct存储空闲对象信息,这些都是实操中积累下来的优化点。
6. 备忘录模式:游戏存档与回滚功能的优雅实现
6.1 为什么不推荐序列化整个对象
最早做存档功能的时候,我图省事直接把MonoBehaviour的字段用JsonUtility序列化到本地。这种做法写起来快,但问题是一旦结构调整,比如改了字段名、删了字段、加了字段,老存档就全部作废了。玩家更新版本后存档丢失,属于事故级别的问题。
备忘录模式针对的就是这种“保存和恢复对象内部状态”的需求。它把存档内容抽象成一个“备忘录对象”,这个对象知道怎么把数据保存到外部存储、怎么从外部存储恢复数据,但它不暴露内部数据给无关对象。
6.2 一个结构清晰的存档系统设计
如果用备忘录模式来组织存档逻辑,我会这样分层。存档数据定义了PlayerData、LevelProgressData、InventoryData等纯数据类,这些类只包含[Serializable]字段,不包含任何游戏逻辑。存档管理器负责把多个数据对象打包、序列化、写盘;具体的游戏系统只负责提供数据快照和从快照恢复数据。
csharp复制[Serializable]
public class PlayerDataSnapshot
{
public int health;
public Vector3 position;
public int currentLevel;
public float playTime;
}
public class PlayerSaveSystem
{
public PlayerDataSnapshot Capture()
{
return new PlayerDataSnapshot
{
health = _player.Health,
position = _player.transform.position,
currentLevel = _player.CurrentLevel,
playTime = _player.PlayTime
};
}
public void Restore(PlayerDataSnapshot snapshot)
{
_player.Health = snapshot.health;
_player.transform.position = snapshot.position;
_player.CurrentLevel = snapshot.currentLevel;
_player.PlayTime = snapshot.playTime;
}
}
这套设计的核心价值是什么?Capture和Restore各自独立,数据对象和游戏逻辑完全分离。如果你以后需要加加密、压缩、云存档,都只需要改存档管理器这一层,底层的数据类和游戏系统不需要动。这和备忘录模式的“负责人不破坏封装性”的理念完全一致。
6.3 在不同存档格式之间如何取舍
Unity的存档格式无非就是三种:JsonUtility、Newtonsoft.Json、二进制。
JsonUtility的问题想必很多人都清楚——对Dictionary、多态、属性这些支持很差。Newtonsoft.Json功能全,但打包体积大,需要在IL2CPP下做link处理,而且它的反序列化性能有波动。二进制方案比如BinaryFormatter在Unity里已经标记为过时了,官方不推荐;如果要走二进制,建议用MemoryStream加自研的字段写入逻辑,或者用MessagePack这类第三方序列化库。
我个人在商业项目里更推荐Newtonsoft.Json,原因很简单:第一,它的破坏性变更支持好,加的字段有默认值,删的字段不会导致反序列化直接崩溃;第二,它的JsonPath能力可以用在存档版本迁移上,老版本存档升到新版本时可以定点修改,不需要全量解析。妥协方案是核心帧数据用二进制存,配置性数据用JSON存,这样读取效率和可维护性可以兼顾。
提示:做存档系统的时候,强烈建议在数据结构里加入
version字段。哪怕你现在还觉得不需要版本迁移,等游戏上线后就会感谢当初留了这个字段。每次改数据结构,顺手写一个版本迁移函数,把旧数据升到新结构,这个事情成本极低,收益极高。
7. 中介者模式:让多个系统协作时不再互相纠缠
7.1 多个系统互相引用的噩梦
游戏项目里经常出现这种景象:UI系统拿到玩家数据后,要通知任务系统推进进度,然后又去调成就系统解锁成就,接着还要给音效管理器发指令播放音效。如果你直接在代码里写UIManager.Instance.TaskSystem.Progress++然后AchievementManager.Instance.Check(),系统之间的耦合就是一张剪不断理还乱的蜘蛛网。
中介者模式的做法是引入一个“中间人”对象来协调这些系统之间的交互。各个系统不再直接引用彼此,而是把意图发送给中介者,由中介者决定谁需要响应这个消息。这个中介者实际上就是一个中心化的消息路由。
7.2 用事件总线实现团队级的中介者架构
在Unity项目里,我通常会用一个轻量级的事件总线来做这个中介者。它不是完整的ECS框架,也不需要引入额外的库,只需要一个静态类加几个泛型方法就够了。
csharp复制public static class EventBus
{
private static readonly Dictionary<Type, Delegate> _handlers = new();
public static void Subscribe<T>(Action<T> handler) where T : struct
{
if (_handlers.TryGetValue(typeof(T), out var del))
{
_handlers[typeof(T)] = Delegate.Combine(del, handler);
}
else
{
_handlers[typeof(T)] = handler;
}
}
public static void Unsubscribe<T>(Action<T> handler) where T : struct
{
if (_handlers.TryGetValue(typeof(T), out var del))
{
_handlers[typeof(T)] = Delegate.Remove(del, handler);
}
}
public static void Publish<T>(T message) where T : struct
{
if (_handlers.TryGetValue(typeof(T), out var del))
{
((Action<T>)del)?.Invoke(message);
}
}
}
这个事件总线用起来很简单。比如玩家完成任务,任务系统发布TaskCompletedEvent{ TaskId = "xxx" },成就系统在初始化时订阅这个事件类型,收到后处理自己的逻辑。两个系统完全不认识对方,但配合默契。
7.3 中介者模式的过度设计风险
中介者模式虽然好用,但它有一个绕不开的陷阱:过度解耦。如果所有跨系统通信都走事件总线,代码里的“显式调用”全变成了“隐式广播”,你很难从一个系统的代码里看出谁在调自己、自己调了谁,调试和代码审查的难度都会上升。
我见过一个项目,一个简单的任务奖励发放流程,前后要发布六个事件,涉及八个系统。排查问题的时候,只能在事件总线的断点里一个一个看事件的流向,效率极低。后来我们给团队定了规矩:同模块内部的调用一律直连,不允许绕道事件总线;只有跨模块、一对多的交互才用事件。另外,复杂的事件流要配上注释文档,标明发布方和订阅方。
这里再分享一个经验:如果跨系统交互是“请求-响应”型而不是“通知-监听”型,直接用事件总线反而别扭,不如定义一个统一接口让中介者来调用。中介者模式可以做成集中式的处理器,而不是只有事件广播这一种形态。
8. 实战经验小结:面试题里的设计模式与项目重构的建议
8.1 面试官问Unity设计模式时到底在考什么
Unity面试题里设计模式永远有一席之地,但面试官不是要你背出24种模式的UML图,而是想看你怎么用这些模式解决实际问题。我整理了几个高频问题,给你参考。
“你在项目里用过哪些设计模式?”这个问题别只说名字,要结合案例讲。比如“我用过观察者模式,当时是要做一套成就系统,玩家击杀、拾取、升级等行为都要触发成就检查,如果用硬引用去通知会非常难维护,所以做了一个事件中心,各项行为只要发布对应事件,成就系统订阅事件做处理。打了个稀有的怪,Quest事件和Achievement事件同时被触发,但两个系统完全解耦。”
“状态模式和策略模式有什么区别?”这题问得也很频繁。核心区别是状态模式的“状态”是由内部条件驱动的,通常状态之间会互相转换;而策略模式的“策略”是由外部选择的,策略之间互相不知道也不关心。写的时候可以把策略模式看作“可替换算法”,状态模式看作“可切换行为”。
“你怎么防止事件系统内存泄漏?”这类问题就是考你有没有实战经验。把前面提到的退订原则、生命周期管理、弱引用方案讲清楚,基本就是满分回答。
8.2 项目重构时怎么渐进式引入设计模式
对老项目做架构优化,最忌讳的是“推到重来”。我建议的做法是新写的系统和模块先应用合适的设计模式,旧代码保持稳定,不要因为“用了状态模式更优雅”就立刻去改所有角色控制器。架构重构要跟着需求走,每次新增功能,顺手用新模式重构相关的那一小块,积少成多,风险可控。
还有一个比较实际的做法:写新功能前先在纸上画一下涉及哪些类、它们之间的关系是“一对一硬引用”还是“一对多通知”,如果是后者,就说明适合引入观察者模式或中介者模式。这样设计模式不是硬加进来的,而是顺着代码的“气味”自然长出来的。
8.3 技术选型之外的“软技能”提醒
这些年招人面试,我发现一个规律:会用设计模式的人不少,但能把设计模式用对的人不多。所谓“用对”,不只是技术层面,还包括对项目复杂度的敏锐度。一个小游戏根本没有复杂交互,你偏要上状态机加命令模式加事件总线,反而让后来接手的人一头雾水。
所以我最后的建议是:把这篇文章里讲的模式都理解透、在测试项目里都练过一遍,然后养成“先判断复杂度,再决定用不用设计模式”的习惯。能用三行代码解决的,别为了模式写三十行;模式是帮你管理复杂度的工具,不是展示技术水平的装饰品。理解了这一层,你在Unity面向对象这条路上的认知就扎实了。
