1. 项目概述:为什么要在Unity里搞懂模板方法模式?
如果你在Unity里写过一些稍微复杂点的系统,比如战斗流程、UI管理器或者资源加载模块,大概率遇到过这种场景:一套流程的骨架是固定的,但其中几个关键步骤的具体实现,会因为角色职业、UI类型或者资源格式的不同而千差万别。新手最常见的做法是什么?复制粘贴一大段代码,然后修修改改。结果就是,改一个Bug要动好几个地方,加一个新功能又得再复制一遍,代码很快就变成了一团乱麻。
模板方法模式,就是专门来治这个“病”的。它不是什么高深莫测的黑科技,而是一种极其务实的设计思路。简单说,它把一套固定的算法骨架(比如“打开界面-加载数据-刷新显示-播放动画”)定义在一个父类(或抽象类)里,然后把其中可以变化的具体步骤,抽象成虚方法或抽象方法,留给子类去按需实现。这样,算法的整体结构被稳定地封装起来,避免了重复,而变化的细节则被隔离到各个子类中,易于扩展和维护。
在Unity开发中,这个模式的应用场景多到数不过来。比如,所有敌人都要经历“出生-寻敌-攻击-死亡”这个流程,但骷髅兵和法师的攻击方式能一样吗?再比如,你做一个任务系统,“接取-执行-提交-领奖”这个流程是固定的,但“执行”这一步,可能是打怪、收集物品,也可能是对话,每种的实现逻辑都不同。用模板方法,你只需要在父类Task里把流程框死,然后为“打怪任务”、“收集任务”分别创建子类,重写那个“执行”方法就行了。代码清晰,职责分明,新人接手也能一眼看懂整个系统的运转逻辑。
所以,今天我们不空谈理论,就结合C#和Unity的特性,把这个模式掰开了、揉碎了讲清楚。从为什么需要它,到怎么手把手写出来,再到Unity里有哪些“坑”和高级玩法,最后分享几个我实际项目里用这个模式优化了代码结构的真实案例。目标是让你看完之后,不仅能看懂,更能立刻在项目里用起来,实实在在地提升代码质量。
2. 核心思路拆解:模板方法模式的“道”与“术”
理解一个设计模式,最关键的不是背下它的UML图,而是搞明白它解决什么痛点,以及它设计思路背后的权衡。模板方法模式的核心,可以概括为“好莱坞原则”:别打电话给我们,我们会打给你(Don‘t call us, we’ll call you)。意思是,父类掌控着程序流程的主动权,子类只需要被动地提供某些步骤的具体实现。父类是导演,子类是演员。
2.1 模式的结构与角色
一个标准的模板方法模式通常包含两类角色:
抽象类(AbstractClass):这是模式的“大脑”和“骨架”。
- 它定义了一个模板方法(Template Method):这是一个具体方法,里面用固定的顺序调用了一系列其他方法。这个方法通常被声明为
final(在C#中是sealed)以防止子类重写整个算法结构,确保流程的稳定性。在Unity的C#中,我们常用sealed关键字,或者简单地通过不标记为virtual来实现。 - 它定义了一系列抽象操作(Primitive Operations):这些是模板方法中所调用的步骤。它们被声明为
abstract(抽象方法)或virtual(虚方法)。抽象方法强制子类必须实现,虚方法则允许子类选择性地覆盖(提供默认实现时)。
- 它定义了一个模板方法(Template Method):这是一个具体方法,里面用固定的顺序调用了一系列其他方法。这个方法通常被声明为
具体类(ConcreteClass):这是模式的“手”和“脚”。
- 它继承自抽象类。
- 它负责实现(或重写)父类中定义的抽象操作,为算法的某些步骤提供具体的实现。一个具体类只需要关心它需要变化的那部分,无需理会整体流程。
2.2 C#与Unity中的实现特性
在C#里实现这个模式,有几个语言特性我们用得最顺手:
abstract关键字:用于声明抽象类和抽象方法。抽象类不能实例化,抽象方法没有方法体,强制子类实现。virtual和override关键字:virtual声明一个方法可以被子类重写,override用于子类中明确表示要重写父类的虚方法。这给了我们灵活性,子类可以选择性地改变某些步骤。sealed关键字:可以用于方法,防止该方法在派生类中被进一步重写。我们通常用它来“锁死”模板方法本身。
在Unity中,我们还要特别注意MonoBehaviour的生命周期。Unity的Start(),Update(),OnEnable()等本身就是一种“模板方法”——引擎定义了它们何时被调用,你只需要在里面填写具体逻辑。当我们自己的模板方法需要与这些生命周期协同工作时,设计要格外小心,避免循环调用或执行顺序混乱。
2.3 何时该用,何时不该用?
使用模板方法模式的典型场景:
- 多个类有相同的主要流程,但部分步骤的实现不同。这是最直接的信号。
- 你需要控制子类的扩展点,只允许它们修改算法的特定部分,从而保护核心流程不被破坏。
- 你想将公共行为提取到父类,避免代码重复,这是面向对象的基本原则。
不适合使用模板方法模式的情况:
- 如果算法的每一步都可能剧烈变化,那么模板方法里会充满大量的抽象方法,导致子类实现负担很重,这时可能策略模式(Strategy Pattern)更合适。
- 如果子类需要频繁地改变算法的整体结构(而不仅仅是某些步骤),那这个模式就成了枷锁。
- 在Unity中,如果某个流程严重依赖于
MonoBehaviour的生命周期事件,并且这些事件本身已经构成了清晰的流程,强行套用模板方法可能会让代码更晦涩。
实操心得:判断该不该用的一个简单方法是:画流程图。如果多个对象的流程图里,大部分框(步骤)都一样,只有少数几个框的内容不同,那模板方法就是天作之合。如果每个对象的流程图都长得完全不一样,那就得另寻他法了。
3. 从零实现:一个完整的Unity案例——游戏关卡流程控制器
光说不练假把式。我们用一个Unity游戏里非常常见的需求——关卡流程控制,来完整实现一遍模板方法模式。假设我们的游戏关卡都有固定的流程:显示开始UI -> 加载场景资源 -> 初始化关卡 -> 开始游戏 -> 判断胜利/失败 -> 显示结束UI。
3.1 第一步:定义抽象类(算法骨架)
我们创建一个抽象类LevelControllerBase。注意,它不继承MonoBehaviour,因为我们希望它是个纯逻辑类,便于管理和测试。实际的MonoBehaviour组件会持有它的实例。
// LevelControllerBase.cs using UnityEngine; public abstract class LevelControllerBase { // 这就是我们的“模板方法”。它定义了通关的固定流程。 // 这里没有用sealed,因为在这个例子中,我们不禁止子类重写整个流程(虽然通常不建议)。 // 但为了清晰,我们可以把它标记为 virtual 并提供一个默认实现,子类除非有充分理由,否则不应重写它。 public void PlayLevel() { Debug.Log($"[{GetType().Name}] 关卡流程开始"); OnShowStartUI(); OnLoadResources(); OnInitializeLevel(); OnGameStart(); // 假设游戏循环在外部(如Update)中驱动,这里我们模拟一个胜利条件检查 bool isWin = CheckWinCondition(); if(isWin) { OnLevelWin(); } else { OnLevelLose(); } OnShowEndUI(); Debug.Log($"[{GetType().Name}] 关卡流程结束"); } // 以下是需要子类实现的“抽象操作”(Primitive Operations) // 我们将它们定义为 protected abstract,强制子类实现,且对外部隐藏。 /// <summary> /// 显示关卡开始UI(如关卡名称、目标提示) /// </summary> protected abstract void OnShowStartUI(); /// <summary> /// 加载关卡特定资源(如场景、怪物Prefab、音频) /// </summary> protected abstract void OnLoadResources(); /// <summary> /// 初始化关卡数据(如生成敌人、设置出生点) /// </summary> protected abstract void OnInitializeLevel(); /// <summary> /// 游戏正式开始(如启动计时器、解锁玩家控制) /// </summary> protected virtual void OnGameStart() { // 提供一个默认的空实现。有些关卡可能不需要特殊的“开始”动作。 Debug.Log("游戏开始!"); } /// <summary> /// 检查胜利条件(如击败所有敌人、到达终点) /// </summary> protected abstract bool CheckWinCondition(); /// <summary> /// 关卡胜利时的处理 /// </summary> protected abstract void OnLevelWin(); /// <summary> /// 关卡失败时的处理 /// </summary> protected abstract void OnLevelLose(); /// <summary> /// 显示关卡结束UI(如胜利/失败面板、结算信息) /// </summary> protected abstract void OnShowEndUI(); }关键点解析:
PlayLevel()方法就是模板方法,它像一个导演脚本,严格规定了各个步骤的执行顺序。- 我们将所有可变的步骤都定义成了
protected abstract方法。protected保证了这些细节只对子类可见,对外部调用者隐藏。abstract强制每个子类都必须给出自己的实现。 OnGameStart()被设计成了virtual方法,并提供了默认实现。这意味着有些关卡可能不需要特殊的开始处理,直接沿用父类的空操作即可,这增加了灵活性。
3.2 第二步:创建具体子类(实现变化部分)
现在,我们创建两个具体的关卡控制器:一个简单的“击杀所有敌人”关卡,和一个需要“限时抵达终点”的关卡。
// KillAllEnemiesLevelController.cs using UnityEngine; public class KillAllEnemiesLevelController : LevelControllerBase { private int _totalEnemies = 5; private int _defeatedEnemies = 0; private GameObject _winUIPrefab; private GameObject _loseUIPrefab; protected override void OnShowStartUI() { // 假设我们有一个UI管理器,这里简化为Debug.Log Debug.Log("=== 关卡目标:击杀所有敌人(5个)==="); // 实际项目中,这里可能会调用 UIManager.Instance.ShowLevelStartPanel("击杀所有敌人"); } protected override void OnLoadResources() { Debug.Log("加载敌人Prefab、战斗音效..."); // Resources.Load 或 Addressables.LoadAssetAsync _winUIPrefab = Resources.Load<GameObject>("UI/WinPanel"); _loseUIPrefab = Resources.Load<GameObject>("UI/LosePanel"); } protected override void OnInitializeLevel() { Debug.Log("在地图指定位置生成5个敌人..."); _defeatedEnemies = 0; // 重置击败数 // 这里会有生成敌人的逻辑,并为每个敌人注册死亡事件 // enemy.OnDeath += () => { _defeatedEnemies++; }; } protected override bool CheckWinCondition() { // 简单的检查:击败数是否等于总数 // 实际游戏中,这里可能会在Update中被频繁调用,或由事件触发 Debug.Log($"检查胜利条件:已击败{_defeatedEnemies}/{_totalEnemies}个敌人"); return _defeatedEnemies >= _totalEnemies; } protected override void OnLevelWin() { Debug.Log("太棒了!你击败了所有敌人!"); Object.Instantiate(_winUIPrefab); // 实例化胜利UI // 播放胜利音效、发放奖励等 } protected override void OnLevelLose() { Debug.Log("任务失败..."); Object.Instantiate(_loseUIPrefab); // 实例化失败UI // 播放失败音效 } protected override void OnShowEndUI() { // 在这个例子中,胜利/失败UI已经在 OnLevelWin/Lose 中显示了。 // 所以这个方法可能只是隐藏一些通用的HUD,或者什么都不做。 Debug.Log("隐藏关卡内HUD..."); } // 提供一个公共方法,供敌人死亡时调用 public void OnEnemyDefeated() { _defeatedEnemies++; Debug.Log($"敌人被击败!当前进度:{_defeatedEnemies}/{_totalEnemies}"); } }// ReachGoalInTimeLevelController.cs using UnityEngine; public class ReachGoalInTimeLevelController : LevelControllerBase { private float _timeLimit = 60f; // 60秒限制 private float _timeElapsed = 0f; private bool _playerReachedGoal = false; private Transform _player; private Transform _goalPoint; protected override void OnShowStartUI() { Debug.Log($"=== 关卡目标:在{_timeLimit}秒内抵达终点 ==="); } protected override void OnLoadResources() { Debug.Log("加载终点旗帜Prefab、计时器UI..."); _goalPoint = new GameObject("GoalPoint").transform; _goalPoint.position = new Vector3(10, 0, 10); // 假设终点位置 } protected override void OnInitializeLevel() { Debug.Log("设置玩家出生点,初始化计时器..."); _player = GameObject.FindGameObjectWithTag("Player").transform; _timeElapsed = 0f; _playerReachedGoal = false; } protected override void OnGameStart() { // 这个关卡需要特殊的“开始”处理:启动计时器 base.OnGameStart(); // 可以选择性调用父类的默认实现 Debug.Log("计时开始!"); // 这里可以开始一个协程或者在其他地方更新_timeElapsed } protected override bool CheckWinCondition() { // 两个条件:1. 玩家到达终点;2. 用时未超时。 // 这里简化处理,假设有一个方法在玩家到达终点时会调用MarkGoalReached bool isWin = _playerReachedGoal && _timeElapsed < _timeLimit; Debug.Log($"检查胜利条件:到达终点{_playerReachedGoal},用时{_timeElapsed}/{_timeLimit}, 结果:{isWin}"); return isWin; } protected override void OnLevelWin() { Debug.Log("恭喜!你在规定时间内到达了终点!"); } protected override void OnLevelLose() { if (_timeElapsed >= _timeLimit) { Debug.Log("时间到了!任务失败。"); } else { Debug.Log("任务失败。"); // 其他失败原因 } } protected override void OnShowEndUI() { Debug.Log("显示结算界面,展示用时和评级..."); } // 供其他系统调用的方法 public void MarkGoalReached() => _playerReachedGoal = true; public void UpdateTime(float deltaTime) => _timeElapsed += deltaTime; }3.3 第三步:在Unity中组装与调用
最后,我们需要一个MonoBehaviour组件来驱动这个流程。通常,这会是一个GameManager或LevelManager。
// LevelManager.cs using UnityEngine; public class LevelManager : MonoBehaviour { public enum LevelType { KillAll, ReachGoal } [SerializeField] private LevelType _currentLevelType; private LevelControllerBase _currentLevelController; void Start() { // 根据关卡类型创建具体的控制器 switch (_currentLevelType) { case LevelType.KillAll: _currentLevelController = new KillAllEnemiesLevelController(); break; case LevelType.ReachGoal: _currentLevelController = new ReachGoalInTimeLevelController(); break; default: Debug.LogError("未知的关卡类型"); return; } // 执行模板方法,启动关卡流程 _currentLevelController.PlayLevel(); } void Update() { // 如果是限时关卡,需要在这里更新时间 if (_currentLevelController is ReachGoalInTimeLevelController timeLevel) { timeLevel.UpdateTime(Time.deltaTime); // 可以每帧或定期检查 CheckWinCondition,这里简化为在PlayLevel中一次性检查 } } // 假设这个方法会被敌人死亡事件调用 public void NotifyEnemyDefeated() { if (_currentLevelController is KillAllEnemiesLevelController killLevel) { killLevel.OnEnemyDefeated(); // 这里可以实时检查胜利条件,或者等待一个显式的检查点 } } // 假设这个方法会被玩家触发 public void NotifyGoalReached() { if (_currentLevelController is ReachGoalInTimeLevelController reachLevel) { reachLevel.MarkGoalReached(); } } }这样做的优势一目了然:
- 流程统一且清晰:所有关卡的启动、运行、结束都遵循
PlayLevel()定义的步骤,不会出现某个关卡忘了显示结束UI的情况。 - 代码复用性高:公共的流程控制逻辑都在
LevelControllerBase里,子类只关心自己特有的逻辑。 - 易于扩展:要加一个新类型的关卡(比如“解谜关卡”),只需要新建一个类继承
LevelControllerBase,实现那几个抽象方法即可。LevelManager的Start()方法里加一个case就行,其他代码几乎不用动。 - 便于维护:如果想修改所有关卡的通用流程(比如在
OnLoadResources前后加一个加载动画),只需要改父类LevelControllerBase一处。
注意事项:在这个例子中,为了简化,胜利条件检查是放在
PlayLevel()里一次性完成的。在真实游戏中,CheckWinCondition()可能需要在Update中持续检查,或者由特定事件(如敌人死亡、玩家到达某地)触发。这时,模板方法PlayLevel()可能只负责初始化,而将循环检查的逻辑交给MonoBehaviour的生命周期或事件系统。这并不违背模板方法模式,只是“算法骨架”的定义变得更灵活,可能是一个“初始化模板方法”加上一个“每帧更新模板方法”。关键在于,变化的步骤(如何检查胜利)仍然被抽象出来,由子类实现。
4. 深入剖析:Unity中的高级应用与避坑指南
掌握了基础实现后,我们来看看在Unity工程实践中,模板方法模式有哪些更高级的用法和需要注意的“坑”。
4.1 与Unity生命周期和协程的结合
Unity是帧驱动的,很多操作(如加载资源、播放序列动画)是异步的。我们的模板方法不能阻塞主线程。这时,我们可以将模板方法改造为返回IEnumerator的协程。
public abstract class AsyncLevelControllerBase : MonoBehaviour { // 模板方法变成了一个协程 public IEnumerator PlayLevelAsync() { Debug.Log($"[{GetType().Name}] 异步关卡流程开始"); yield return OnShowStartUIAsync(); yield return OnLoadResourcesAsync(); yield return OnInitializeLevelAsync(); yield return OnGameStartAsync(); // 等待胜利条件达成。这里需要子类提供一个“等待条件”的协程。 yield return WaitForWinCondition(); bool isWin = CheckWinCondition(); // 最终检查 if(isWin) { yield return OnLevelWinAsync(); } else { yield return OnLevelLoseAsync(); } yield return OnShowEndUIAsync(); Debug.Log($"[{GetType().Name}] 异步关卡流程结束"); } protected abstract IEnumerator OnShowStartUIAsync(); protected abstract IEnumerator OnLoadResourcesAsync(); protected abstract IEnumerator OnInitializeLevelAsync(); protected virtual IEnumerator OnGameStartAsync() { yield break; // 默认空实现 } protected abstract IEnumerator WaitForWinCondition(); // 新的抽象方法:等待条件满足 protected abstract bool CheckWinCondition(); protected abstract IEnumerator OnLevelWinAsync(); protected abstract IEnumerator OnLevelLoseAsync(); protected abstract IEnumerator OnShowEndUIAsync(); }这样设计的好处是,每个步骤都可以包含yield return new WaitForSeconds()、yield return StartCoroutine(...)或者yield return AsyncOperation等异步操作,完美契合Unity的资源加载、动画播放等需求。
4.2 “钩子”方法(Hook)的妙用
除了强制子类实现的抽象方法,我们还可以提供一些带有默认实现的虚方法(Hook,钩子)。子类可以视情况决定是否覆盖它们,从而在算法的特定点插入自定义行为。
public abstract class LevelControllerBaseWithHooks : LevelControllerBase { // 在模板方法的关键节点前后添加钩子 public new void PlayLevel() // 注意这里用new隐藏了父类的PlayLevel,不推荐大量使用,这里仅为演示 { OnLevelStart(); base.PlayLevel(); // 调用父类的原流程 OnLevelEnd(); } // 钩子方法,默认空实现 protected virtual void OnLevelStart() { } protected virtual void OnLevelEnd() { } // 也可以在父类的PlayLevel内部插入钩子 // protected override void OnGameStart() // { // BeforeGameStartHook(); // 钩子 // base.OnGameStart(); // AfterGameStartHook(); // 钩子 // } // protected virtual void BeforeGameStartHook() { } // protected virtual void AfterGameStartHook() { } }钩子的应用场景:比如,你想在所有关卡开始前播放一段通用的开场动画,在所有关卡结束后统一上传游戏数据。你可以在基类OnLevelStart和OnLevelEnd里写好这些通用逻辑。如果某个特殊关卡(比如Boss关)需要在开场动画前先播一段特殊剧情,它就可以重写OnLevelStart,先播自己的剧情,然后调用base.OnLevelStart()播放通用动画。
4.3 常见陷阱与避坑指南
过度设计(Over-engineering):这是新手最容易犯的错。如果一个流程只有一两个简单的变体,直接用
if-else或者简单的继承可能更清晰。不要为了用模式而用模式。判断标准是:当增加一个新的变体时,你发现需要复制大量代码并修改多处,这时候就该考虑模板方法了。模板方法过于庞大:如果模板方法
PlayLevel()里的步骤太多(比如超过10步),会导致父类难以理解和维护。此时,应考虑将一些相关的步骤组合成新的方法,或者审视是否应该将这个大流程拆分成几个更小的、独立的模板方法。子类破坏流程(Liskov替换原则):子类在重写虚方法时,不应该改变该步骤在整体流程中的约定。例如,
OnLoadResources方法的本意是加载资源,子类重写时不应该在里面偷偷地开始游戏逻辑,否则会破坏父类设定的流程,导致难以预料的行为。良好的命名和注释可以帮助约束这一点。与Unity组件生命周期混淆:如果你的抽象类本身是
MonoBehaviour,并且模板方法(比如一个Initialize())在Awake或Start中调用,要小心子类也可能有Awake或Start。Unity会调用所有脚本的Awake和Start,这可能导致父类的模板方法被子类的生命周期方法中的代码干扰。建议:明确分工。要么让父类完全控制流程(子类不实现Start),要么使用“初始化调用”模式,由父类在适当时机(如Start内)显式调用子类的初始化方法。对“算法骨架”的僵化理解:模板方法定义的骨架不一定是线性的、一次性的。在游戏循环中,骨架可能是一个“状态机”。比如,一个
EnemyAI的父类,其模板方法UpdateAI()可能定义了“巡逻 -> 发现玩家 -> 追击 -> 攻击 -> 返回巡逻”的状态转换骨架,而每个状态的具体行为(如何巡逻、如何攻击)则由子类实现。这依然是模板方法模式的灵活应用。
实操心得:在团队项目中应用模板方法模式,文档和命名至关重要。一定要在抽象类和方法上写清XML注释,说明每个步骤的职责、输入输出、以及子类重写时需要注意什么。比如:“此方法仅用于加载资源,切勿在此处初始化游戏逻辑,初始化请放在
OnInitializeLevel中。” 这能极大减少队友的困惑和误用。
5. 实战扩展:模板方法在Unity各系统中的应用案例
模板方法模式在Unity中几乎无处不在,下面我分享几个在真实项目中优化过的案例,看看它如何解决实际问题。
5.1 案例一:可复用的UI弹窗系统
问题:游戏里有各种弹窗(提示框、确认框、物品详情框),它们都有类似的生命周期:打开动画 -> 获取数据并刷新 -> 接收玩家输入 -> 关闭动画。但每个弹窗的内容、动画和输入处理都不同。
模板方法解决方案:
public abstract class UIPopupBase : MonoBehaviour { public void OpenPopup(object data = null) { gameObject.SetActive(true); StartCoroutine(OpenPopupRoutine(data)); } private IEnumerator OpenPopupRoutine(object data) { // 1. 播放打开动画(可选) yield return StartCoroutine(OnPlayOpenAnimation()); // 2. 用传入的数据刷新界面 OnRefreshUI(data); // 3. 等待玩家交互(如点击确认、取消、关闭按钮) yield return StartCoroutine(WaitForUserInteraction()); // 4. 处理交互结果 OnHandleInteractionResult(); // 5. 播放关闭动画(可选) yield return StartCoroutine(OnPlayCloseAnimation()); // 6. 关闭自己或通知管理器回收 OnClosePopup(); } protected virtual IEnumerator OnPlayOpenAnimation() { yield break; } protected abstract void OnRefreshUI(object data); protected abstract IEnumerator WaitForUserInteraction(); protected abstract void OnHandleInteractionResult(); protected virtual IEnumerator OnPlayCloseAnimation() { yield break; } protected virtual void OnClosePopup() { gameObject.SetActive(false); } }具体子类(确认框):
public class ConfirmationPopup : UIPopupBase { [SerializeField] private Text _messageText; [SerializeField] private Button _confirmBtn; [SerializeField] private Button _cancelBtn; private bool _userConfirmed = false; protected override void OnRefreshUI(object data) { if (data is string message) { _messageText.text = message; } } protected override IEnumerator WaitForUserInteraction() { var waitEvent = new System.Threading.Tasks.TaskCompletionSource<bool>(); _confirmBtn.onClick.AddListener(() => { _userConfirmed = true; waitEvent.TrySetResult(true); }); _cancelBtn.onClick.AddListener(() => { _userConfirmed = false; waitEvent.TrySetResult(true); }); // 等待两个按钮中的任意一个被点击 yield return new WaitUntil(() => waitEvent.Task.IsCompleted); // 移除监听,防止重复触发 _confirmBtn.onClick.RemoveAllListeners(); _cancelBtn.onClick.RemoveAllListeners(); } protected override void OnHandleInteractionResult() { if (_userConfirmed) { Debug.Log("用户点击了确认"); // 执行确认后的逻辑... } else { Debug.Log("用户点击了取消"); // 执行取消后的逻辑... } } }效果:所有弹窗的行为一致,管理方便。新增一个弹窗类型,只需关注内容填充和交互处理,打开关闭动画等通用逻辑无需重复编写。
5.2 案例二:差异化的人物状态机
问题:游戏中有英雄、怪物等多种角色,它们都有“闲置-移动-攻击-受伤-死亡”等状态,但每个状态下的具体行为(动画、音效、逻辑)差异很大。
模板方法解决方案(简化版状态机):
public abstract class CharacterStateMachine : MonoBehaviour { protected enum State { Idle, Move, Attack, Hurt, Die } protected State _currentState; void Update() { // 模板方法:每帧根据当前状态执行对应的行为 switch (_currentState) { case State.Idle: OnIdleUpdate(); break; case State.Move: OnMoveUpdate(); break; case State.Attack: OnAttackUpdate(); break; // ... 其他状态 } } // 状态进入、更新、退出 的钩子 protected virtual void OnEnterIdle() {} protected abstract void OnIdleUpdate(); protected virtual void OnExitIdle() {} protected virtual void OnEnterMove() {} protected abstract void OnMoveUpdate(); protected virtual void OnExitMove() {} // ... 其他状态类似 // 状态切换方法,封装了“退出旧状态 -> 切换状态 -> 进入新状态”的固定流程 public void ChangeState(State newState) { // 退出旧状态 switch (_currentState) { case State.Idle: OnExitIdle(); break; case State.Move: OnExitMove(); break; // ... } // 切换状态 _currentState = newState; // 进入新状态 switch (_currentState) { case State.Idle: OnEnterIdle(); break; case State.Move: OnEnterMove(); break; // ... } } }具体子类(英雄):
public class HeroStateMachine : CharacterStateMachine { protected override void OnIdleUpdate() { // 英雄的闲置逻辑:播放呼吸动画,检测输入 if(Input.GetKeyDown(KeyCode.Space)) { ChangeState(State.Attack); } } protected override void OnMoveUpdate() { // 英雄的移动逻辑:根据输入移动,播放跑步动画 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); transform.Translate(new Vector3(h, 0, v) * Time.deltaTime * 5f); } // ... 实现其他抽象方法 }效果:状态机的框架被固化在父类中,确保了状态切换的逻辑正确(不会忘记调用退出和进入方法)。不同角色只需实现自己特有的状态行为即可。
5.3 案例三:资源加载策略模板
问题:游戏不同阶段(启动、关卡内、切换场景)需要不同的资源加载策略(同步加载、异步加载、使用Addressables),但加载的流程(“计算依赖-加载-实例化-回调”)是相似的。
模板方法解决方案:
public abstract class ResourceLoaderBase { public GameObject LoadPrefab(string path, Transform parent = null) { // 1. 前置处理(如记录加载开始时间) OnBeforeLoad(path); // 2. 执行具体的加载逻辑(由子类实现) Object loadedAsset = PerformLoad(path); // 3. 实例化(如果有需要) GameObject instance = null; if (loadedAsset is GameObject prefab) { instance = Object.Instantiate(prefab, parent); } // 4. 后置处理(如记录加载结束时间、触发事件) OnAfterLoad(path, loadedAsset, instance); return instance; } protected virtual void OnBeforeLoad(string path) { Debug.Log($"开始加载资源: {path}"); } protected abstract Object PerformLoad(string path); protected virtual void OnAfterLoad(string path, Object asset, GameObject instance) { Debug.Log($"资源加载完成: {path}"); if (instance != null) { Debug.Log($"实例化成功,实例ID: {instance.GetInstanceID()}"); } } }具体子类(Resources同步加载器):
public class ResourcesLoader : ResourceLoaderBase { protected override Object PerformLoad(string path) { // 移除了可能的"Resources/"前缀,实际项目会更严谨 return Resources.Load(path); } }具体子类(Addressables异步加载器):
public class AddressablesLoader : ResourceLoaderBase { protected override Object PerformLoad(string path) { // 注意:这里简化了,实际Addressables是异步的,需要更复杂的处理(如协程、async/await) // 仅为展示模式应用 var handle = Addressables.LoadAssetAsync<GameObject>(path); // 通常我们会等待handle完成,这里简化返回 return handle.WaitForCompletion(); // 注意:WaitForCompletion可能阻塞主线程 } protected override void OnBeforeLoad(string path) { base.OnBeforeLoad(path); Debug.Log("使用Addressables系统加载..."); } }效果:加载策略(同步/异步/远程)可以轻松切换,而加载的通用逻辑(日志、实例化、回调)只需写一次。当我们需要从Resources迁移到Addressables时,只需替换使用的Loader子类,业务逻辑代码几乎不用改。
通过这些案例可以看到,模板方法模式的价值在于它提炼了流程中的不变部分,隔离了变化部分。它让我们的代码在面对相似但略有不同的需求时,能够保持结构清晰、易于扩展,而不是陷入复制粘贴和散弹式修改的泥潭。在Unity这种组件化、事件驱动的环境中,合理运用模板方法模式,能显著提升框架代码的健壮性和可读性。