Unity命令模式实战:从解耦到撤销重做,构建灵活游戏架构

发布时间:2026/7/12 12:20:29
Unity命令模式实战:从解耦到撤销重做,构建灵活游戏架构 1. 项目概述为什么Unity开发者绕不开命令模式如果你在Unity里做过稍微复杂一点的交互比如一个角色技能系统、一个可撤销的编辑器操作或者一个需要排队执行的任务管理器你大概率已经和“命令模式”打过交道了只是可能没意识到它的名字。我第一次在Unity项目里系统性地应用命令模式是为了解决一个看似简单但极其混乱的问题一个策略游戏里单位有移动、攻击、释放技能等多种指令这些指令来自UI点击、键盘快捷键甚至AI逻辑执行时还要考虑网络同步和撤销回退。当时代码里到处都是if-else和直接的方法调用耦合度高到改一处功能整个战斗系统都在颤抖。命令模式Command Pattern正是为了解决这种“请求发送者”和“请求执行者”之间紧耦合的痛点而生的。它的核心思想非常直观把一个请求或操作封装成一个独立的对象。在这个对象里包含了执行这个操作所需的所有信息比如目标对象、参数。这样一来你就可以像管理普通数据一样来管理操作——把它们存入队列、记录日志、实现撤销/重做或者延迟执行。在Unity这种以GameObject和Component为核心、强调事件驱动和灵活交互的开发环境中命令模式的价值被无限放大。无论是处理用户输入、构建复杂的技能系统、实现编辑器工具还是设计网络同步协议它都能让代码结构变得更清晰、更灵活、更易于维护。接下来我会结合在Unity中的实际应用从设计思路到代码实现再到那些容易踩坑的细节为你彻底拆解命令模式。无论你是刚接触设计模式的新手还是想优化现有架构的老手这篇文章都能给你带来可以直接“抄作业”的实战方案。2. 核心思路拆解从“直接调用”到“对象封装”在深入代码之前我们必须先理解命令模式要解决的根本问题以及它是如何通过巧妙的抽象来解决问题的。这比死记硬背类图重要得多。2.1 传统做法的困境紧耦合与僵化假设我们有一个简单的Unity场景一个玩家角色Player可以执行“跳跃”和“射击”两个动作。最直接的实现可能是这样的public class Player : MonoBehaviour { public void Jump() { /* 跳跃逻辑 */ } public void Shoot() { /* 射击逻辑 */ } } public class InputHandler : MonoBehaviour { public Player player; void Update() { if (Input.GetKeyDown(KeyCode.Space)) { player.Jump(); // 直接调用 } if (Input.GetKeyDown(KeyCode.Mouse0)) { player.Shoot(); // 直接调用 } } }这段代码在功能上没问题但它隐藏了几个严重的设计缺陷紧耦合InputHandler直接依赖于具体的Player类及其方法。如果我想把跳跃动作换给另一个对象比如一个宠物或者修改Player.Jump()的方法签名就必须同时修改InputHandler。难以扩展每增加一个新动作比如“蹲下”我就要在InputHandler的Update方法里增加一个if分支。动作多了之后这个方法会变得冗长且难以维护。缺乏灵活性指令无法被存储、排队或撤销。例如无法实现“录制宏”、“撤销上一步操作”或“将一连串指令打包发送给网络对端”这样的高级功能。2.2 命令模式的解决方案抽象与解耦命令模式通过引入一个“命令”抽象层来解决上述问题。它的核心参与者通常包括命令接口 (ICommand)声明执行操作的方法通常是Execute()。具体命令 (ConcreteCommand)实现ICommand接口它持有一个接收者对象如Player的引用并在Execute()方法中调用接收者的一个或多个具体方法。接收者 (Receiver)知道如何执行具体的操作如Player类。调用者/请求者 (Invoker)要求命令执行请求如InputHandler。它持有一个命令对象并在某个时间点调用其Execute()方法。客户端 (Client)创建具体命令对象并设置其接收者通常在Unity中就是我们的MonoBehaviour脚本。将上面的例子用命令模式重构关系就变成了InputHandler调用者不再认识Player接收者它只认识ICommand接口。当空格键按下时它只是简单地调用jumpCommand.Execute()。至于这个命令内部是让玩家跳还是让相机震动亦或是播放一个音效InputHandler完全不关心。这就实现了彻底的解耦。注意在Unity的上下文中“客户端”的角色常常被场景初始化、依赖注入框架或一个专门的“命令工厂”所扮演。而“调用者”可能是InputHandler、一个UI按钮的监听器或者一个AI决策系统。2.3 Unity中的独特考量生命周期与组件化Unity的基于组件的架构和游戏循环如Update为命令模式带来了新的应用场景和注意事项基于MonoBehaviour的命令你可以让具体命令也继承MonoBehaviour从而方便地利用Unity的序列化、协程和生命周期方法。例如一个需要持续多帧的移动命令可以在Execute()中启动一个协程。与事件系统结合Unity的UnityEvent本质上就是一个简单的命令模式实现。你可以将函数调用拖拽到Inspector面板上这相当于创建了一个匿名的具体命令。但UnityEvent功能较弱难以实现复杂的队列、撤销或参数化命令。性能与GC垃圾回收频繁创建和销毁命令对象尤其是包含引用类型成员时可能引发GC压力。对于高频执行的命令如每帧的移动指令需要考虑使用对象池来复用命令实例。撤销/重做与序列化要实现撤销命令接口通常还需要一个Undo()方法。为了能正确撤销命令可能需要保存执行前的状态快照。这时需要考虑状态数据的深拷贝和序列化问题特别是在涉及复杂对象或Unity引擎对象如Transform、Material时。理解了这些核心思路我们就能更有目的地去设计具体的实现方案避免陷入生搬硬套设计模式的误区。3. 核心细节解析接口设计、撤销实现与对象池理论清晰后我们来深入实现层面的关键细节。一个健壮的、可用于生产环境的命令模式实现必须妥善处理好接口设计、撤销/重做机制以及性能优化。3.1 命令接口的设计不止于Execute一个最基本的命令接口只需要一个Execute方法。但在游戏开发中这远远不够。一个更实用的接口可能长这样public interface ICommand { /// summary /// 执行命令 /// /summary void Execute(); /// summary /// 撤销该命令的执行效果 /// /summary void Undo(); /// summary /// 命令是否已执行完成用于异步或持续命令 /// /summary bool IsFinished { get; } /// summary /// 合并命令可选用于优化连续相同操作 /// /summary /// param namenextCommand后续命令/param /// returns如果合并成功返回true并可能修改自身状态/returns bool Merge(ICommand nextCommand); }Undo()这是实现撤销功能的基础。在Execute()中你需要保存足够的信息如物体移动前的位置以便在Undo()中能精确恢复到之前的状态。IsFinished对于非即时完成的命令如一个让物体移动到某点的命令这个属性非常重要。调用者如命令队列可以根据它来判断是否该执行下一个命令。Merge()这是一个高级优化技巧。例如在RTS游戏中玩家连续点击了多次“向右移动1个单位”的命令。与其在历史记录中保存10个独立的命令不如将它们合并成一个“向右移动10个单位”的命令。这大大简化了撤销/重做的逻辑和历史记录的内存占用。3.2 撤销/重做栈的实现状态管理与边界处理撤销/重做功能通常通过两个栈Stack来实现一个“历史记录栈”和一个“重做栈”。public class CommandManager : MonoBehaviour { private StackICommand _undoStack new StackICommand(); private StackICommand _redoStack new StackICommand(); public void ExecuteCommand(ICommand command) { command.Execute(); _undoStack.Push(command); // 当执行一个新命令时清空重做栈因为新的分支产生了 _redoStack.Clear(); } public void Undo() { if (_undoStack.Count 0) { var command _undoStack.Pop(); command.Undo(); _redoStack.Push(command); } } public void Redo() { if (_redoStack.Count 0) { var command _redoStack.Pop(); command.Execute(); _undoStack.Push(command); } } }实操心得与避坑指南状态快照的深度对于Transform的位置、旋转直接保存Vector3和Quaternion通常就够了。但如果命令修改了材质属性、列表内容或复杂的数据结构你需要进行深拷贝。Unity的JsonUtility或第三方序列化库如 Newtonsoft.Json可以辅助完成此工作但要注意性能。不可撤销的命令有些命令不应该被撤销比如“保存游戏”、“退出到主菜单”。你可以在接口中增加一个bool CanUndo { get; }属性或者在ExecuteCommand时进行判断不将其压入撤销栈。内存管理撤销栈不能无限增长。需要设置一个最大容量当栈满时移除最旧的命令。这时要小心被移除的命令如果还持有对游戏对象的引用可能会妨碍该对象被垃圾回收。可以考虑让命令实现IDisposable接口在被移出栈时释放资源。合并命令的时机Merge操作通常在将命令压入历史栈之前进行。CommandManager可以检查新命令是否能与栈顶命令合并如果能则修改栈顶命令而不是压入新命令。3.3 性能优化关键命令对象池在动作游戏或RTS游戏中命令可能每帧都在产生。频繁地new一个命令对象会产生GC垃圾回收压力导致帧率卡顿。对象池是解决这个问题的标准方案。public class CommandPoolT where T : ICommand, new() { private StackT _pool new StackT(); public T Get() { if (_pool.Count 0) { return _pool.Pop(); } return new T(); } public void Release(T command) { // 重置命令到初始状态避免残留数据影响下次使用 // 如果命令有Reset方法可以在这里调用 _pool.Push(command); } } // 使用示例移动命令池 public class MoveCommand : ICommand { public Vector3 TargetPosition { get; set; } private Transform _unitTransform; private Vector3 _previousPosition; // 提供一个初始化方法而不是在构造函数中完成所有设置 public void Init(Transform unitTransform, Vector3 targetPos) { _unitTransform unitTransform; TargetPosition targetPos; } public void Execute() { _previousPosition _unitTransform.position; _unitTransform.position TargetPosition; } public void Undo() { _unitTransform.position _previousPosition; } // 可选提供一个重置方法供对象池调用 public void Reset() { _unitTransform null; TargetPosition Vector3.zero; _previousPosition Vector3.zero; } } // 在命令管理器中 public class AdvancedCommandManager : MonoBehaviour { private CommandPoolMoveCommand _moveCommandPool new CommandPoolMoveCommand(); public void ExecuteMove(Transform unit, Vector3 targetPos) { var cmd _moveCommandPool.Get(); cmd.Init(unit, targetPos); ExecuteCommand(cmd); // 执行并压栈 // 注意对象池中释放的时机很重要不能在命令还在撤销栈里时就释放。 // 通常需要在命令从历史栈中永久移除时如超出容量才调用Release。 } }重要提示使用对象池时最大的陷阱是“状态污染”。从池中取出的命令对象可能残留着上一次执行的数据。因此必须提供一个清晰的初始化方法如Init并在获取命令后立即用新的参数调用它。Reset方法有助于在命令回池时清理状态但初始化步骤仍是必须的。4. 实战应用构建一个支持撤销的单位移动系统现在我们将把所有理论整合到一个具体的Unity示例中一个简单的RTS风格单位移动系统支持点击地面移动、命令队列和撤销/重做。4.1 项目结构与核心类定义首先创建以下脚本ICommand.cs命令接口。MoveCommand.cs具体的移动命令。CommandManager.cs全局命令管理器单例模式便于访问。UnitController.cs挂载在单位上的脚本接收移动命令。GroundClickHandler.cs处理鼠标点击地面生成移动命令。4.2 具体实现代码ICommand.cspublic interface ICommand { void Execute(); void Undo(); bool IsFinished { get; } bool Merge(ICommand nextCommand); }MoveCommand.csusing UnityEngine; public class MoveCommand : ICommand { public UnitController Unit { get; private set; } public Vector3 TargetPosition { get; private set; } private Vector3 _positionBeforeMove; private bool _isExecuting false; private float _speed 5.0f; private const float ARRIVAL_THRESHOLD 0.1f; public bool IsFinished !_isExecuting; public MoveCommand(UnitController unit, Vector3 targetPosition) { Unit unit; TargetPosition targetPosition; TargetPosition.y unit.transform.position.y; // 保持Y轴不变假设地面是平的 } public void Execute() { if (Unit null) return; _positionBeforeMove Unit.transform.position; _isExecuting true; // 在实际项目中这里可能会触发单位的移动动画、音效等 Debug.Log($Unit {Unit.name} 开始向 {TargetPosition} 移动。); } // 这个Update方法需要由某个管理器如CommandManager每帧调用 public void UpdateCommand(float deltaTime) { if (!_isExecuting) return; Vector3 currentPos Unit.transform.position; Vector3 direction (TargetPosition - currentPos).normalized; Vector3 newPosition Vector3.MoveTowards(currentPos, TargetPosition, _speed * deltaTime); Unit.transform.position newPosition; // 检查是否到达目的地 if (Vector3.Distance(newPosition, TargetPosition) ARRIVAL_THRESHOLD) { Unit.transform.position TargetPosition; _isExecuting false; Debug.Log($Unit {Unit.name} 已到达目的地。); } } public void Undo() { _isExecuting false; if (Unit ! null) { Unit.transform.position _positionBeforeMove; Debug.Log($Unit {Unit.name} 撤销移动回到 {_positionBeforeMove}。); } } public bool Merge(ICommand nextCommand) { // 尝试与下一个移动命令合并 if (nextCommand is MoveCommand nextMove nextMove.Unit this.Unit) { // 简单合并将目标位置更新为下一个命令的目标位置 // 更复杂的合并可以计算平均位置或判断是否同向 this.TargetPosition nextMove.TargetPosition; Debug.Log($合并移动命令新目标为 {TargetPosition}。); return true; } return false; } }CommandManager.csusing System.Collections.Generic; using UnityEngine; public class CommandManager : MonoBehaviour { public static CommandManager Instance { get; private set; } private StackICommand _undoStack new StackICommand(); private StackICommand _redoStack new StackICommand(); private ListMoveCommand _activeMoveCommands new ListMoveCommand(); // 用于更新进行中的命令 [SerializeField] private int _maxHistorySize 50; // 历史记录最大容量 void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; } } void Update() { // 更新所有正在执行中的移动命令 for (int i _activeMoveCommands.Count - 1; i 0; i--) { var cmd _activeMoveCommands[i]; if (cmd.IsFinished) { _activeMoveCommands.RemoveAt(i); } else { // 假设MoveCommand有一个需要每帧更新的方法 // 这里需要根据你的命令设计来调整。更好的做法是让命令自己注册到更新循环。 if (cmd is MoveCommand moveCmd) { // 这里是一种简化的更新方式。更优雅的设计是让命令实现IUpdatable接口。 // 我们通过反射调用一个假设的Update方法仅作示例实际应优化。 var method cmd.GetType().GetMethod(UpdateCommand); method?.Invoke(cmd, new object[] { Time.deltaTime }); } } } // 输入处理撤销与重做 if (Input.GetKeyDown(KeyCode.Z) (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl))) { Undo(); } if (Input.GetKeyDown(KeyCode.Y) (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl))) { Redo(); } } public void ExecuteCommand(ICommand command) { // 尝试与上一个命令合并优化 if (_undoStack.Count 0 _undoStack.Peek().Merge(command)) { Debug.Log(命令已合并不创建新历史记录。); // 合并后需要重新执行栈顶命令吗这取决于设计。 // 这里假设合并后原命令的效果已经代表了新意图无需重新Execute。 // 如果需要比如移动命令合并后目标点变了可以Pop再Execute再Push。 return; } command.Execute(); _undoStack.Push(command); // 如果命令是持续性的如移动将其加入活动列表进行更新 if (command is MoveCommand moveCmd !moveCmd.IsFinished) { _activeMoveCommands.Add(moveCmd); } // 执行新命令后清空重做栈 _redoStack.Clear(); // 限制历史记录大小 if (_undoStack.Count _maxHistorySize) { // 移除最旧的命令注意可能需要释放资源 var oldCommand _undoStack.ToArray()[_undoStack.Count - 1]; // 栈底元素 // 如果命令持有重要资源在此处释放 _undoStack new StackICommand(_undoStack.Take(_maxHistorySize).Reverse()); // 简化处理实际效率不高 Debug.LogWarning(撤销栈已满移除了最旧的命令。); } } public void Undo() { if (_undoStack.Count 0) return; var command _undoStack.Pop(); command.Undo(); _redoStack.Push(command); // 如果撤销的是一个正在活动的移动命令从活动列表中移除 if (command is MoveCommand moveCmd) { _activeMoveCommands.Remove(moveCmd); } } public void Redo() { if (_redoStack.Count 0) return; var command _redoStack.Pop(); command.Execute(); _undoStack.Push(command); if (command is MoveCommand moveCmd !moveCmd.IsFinished) { _activeMoveCommands.Add(moveCmd); } } // 清空所有历史例如开始新关卡时 public void ClearHistory() { _undoStack.Clear(); _redoStack.Clear(); _activeMoveCommands.Clear(); } }UnitController.csusing UnityEngine; public class UnitController : MonoBehaviour { [SerializeField] private MeshRenderer _meshRenderer; private Color _originalColor; void Start() { if (_meshRenderer ! null) { _originalColor _meshRenderer.material.color; } } // 可以被命令调用的方法或由命令直接操作Transform public void Highlight(bool enable) { if (_meshRenderer ! null) { _meshRenderer.material.color enable ? Color.yellow : _originalColor; } } }GroundClickHandler.csusing UnityEngine; public class GroundClickHandler : MonoBehaviour { [SerializeField] private LayerMask _groundLayer; [SerializeField] private UnitController _selectedUnit; // 简化假设我们只有一个选中的单位 void Update() { if (Input.GetMouseButtonDown(1)) // 右键点击移动 { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, Mathf.Infinity, _groundLayer)) { if (_selectedUnit ! null) { Vector3 targetPosition hit.point; // 创建移动命令 MoveCommand moveCommand new MoveCommand(_selectedUnit, targetPosition); // 通过命令管理器执行 CommandManager.Instance.ExecuteCommand(moveCommand); } } } // 单位选择左键 if (Input.GetMouseButtonDown(0)) { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { var unit hit.collider.GetComponentUnitController(); if (unit ! null) { // 取消之前选中单位的高亮 if (_selectedUnit ! null _selectedUnit ! unit) { _selectedUnit.Highlight(false); } // 选中新单位 _selectedUnit unit; _selectedUnit.Highlight(true); Debug.Log($选中单位: {unit.name}); } } } } }4.3 场景设置与测试在场景中创建一个平面Ground将其Layer设为“Ground”或在代码中指定对应的LayerMask。创建几个立方体Cube作为可移动的单位为它们添加UnitController脚本并将MeshRenderer拖拽到脚本的_meshRenderer字段上。创建一个空物体命名为“CommandSystem”挂载CommandManager和GroundClickHandler脚本。将GroundClickHandler脚本中的_selectedUnit在编辑器里指定为一个默认单位或者通过点击选择。运行游戏。右键点击地面选中的单位会向点击位置移动。按CtrlZ可以撤销移动按CtrlY可以重做。这个示例演示了解耦GroundClickHandler只负责生成命令不关心移动的具体实现。撤销/重做通过CommandManager管理两个栈实现。命令队列的雏形_activeMoveCommands列表管理着正在执行的命令。你可以轻松扩展它让单位按顺序执行多个移动命令即路径点。命令合并在CommandManager.ExecuteCommand中尝试合并命令优化连续点击移动的操作。5. 进阶应用与模式变体基础系统搭建好后我们可以探索命令模式更强大的应用场景和变体以适应复杂的游戏系统。5.1 命令队列与宏命令命令队列是命令模式最经典的应用之一。它允许你将多个命令按顺序存储起来然后依次或按特定规则执行。这在RTS游戏的单位指令排队、动画序列播放、剧情脚本系统中非常常见。public class CommandQueue { private QueueICommand _queue new QueueICommand(); public void Enqueue(ICommand command) { _queue.Enqueue(command); } public void Update() { if (_queue.Count 0) { var currentCommand _queue.Peek(); // 查看队首命令但不取出 if (currentCommand.IsFinished) { _queue.Dequeue(); // 完成则出队 if (_queue.Count 0) { _queue.Peek().Execute(); // 执行下一个命令 } } else { // 更新当前命令如果是持续性的 // 类似CommandManager中的更新逻辑 } } } public void Clear() { _queue.Clear(); } }宏命令则是将一系列命令组合成一个更大的命令。这在实现“连招”、“一键施放多个技能”或“批量操作”时非常有用。public class MacroCommand : ICommand { private ListICommand _subCommands new ListICommand(); public void AddCommand(ICommand command) _subCommands.Add(command); public void RemoveCommand(ICommand command) _subCommands.Remove(command); public void Execute() { foreach (var cmd in _subCommands) { cmd.Execute(); } } public void Undo() { // 注意撤销顺序通常与执行顺序相反 for (int i _subCommands.Count - 1; i 0; i--) { _subCommands[i].Undo(); } } public bool IsFinished _subCommands.All(cmd cmd.IsFinished); public bool Merge(ICommand nextCommand) false; // 宏命令通常不合并 }5.2 异步命令与协程集成很多游戏操作不是瞬间完成的比如播放一段动画、等待一个网络请求、或者让物体平滑移动到某点。我们可以利用Unity的协程来实现异步命令。public abstract class AsyncCommand : ICommand { protected MonoBehaviour _runner; // 用于启动协程的MonoBehaviour private Coroutine _executionRoutine; public bool IsFinished { get; protected set; } protected AsyncCommand(MonoBehaviour runner) { _runner runner; } public void Execute() { if (_runner ! null _runner.isActiveAndEnabled) { _executionRoutine _runner.StartCoroutine(ExecuteAsync()); } } public void Undo() { if (_executionRoutine ! null _runner ! null) { _runner.StopCoroutine(_executionRoutine); } UndoAsync(); } protected abstract IEnumerator ExecuteAsync(); protected abstract void UndoAsync(); } // 具体实现一个等待数秒的命令 public class WaitCommand : AsyncCommand { private float _duration; public WaitCommand(MonoBehaviour runner, float duration) : base(runner) { _duration duration; } protected override IEnumerator ExecuteAsync() { IsFinished false; Debug.Log($开始等待 {_duration} 秒...); yield return new WaitForSeconds(_duration); Debug.Log(等待结束。); IsFinished true; } protected override void UndoAsync() { // 等待命令的撤销通常意味着中断等待可能还需要回滚因等待而流逝的时间相关的逻辑。 // 这是一个简化示例实际项目需要更复杂的状态管理。 Debug.Log(撤销了等待命令。); IsFinished true; } public override bool Merge(ICommand nextCommand) { if (nextCommand is WaitCommand nextWait) { _duration nextWait._duration; return true; } return false; } }实操心得使用协程实现异步命令时最大的挑战是撤销。停止协程 (StopCoroutine) 可以中断执行但如何恢复到执行前的精确状态需要你在命令中保存更详细的快照。对于移动、渐变等操作UndoAsync可能需要执行一个反向的协程动画。5.3 网络游戏中的命令模式同步与预测在多人网络游戏尤其是RTS、MOBA中命令模式是实现“锁步同步”或“客户端预测”等技术的基石。基本思路是所有玩家的输入命令都被收集起来按相同的顺序在所有客户端上执行以确保游戏状态一致。命令序列化每个命令都需要能被序列化成字节流以便通过网络发送。你需要为每个具体命令类型分配一个唯一的ID。public abstract class NetworkCommand : ICommand { public abstract int CommandId { get; } public abstract byte[] Serialize(); public abstract void Deserialize(byte[] data); // ... Execute, Undo 等 }命令广播与执行本地玩家产生一个命令后先本地执行预测同时广播给服务器或其他玩家。服务器验证后再将命令广播给所有客户端。所有客户端在收到命令后在相同的游戏帧执行该命令。预测与回滚在高速动作游戏中为了减少延迟感客户端会立即执行本地输入的命令预测。如果服务器后来发回的权威状态与预测不一致客户端需要进行“回滚”撤销预测的命令并“重放”正确的命令序列。这要求命令的Execute和Undo必须是确定性的且执行速度极快。网络命令模式的实现复杂度极高涉及帧锁定、状态快照、插值补偿等。通常需要借助专业的网络库如Mirror、Fish-Networking或自行实现一套严谨的框架。但其核心思想依然是对“玩家操作”这个概念的抽象和封装这正是命令模式所擅长的。6. 常见问题、排查技巧与性能调优在实际项目中使用命令模式你会遇到各种各样的问题。下面是我踩过的一些坑和总结的解决方案。6.1 常见问题速查表问题现象可能原因排查与解决方案撤销后状态不正确1.Undo()方法中恢复的状态不完整或错误。2. 命令执行过程中状态被其他系统非命令修改。3. 深拷贝对象时引用关系出错。1. 在Execute()开始时保存所有受影响状态的完整快照。使用序列化工具进行深拷贝测试。2. 确保游戏状态的修改只能通过命令进行。这需要良好的架构设计。3. 检查自定义类的Clone或序列化方法确保嵌套引用被正确复制。GC垃圾回收导致卡顿每帧都new新的命令对象产生大量垃圾。1.使用对象池为高频命令如移动、旋转实现对象池。2.使用结构体如果命令数据很小且简单可以考虑用struct实现ICommand但要注意装箱拆箱和引用语义的变化。3.命令合并减少命令对象的数量。命令执行顺序错乱1. 异步命令未正确处理完成状态。2. 命令队列的更新逻辑有误。3. 多线程环境下如下载完成回调未在主线程执行命令。1. 确保IsFinished属性在异步操作完成后被正确设置为true。2. 在命令队列的Update中严格检查队首命令是否完成后再出队。3. 在Unity中确保命令的Execute()最终在主线程被调用。可以使用UnityEngine.Dispatchers或维护一个主线程动作队列。内存泄漏1. 命令对象长期被历史栈引用无法释放。2. 命令内部持有对MonoBehaviour或Texture等大型资源的强引用。1. 为历史栈设置容量上限并妥善处理被移除的命令调用Dispose或Reset。2. 在命令中持有资源时使用WeakReference或通过ID间接引用。在命令完成或撤销后及时释放对资源的引用。Inspector中无法方便配置命令具体命令类的参数需要在运行时动态设置不方便在编辑器里预设。1. 创建CommandAssetScriptableObject。将命令的数据如目标位置、伤害值存储在Asset中。运行时命令对象从Asset读取数据。这样可以在Project窗口里大量配置不同的命令。2. 使用UnityEvent包装命令。创建一个UnityEventCommand其Execute()方法就是调用一个配置好的UnityEvent。这样可以利用Unity编辑器强大的事件配置界面。6.2 性能调优实战建议按需使用不是所有操作都需要用命令模式。对于一次性、无需撤销、逻辑简单的调用直接函数调用更高效。命令模式会带来一定的抽象开销。简化命令对象命令对象应该尽量轻量。它主要存储意图和数据而不是承载复杂的逻辑。复杂的业务逻辑应该放在“接收者”如Unit,Building中。使用值类型和简单引用命令内部尽量使用int,float,Vector3等值类型或字符串、枚举。避免在命令中持有复杂的类实例。如果必须引用考虑使用唯一ID如InstanceID进行查找。历史记录压缩对于长时间运行的会话如策略游戏历史记录会非常庞大。可以定期将一系列命令压缩成一个“检查点”命令。例如每100个命令后保存一次完整的游戏状态快照然后清空前100个命令的历史。撤销时先恢复到最近的检查点再重放检查点之后的命令。使用命令缓冲区对于高频输入如每帧的移动输入不要每帧都创建新命令。可以创建一个“累积式”命令。例如ContinuousMoveCommand在Update中累积输入向量在固定时间间隔或输入结束时才生成一个最终的移动命令放入历史栈。这既能减少命令数量又能让操作更流畅。命令模式是Unity中提升代码架构灵活性的强大工具但它不是银弹。清晰地界定其使用边界结合其他设计模式如状态模式、观察者模式才能构建出既清晰又高效的游戏系统。我最深的体会是引入命令模式的最佳时机是当你第一次冒出“要是能撤销刚才的操作就好了”这个念头的时候。从那个点开始设计往往能获得最直观的收益。