很多Unity开发者把设计模式当成面试题来背,背完就忘,写代码的时候还是老一套。我这个系列前面十几篇一直在聊面向对象基础、SOLID原则、常用模式在Unity里的落地,今天这篇是设计模式部分的一个收尾,把那些“虽然不在面试题最前面,但在项目里真的能救命”的剩余模式一次性说清楚。适合那些已经能熟练用MonoBehaviour写功能、但项目越写越乱、想着手重构的Unity开发者,也适合准备进阶的中级程序看。文章会用大量Unity场景举例,尽量不扯理论空话。
1. 为什么还需要一篇“其他设计模式”
1.1 前面没讲完的那些模式去哪了
如果你系统翻过GoF那本经典书,会发现设计模式总共二十多种。很多入门的Unity教程只挑其中五六个讲,单例、工厂、观察者、状态、对象池,讲完就说“设计模式学完了”。但实际进到中大型项目里你会发现,光靠那几个模式根本不够用。比如策划要求技能支持“不同职业有不同伤害倍率”,你用if/else堆;要求新增一种敌人时能复用同一套出生流程,你直接复制粘贴;要求UI各面板之间能不互相引用就把数据传过去,你开始头疼。这些场景恰好需要的是另一批设计模式,我不觉得它们是“冷门”,只是平时科普得少。
这一篇我就打算把策略模式、模板方法模式、命令模式、中介者模式、备忘录模式这些真正在Unity里高频落地的模式补上,再给一个综合示例把多个模式串起来。这正好呼应面向对象的终极目标:让代码能够应对变化,而不是每次变化都翻出全部旧代码做手术。
1.2 筛选原则:游戏开发里真正能用上的模式
我见过很多开发者学设计模式最后学懵了,因为书上每个模式都讲得很抽象,跟游戏场景对不上。所以这一篇里我特意做了一次筛选,标准就三条。
第一,看场景是不是高频。像解释器模式,日常业务逻辑里基本用不到,编译器领域才常见,所以不展开。第二,看能否直接解决Unity的痛点。Unity里最常见的问题是MonoBehaviour脚本互相引用、Update里到处new对象产生GC、多个流程写得到处都是,这些痛点对应的模式我会重点讲。第三,看是否能在一次完整实践中串起来。单一模式看起来简单,真正难的是多个模式协同还不显得臃肿,所以我会专门给一个技能系统的综合示例。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频实用的行为型模式拆解
2.1 策略模式:把会变的行为独立出去
策略模式解决的是“同一个东西有多种做法,并且做法之间还能随时换”的问题。举个例子,角色受伤之后的伤害结算,普通攻击、暴击、属性克制,计算逻辑完全不同。多数人第一版直接写了switch:
csharp复制public float CalculateDamage(AttackType type, float baseDamage)
{
switch (type)
{
case AttackType.Normal:
return baseDamage;
case AttackType.Critical:
return baseDamage * 2f;
case AttackType.ElementAdvantage:
return baseDamage * 1.5f;
default:
return baseDamage;
}
}
这写法短期没问题,可一旦策划说“这版本加一个火属性克制,下版本再加一个水系抗性”,这个函数就被反复改,改出bug的几率一次比一次高。策略模式的处理方式是定义一个策略接口,每种算法单独一个类。
csharp复制public interface IDamageStrategy
{
float CalculateDamage(float baseDamage);
}
public class NormalDamageStrategy : IDamageStrategy
{
public float CalculateDamage(float baseDamage) => baseDamage;
}
public class CriticalDamageStrategy : IDamageStrategy
{
private float multiplier = 2f;
public float CalculateDamage(float baseDamage) => baseDamage * multiplier;
}
public class ElementAdvantageStrategy : IDamageStrategy
{
public float CalculateDamage(float baseDamage) => baseDamage * 1.5f;
}
然后把计算参数传进来,由外部决定当前角色用哪种策略。角色类只认策略接口,完全不关心具体算法。好处是新增一种伤害类型时,你不需要去动已经稳定的角色代码,只需要新增一个策略类,在装配处换一下引用就行。这就是面向对象里常说的“开闭原则”,对扩展开放,对修改关闭。
我在项目里实际使用时,一般不会让角色绑死一个策略,而是让策略可以动态替换。比如角色吃到“狂暴”Buff之后,伤害策略从普通换成暴击;Buff结束之后,再换回来。这样角色类的代码几乎不变化,变化的只有运行时策略引用,整个代码结构清爽得多。
2.2 模板方法模式:固定流程,留好扩展点
模板方法模式适合那种“流程骨架完全一样,只有部分步骤实现不同”的场景。我经常举的一个例子是敌人出生流程。不同种类的敌人出生时都要经历“出生特效→初始化属性→激活AI→掉落物品处理”这几个阶段,但每个阶段的具体实现不一样。
如果每个敌人脚本都自己写一套出生流程,会出现两个问题。第一是流程顺序容易乱,有的敌人先做属性初始化再播放特效,有的反过来,排查起来特别痛苦。第二是新增一种敌人时,开发者把旧代码复制一份改改参数,一旦出生流程本身要加一个阶段,所有敌人脚本都得跟着改,很容易漏。
模板方法模式把流程骨架放到父类里,把变化的步骤留成抽象方法让子类实现:
csharp复制public abstract class EnemySpawnFlow
{
public void Spawn(Vector3 pos)
{
PlaySpawnEffect(pos);
InitStats();
OnSpawned(pos);
}
protected abstract void PlaySpawnEffect(Vector3 pos);
protected abstract void InitStats();
protected virtual void OnSpawned(Vector3 pos) { }
}
csharp复制public class NormalEnemySpawnFlow : EnemySpawnFlow
{
protected override void PlaySpawnEffect(Vector3 pos)
{
// 生成普通传送特效
}
protected override void InitStats()
{
// 设置血量、攻击力等基础属性
}
}
Spawn方法是父类定义好的,子类不需要重写,所有敌人的出生顺序被强制统一。如果你仔细观察,会发现Unity的生命周期方法本身就是一种模板方法:MonoBehaviour调用Awake、OnEnable、Start的顺序由引擎固定,你只需要实现具体内容。理解了这个思想,你在自己写框架时就不会让子类随便重写流程方法,而是只留出“步骤实现”的口子,流程控制权始终握在父类手上。
需要提醒一句,别把Unity引擎的Awake和Start直接当成模板方法的步骤塞进去。模板方法应该通过一次显式的调用发起,比如上面的Spawn方法由出生管理器统一调用,这样可读性和可控性都更好。否则你在Awake里初始化一半又被外部调用一半,找问题时会非常头大。
2.3 命令模式:输入、撤销与重做的统一出口
命令模式是把“请求”本身变成一个对象,这样请求就能被记录、排队、撤销。Unity里最典型的应用是玩家操作记录、地图编辑器里的撤销重做、输入缓冲系统。很多人觉得Unity编辑器自带的Undo就够了,不需要自己实现,但你做玩法层面的撤销重做时,比如战棋游戏的棋子移动回退、建造游戏的建造撤销,Unity的Undo是管不到你自定义逻辑的,这时候命令模式就派上用场。
先定义命令接口:
csharp复制public interface ICommand
{
void Execute();
void Undo();
}
再定义一个移动命令:
csharp复制public class MoveCommand : ICommand
{
private Transform target;
private Vector3 from;
private Vector3 to;
public MoveCommand(Transform target, Vector3 from, Vector3 to)
{
this.target = target;
this.from = from;
this.to = to;
}
public void Execute()
{
target.position = to;
}
public void Undo()
{
target.position = from;
}
}
最后用一个命令管理器统一执行和撤销:
csharp复制public class CommandInvoker : MonoBehaviour
{
private Stack<ICommand> history = new Stack<ICommand>();
public void ExecuteCommand(ICommand command)
{
command.Execute();
history.Push(command);
}
public void UndoLast()
{
if (history.Count == 0) return;
ICommand command = history.Pop();
command.Undo();
}
}
这个结构最大的好处是,调用方完全不需要知道操作细节,只要递过来一个命令对象就行。而管理器通过一个栈自动记录历史,撤销时只要弹栈调用Undo。之前我在做一个建造类原型时,每个建筑的放置、拆除、旋转都封装成命令,玩家点一下撤销,系统自动回退最近一次操作,不到一个下午就做完了。
这里有个实操要点:命令对象不要存太多MonoBehaviour的强引用。如果命令在栈里存了很久,而场景里那个角色已经被销毁,撤销时就会访问空引用。我的习惯是命令里只存“必要的最小状态”,能存坐标就存坐标,能存ID就存ID,执行时再根据ID去查对象。
3. 几个改动不小但非常值得掌握的模式
3.1 对象池模式:消灭高频GC与瞬时卡顿
严格来说对象池不是GoF那23种模式之一,但它在Unity里实用到不提不行。普通做法里,子弹、敌人、伤害飘字都是Instantiate生成,销毁时Destroy。这两个API一旦高频调用,会产生大量堆分配和堆垃圾,触发GC时就会看到明显掉帧,尤其是移动端。
对象池的思路很简单:资源不真正销毁,而是隐藏起来,下次要用时直接取出激活。我写过一套简单的泛型对象池:
csharp复制public class ObjectPool<T> where T : Component
{
private Stack<T> pool = new Stack<T>();
private T prefab;
private Transform parent;
public ObjectPool(T prefab, int preloadCount, Transform parent)
{
this.prefab = prefab;
this.parent = parent;
for (int i = 0; i < preloadCount; i++)
{
T item = CreateInstance();
item.gameObject.SetActive(false);
pool.Push(item);
}
}
public T Get()
{
T item = pool.Count > 0 ? pool.Pop() : CreateInstance();
item.gameObject.SetActive(true);
return item;
}
public void Release(T item)
{
item.gameObject.SetActive(false);
pool.Push(item);
}
private T CreateInstance()
{
T item = Object.Instantiate(prefab, parent);
item.name = prefab.name + "_" + pool.Count;
return item;
}
}
用的时候池子固定预加载一批对象,比如在关卡开始时就创建30颗子弹挂在那里。运行中Get一个出来,用完Release回去,全程没有Instantiate和Destroy,GC压降非常明显。
但不是所有东西都适合对象池。我踩过的坑是给一些项目里只生成一次的UI弹窗也硬套对象池,结果为了维护池子的状态代码量比原来还多,收益却没多少。对象池最适用的特征是三个:对象生命周期短、生成销毁频繁、创建成本高。三个条件至少占两个,池化才划算。
另外要注意,对象从池子里取出来时,必须确认它的状态被完整重置。子弹取出来还是上一次残留的尾迹,特效取出来还在播上一次动画,这类bug很隐蔽。我一般在对象上挂一个Reset接口,Release时把速度、旋转、贴图、位移全部清零,Get时再统一初始化。
3.2 中介者模式:UI模块通信的解耦利器
中介者模式适合解决“对象和对象之间互相引用,最后变成蜘蛛网”的问题。项目里最典型的是UI系统。背包面板、商城面板、角色面板、任务面板,模块多了以后会出现A引用B,B引用C,C又引用A的情况。每次改一个面板,至少牵连两三个面板一起改动,非常难受。
我常用的方案是做一个轻量的UIMediator,专门负责跨模块的事件广播:
csharp复制public class UIMediator : MonoBehaviour
{
public static UIMediator Instance { get; private set; }
public event Action<int> OnCoinsChanged;
public event Action<string> OnItemSelected;
private void Awake()
{
Instance = this;
}
public void NotifyCoinsChanged(int coins)
{
OnCoinsChanged?.Invoke(coins);
}
public void NotifyItemSelected(string itemId)
{
OnItemSelected?.Invoke(itemId);
}
}
商城面板购买成功时调用UIMediator.Instance.NotifyCoinsChanged(newCoins),它根本不知道谁会关心这件事。背包面板在OnEnable里订阅事件,在OnDisable里取消订阅,收到通知后自己去刷新显示。这样商城和背包之间没有任何直接引用,想删掉一个面板,另一个面板完全不受影响。
但中介者模式我必须提醒一句“别滥用”。它解决的是跨模块通信,不是模块内部通信。同一个面板内部的两个子组件之间传值,直接引用方法比绕中介者更清晰。我之前见过有项目把每个按钮点击都通过中介者转发,结果想找“点击购买之后到底执行了什么”,得在事件注册列表里面翻半天,调试成本极高。
中介者的生命周期也要注意。UIMediator这种单例如果场景切换时被销毁,其他面板晚一点注册事件时就会拿到空引用。我的做法是把它放在独立的场景管理对象上,用DontDestroyOnLoad常驻,或者在场景里做成“谁用谁检测”的懒加载单例,确保事件注册时它一定还在。
3.3 备忘录模式:存档系统的自然解法
备忘录模式用来保存和恢复对象状态,Unity里最常见的对应物就是存档系统。这个模式有三个角色:发起者Originator是要保存状态的对象,备忘录Memento是状态快照,管理者Caretaker负责保存快照。
对于一个角色存档,你想保存等级、血量、位置,最直接的做法是定义存档数据结构:
csharp复制[System.Serializable]
public class GameSaveData
{
public int playerLevel;
public int hp;
public Vector3 position;
}
GameSaveData就是备忘录,角色类里的存档方法生成快照,读档方法应用快照。这样做的好处是存档逻辑和业务逻辑分离,角色类不需要关心JSON怎么序列化、文件存到哪里,管理者只管把快照写成文件。
用备忘录模式有一个容易踩的坑:快照里不要直接存UnityEngine.Object引用。比如你为了图省事,在存档里直接存了角色的贴图、场景中某个物品的引用,看起来序列化时不会报错,但游戏重启后场景重新加载,旧引用全部失效,读档时一堆Missing。我自己的习惯是快照里只存数据层的值类型和基础类型,像装备ID、节点名称、坐标,读档时再根据这些数据去查找对应的资源。
如果存档数据结构特别大,还可以考虑给备忘录加一个压缩或版本字段,方便以后存档结构升级时做兼容。这算是对备忘录模式的一种务实扩展,Unity里很多游戏存档文件开头都会写一个version,就是这个原因。
4. 一套完整示例:设计一个可复用的技能系统
4.1 系统结构与模式分工
上面每个模式单独看都简单,但项目里真正需要的是一个组合方案。这里我就拿一个比较完整的技能系统来演示多个模式怎么配合。
技能系统会有这些需求:技能释放有固定流程但不同技能流程细节不同;伤害计算需要支持暴击、属性克制;放技能时要产生特效和伤害飘字,特效不能反复生成销毁;技能冷却需要通知UI更新;如果要回放或撤销操作,技能命令需要能记录。
我分配的模式是这样的:
- 模板方法模式:定义技能释放流程,包括检查消耗、播放动作、生成伤害、产生特效、结算冷却。不同技能通过子类覆写具体步骤来完成差异化。
- 策略模式:伤害结算部分,把“普通伤害”“暴击伤害”“属性克制伤害”封装成独立策略,技能系统本身不关心具体计算方式。
- 命令模式:玩家每次按键触发技能时,封装成一个命令对象,交给Invoker执行,同时压入历史栈,方便做回放或调试。
- 对象池模式:技能特效和伤害飘字全部通过对象池管理,避免高频率技能释放导致GC。
- 中介者模式:技能冷却结果和金币消耗,通过UIMediator广播给UI,技能模块不直接引用任何UI面板。
这套结构最大的价值是,新加一个技能时,你只需要新建一个继承技能流程的子类,再给这个技能配好伤害策略;特效、飘字、UI刷新全部走公共通道,不需要动旧逻辑。我实际做下来,新增一种技能的平均改动量大约只有原来直接堆逻辑时的三分之一。
4.2 核心代码实现
先看模板方法的技能流程骨架:
csharp复制public abstract class SkillBase
{
protected SkillContext context;
public void ExecuteSkill()
{
if (!CanUse()) return;
ConsumeCost();
PlayAnimation();
SpawnEffect();
ApplyDamage();
StartCooldown();
}
protected abstract bool CanUse();
protected abstract void ConsumeCost();
protected virtual void PlayAnimation() { }
protected abstract void SpawnEffect();
protected abstract void ApplyDamage();
protected abstract void StartCooldown();
}
ExecuteSkill是模板方法,流程顺序被固定住了。接下来是策略模式的伤害结算:
csharp复制public interface IDamageStrategy
{
float CalculateDamage(float baseDamage);
}
public class SkillDamageNormal : IDamageStrategy
{
public float CalculateDamage(float baseDamage) => baseDamage;
}
public class SkillDamageCritical : IDamageStrategy
{
public float CalculateDamage(float baseDamage) => baseDamage * 2.5f;
}
技能子类在ApplyDamage时调用策略:
csharp复制public class FireballSkill : SkillBase
{
private IDamageStrategy damageStrategy;
public FireballSkill(SkillContext context, IDamageStrategy damageStrategy)
{
this.context = context;
this.damageStrategy = damageStrategy;
}
protected override void ApplyDamage()
{
float damage = damageStrategy.CalculateDamage(context.baseDamage);
// 对目标应用伤害
}
protected override void SpawnEffect()
{
GameObject effect = EffectPool.Instance.Get();
effect.transform.position = context.target.position;
// 播放结束后归还池子
}
protected override bool CanUse() => context.remainingCooldown <= 0f;
protected override void ConsumeCost() { /* 消耗魔法值 */ }
protected override void StartCooldown() { context.remainingCooldown = 3f; }
}
火球术、冰霜术、雷电术,每种技能都只要新建一个类,覆写方法。比如雷电术需要多一段“目标附近的敌人收到链式伤害”逻辑,就在子类里补上,父类的流程完全不用动。
命令模式负责技能触发的统一入口:
csharp复制public class SkillCommand : ICommand
{
private SkillBase skill;
public SkillCommand(SkillBase skill)
{
this.skill = skill;
}
public void Execute()
{
skill.ExecuteSkill();
}
public void Undo()
{
// 如果技能系统支持取消效果,这里实现回退逻辑
}
}
玩家按下技能键时,InputManager构造SkillCommand,交给CommandInvoker执行。这样所有技能都能被统一记录,之后无论是做战斗回放、网络同步演示还是调试,都能从命令历史里拿到完整操作序列。
4.3 参数选择与运行效果
这套系统里我最想强调的一个参数是对象池的预加载数量。特效池该预加载多少个,不能拍脑袋。我一般用公式估算:预加载数量 = 技能最大同时释放频率 x 特效平均存活时间。如果你的特效平均播放1.5秒,每秒最高释放5个技能,那池子至少准备8到10个才够。预加载太少,池子会在高峰期动态CreateInstance,性能优势打折;预加载太多,关卡启动时白白创建一堆不用的对象,内存浪费。
实际操作时我还加了一个“池子上限”保护。Release时检查池子大小,如果已经超过预设上限,直接把对象销毁而不是放回去。这样可以让池子自动适应压力,不会因为某波怪特别多而让池子无限膨胀。
这套系统做完之后,最直观的变化是战斗场景的GC Allocation基本消失了。技能释放高峰期,Profile里的Heap Allocation曲线从原来的锯齿状变成一条平线。同时,新增技能的代码量下降非常明显,我团队里一个刚开始接触这些模式的初级程序,在理解结构之后用这套方案加了三个新技能,没有改动任何旧类。
5. 设计模式在Unity中的常见问题与排查技巧
5.1 模式套用过度,代码反而更难维护
我见过不少开发者,学会设计模式之后就像手里多了把锤子,看什么都是钉子。一个简单的攻击逻辑,非要拆成策略模式加工厂模式加命令模式,结果类数量是原来的五倍,查找逻辑要跨七八个文件,最后连他自己都忘了怎么串起来的。
我的判断标准很简单:先写直白代码,出现“重复”或者“频繁变更”再上模式。第一种情况,同一段逻辑已经复制粘贴出现两三个地方,这时候抽象才有实际收益。第二种情况,需求方明确说了这部分逻辑大概率还要改很多次,比如伤害计算和技能流程,那提前用策略和模板方法就值得。如果只是固定一个功能,很可能不会再变,就别折腾了。
另外,模式引入之后命名要清晰。接口就叫IDamageStrategy,类就叫FireballSkill,让看代码的人一眼就知道它在哪一层。最怕的是把模式当学术名词堆在类名里,什么SkillCommandMediatorBuilder,又长又难懂,反而破坏可读性。
5.2 生命周期绑定不清,缓存成了新的坑
设计模式引入后最容易出现的一类bug,是生命周期相关。UIMediator单例在场景切换时被销毁,而某些面板注册事件时单例还没创建;对象池挂在某个场景对象下,切场景之后池子里的对象全部丢失;命令历史栈里存着已经销毁的对象引用,撤销时直接抛空引用。
处理这类问题的原则是“谁管理生命周期,谁就负责清理”。场景常驻的对象用DontDestroyOnLoad,或者干脆做成独立的管理器;场景内对象池要监听场景切换事件,在切场景前把池子清空。命令历史栈在场景结束时要调用一次Clear,避免栈里的命令对象还持有旧场景引用。
这里我分享一个排查技巧。一旦发现某个模式化代码出现空引用,先在对应的Execute、Undo、Get、Release方法入口打一条日志,打印当前对象的activeSelf和场景名。通常几轮日志下来就能定位是“对象被提前销毁了”还是“引用根本没初始化”,不要直接一头扎进业务逻辑里翻。
5.3 排查技巧与效果验证
设计模式不是写完就完了,还要看它到底有没有解决性能和维护问题。我最常用的验收方式是三点。
一是在Profiler里看GC Allocation。跑一段固定流程,对比重构前后的Heap Allocation,如果对象池生效,分配量应该显著下降。二是在新需求来的时候计时。策划说“加一种会分裂的敌人”,你记录从需求确认到功能完成花了多久,如果模式化代码让这个时间明显缩短,说明架构是有效的。三是看改bug时牵扯到的文件数量。老代码改一个bug可能牵动四个脚本,模式化之后应该收窄到两三个类内部。
如果三项指标都没改善,那说明你的模式方案可能只是把复杂度从A文件挪到了B文件,没有真正降低耦合。这时候不要留恋架构,该简化就简化。
6. 速查表,以及我踩过的坑
6.1 哪些模式适合Unity,一句话速查
总结一下这篇涉及的模式,你可以把它当作日常开发的选型参考。
| 模式 | 解决问题 | Unity典型场景 | 推荐程度 |
|---|---|---|---|
| 策略模式 | 行为算法可互换 | 伤害计算、AI巡逻策略、技能效果 | 强烈推荐 |
| 模板方法模式 | 固定流程复用 | 敌人出生、关卡初始化、技能释放流程 | 强烈推荐 |
| 命令模式 | 请求可记录、可撤销 | 编辑器工具、操作回放、输入缓冲 | 推荐 |
| 对象池模式 | 高频创建销毁的性能问题 | 子弹、特效、飘字、敌人 | 强烈推荐 |
| 中介者模式 | 对象间复杂交互解耦 | UI跨模块通信、战斗事件广播 | 推荐 |
| 备忘录模式 | 状态保存与恢复 | 存档系统、快照、版本回退 | 推荐 |
每种模式都有自己的适用边界。对象池解决性能而非结构问题,命令模式解决操作记录问题,模板方法解决流程复用问题。选型时先问自己“当前最痛的点是什么”,再挑对应的模式,而不是把模式全部堆上去。
6.2 一个刚踩过的坑和我的体会
坦白说,我也走过一段弯路。之前带一个小项目时,为了让代码显得“高级”,我把所有UI交互全部改成了中介者事件转发。结果项目中期调试时发现,一个按钮点击之后,操控面板发事件、商城面板响应、商城面板又发事件、库存面板响应,整个链路拉得很长,出问题时根本不知道是哪个环节遗漏了订阅。
后来我复盘得非常干脆:跨模块通信才用中介者,模块内部简单方法直接调用。从那以后,同样的功能模块,调试效率提升很快,新人接手代码也不需要先画一张事件依赖图。
设计模式本身不复杂,复杂的是在该用的时候用、不该用的时候克制。这篇把“其他设计模式”里和Unity最相关的几个讲完了,剩下的就是你在项目里多试、多对比、多复盘。刚开始不熟练没关系,从一个技能系统或者一个UI模块开始练手,模式给你的收益会慢慢体现出来。
