拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Unity运行时撤销重做系统实现:从命令模式到状态快照的完整方案

1. 项目概述在Unity编辑器里做开发CtrlZ和CtrlY这两个快捷键大概是除了鼠标点击之外我们按得最多的组合键了。无论是调整一个物体的位置、修改一个材质球的颜色还是不小心删掉了一堆精心摆放的Prefab撤销和重做功能都是我们最后的“后悔药”。但当我们从编辑器开发转向运行时Runtime的游戏逻辑开发时这套由Unity Editor内置的、开箱即用的撤销重做系统就失效了。你的玩家在游戏里误操作了想回退一步对不起没有这个功能。这就是为什么我们需要自己动手在游戏运行时实现一套撤销/重做Undo/Redo系统。这个需求听起来很基础但实际做起来你会发现它像洋葱一样剥开一层还有一层。它不仅仅是记录一个操作那么简单你需要考虑操作的类型移动、旋转、创建、删除、属性修改、操作的粒度是记录每一步微调还是合并一连串操作、数据的内存占用、以及最关键的状态序列化与反序列化。网上能找到的很多简单示例往往只演示了如何记录一个Vector3的位置但在真实的、复杂的项目里这种简单的实现很快就会遇到瓶颈。今天我就结合自己在一个中型策略游戏项目中实际踩过的坑来聊聊如何在Unity中实现一个健壮、可扩展的运行时撤销重做系统。我会从最基础的命令模式讲起逐步深入到支持多种操作类型、操作分组、以及如何优雅地处理Unity特有组件如Transform、Renderer.material的状态管理。文末也会提供一个完整的、附带详细注释的源项目你可以直接拿去用在你的项目里或者作为理解底层原理的参考。2. 核心设计思路与架构选型在动手写代码之前我们先得把架构想清楚。撤销重做的核心思想是“记录状态而非记录操作”。听起来有点抽象我举个例子假设你有一个按钮点击后让一个物体从A点移动到B点。一种实现方式是记录“从A移动到B”这个指令。但当你想撤销时如果物体已经被其他系统移动到了C点单纯执行“从B移回A”的逆指令就可能出错。更稳健的做法是在移动前完整记录物体在A点的所有相关状态位置、旋转、父节点等执行移动后再记录B点的状态。撤销时直接将状态恢复为A点的快照。基于这个思想业界最经典的模式就是命令模式Command Pattern。我们将每一个可撤销的操作封装成一个独立的“命令”对象。这个对象至少需要知道两件事1. 如何执行Do 2. 如何撤销Undo。同时我们需要两个栈Stack来分别管理已执行和已撤销的命令一个UndoStack撤销栈和一个RedoStack重做栈。2.1 基础命令模式实现我们先定义一个所有命令的基接口public interface ICommand { // 执行命令 void Execute(); // 撤销命令 void Undo(); // 获取命令的描述信息用于UI显示 string GetDescription(); }然后实现一个具体的命令比如移动物体public class MoveCommand : ICommand { private Transform targetTransform; private Vector3 previousPosition; private Vector3 newPosition; public MoveCommand(Transform target, Vector3 fromPos, Vector3 toPos) { targetTransform target; previousPosition fromPos; newPosition toPos; } public void Execute() { // 执行移动 targetTransform.position newPosition; } public void Undo() { // 撤销移动 targetTransform.position previousPosition; } public string GetDescription() $移动 {targetTransform.name}; }最后我们需要一个命令管理器CommandManager来维护栈和执行流程public class CommandManager { 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) { ICommand command undoStack.Pop(); command.Undo(); redoStack.Push(command); } } public void Redo() { if (redoStack.Count 0) { ICommand command redoStack.Pop(); command.Execute(); undoStack.Push(command); } } }这个基础框架已经能工作了。你可以创建一个CommandManager实例然后通过ExecuteCommand来执行移动命令再调用Undo和Redo。但是它离“生产可用”还差得很远。接下来我们要解决几个关键问题。2.2 从“记录指令”到“记录状态快照”上面的MoveCommand记录的是位移的起点和终点。这适用于移动但对于其他操作呢比如改变一个Renderer的材质颜色。颜色是一个Color值我们同样可以记录旧值和新值。但如果是“创建一个复杂的游戏物体”呢这个物体可能包含多个子物体、一堆组件、以及它们各自的属性。记录“创建”这个指令本身很简单但撤销“创建”就意味着要“删除”而删除时需要知道这个物体的完整引用和它在场景树中的位置以便能精确地还原。因此一个更通用、更强大的思路是为每一个需要支持撤销的操作对象创建其状态快照Snapshot。快照应该包含恢复该对象到某一时刻所需的最小数据集。在Unity中最通用的序列化状态的方式就是利用UnityEngine.JsonUtility或者自定义的序列化结构。我们可以设计一个通用的SnapshotCommandpublic abstract class SnapshotCommandT : ICommand where T : class { protected T target; protected string previousStateJson; protected string newStateJson; public SnapshotCommand(T target) { this.target target; // 在执行前捕获当前状态作为“前一个状态” previousStateJson CaptureState(target); } public void Execute() { // 执行具体操作... PerformOperation(); // 操作执行后捕获新状态 newStateJson CaptureState(target); } public void Undo() { // 将状态恢复为 previousStateJson ApplyState(target, previousStateJson); } // 重做其实就是再次执行 public void Redo() { ApplyState(target, newStateJson); } // 抽象方法由子类实现如何捕获和恢复状态 protected abstract string CaptureState(T obj); protected abstract void ApplyState(T obj, string stateJson); protected abstract void PerformOperation(); }这样对于任何类型的对象我们只需要继承SnapshotCommand并实现三个抽象方法就能轻松为其添加撤销支持。这个模式的核心优势在于将“状态管理”与“操作逻辑”解耦。实操心得在实际项目中我强烈建议采用这种“状态快照”模式而不是简单的“记录差值”。虽然快照可能占用更多内存尤其是对复杂对象但它极大地简化了逻辑复杂性避免了因操作顺序或外部干扰导致的撤销错误。对于大多数游戏对象其状态数据量并不大内存开销在可控范围内。你可以通过只记录真正变化的属性差分序列化来优化但这属于高级优化技巧初期建议以正确性优先。3. 实现细节处理Unity特有场景理论有了我们进入实战环节。在Unity中实现撤销重做有几个特有的难点需要攻克。3.1 对GameObject和Component的支持GameObject是Unity场景的基本单元。它的状态不仅包括自身的activeSelf还包括其所有组件的状态。我们不可能为每一个可能的组件类型都写一个快照命令。这里需要一种更动态的方法。一个可行的方案是我们定义一个IUndoable接口让任何需要支持撤销的组件自己来实现如何保存和加载状态。public interface IUndoable { // 返回一个唯一标识用于在撤销/重做时找到正确的对象 string GetUndoId(); // 捕获当前状态返回一个可序列化的字符串如JSON string CaptureState(); // 根据提供的状态字符串恢复状态 void RestoreState(string stateJson); }然后我们创建一个通用的ComponentSnapshotCommandpublic class ComponentSnapshotCommand : ICommand { private IUndoable undoableComponent; private string stateBefore; private string stateAfter; private Action performAction; // 执行的具体操作 public ComponentSnapshotCommand(IUndoable component, Action action) { undoableComponent component; performAction action; stateBefore component.CaptureState(); } public void Execute() { performAction?.Invoke(); stateAfter undoableComponent.CaptureState(); } public void Undo() undoableComponent.RestoreState(stateBefore); // 注意Redo需要恢复执行后的状态 public void Redo() undoableComponent.RestoreState(stateAfter); }这样任何一个MonoBehaviour只要实现了IUndoable接口就可以轻松地集成到撤销系统中。例如一个简单的TransformUndoable组件public class TransformUndoable : MonoBehaviour, IUndoable { public string GetUndoId() gameObject.GetInstanceID().ToString(); public string CaptureState() { var data new TransformData { position transform.localPosition, rotation transform.localRotation, scale transform.localScale }; return JsonUtility.ToJson(data); } public void RestoreState(string stateJson) { var data JsonUtility.FromJsonTransformData(stateJson); transform.localPosition data.position; transform.localRotation data.rotation; transform.localScale data.scale; } [System.Serializable] private class TransformData { public Vector3 position; public Quaternion rotation; public Vector3 scale; } }3.2 操作分组Undo Group这是提升用户体验的关键。想象一下玩家在拖拽一个物体时鼠标每移动一帧我们都记录一个撤销点。那么当他完成拖拽想撤销时可能需要按几十次CtrlZ才能回到起点。这显然是不可接受的。我们需要将一系列连续的操作合并成一个“组”一次撤销就回退整个组。这对应着Unity Editor API中的Undo.IncrementCurrentGroup()概念。在我们的命令管理器里可以引入“事务”Transaction的概念。在开始一系列操作前开始一个事务操作结束后提交事务。事务内部的所有命令会被打包成一个CompositeCommand组合命令。public class CommandManager { private StackICommand undoStack new StackICommand(); private StackICommand redoStack new StackICommand(); private ListICommand currentTransaction null; // 当前正在构建的事务 public void BeginTransaction() { // 如果已经在一个事务中先提交旧的或抛出异常取决于需求 if (currentTransaction ! null) { EndTransaction(); } currentTransaction new ListICommand(); } public void ExecuteCommand(ICommand command, bool autoEndTransaction false) { if (currentTransaction ! null) { // 事务中只执行不入栈先收集起来 command.Execute(); currentTransaction.Add(command); if (autoEndTransaction) { EndTransaction(); } } else { // 非事务中正常执行并入栈 command.Execute(); undoStack.Push(command); redoStack.Clear(); } } public void EndTransaction() { if (currentTransaction null || currentTransaction.Count 0) { currentTransaction null; return; } // 将事务中的所有命令打包成一个组合命令 var compositeCommand new CompositeCommand(currentTransaction); undoStack.Push(compositeCommand); redoStack.Clear(); currentTransaction null; // 事务结束 } // CompositeCommand 实现 private class CompositeCommand : ICommand { private ListICommand commands; public CompositeCommand(ListICommand cmds) { commands new ListICommand(cmds); } public void Execute() { foreach (var cmd in commands) cmd.Execute(); } public void Undo() { for (int i commands.Count - 1; i 0; i--) commands[i].Undo(); } // 注意Undo顺序是反的 } }使用时对于像拖拽这样的连续操作commandManager.BeginTransaction(); // ... 在拖拽的每一帧记录位置变化但使用ExecuteCommand(command, false)... commandManager.EndTransaction(); // 拖拽结束所有帧的变化合并为一次撤销3.3 处理对象的创建与销毁对象的创建和销毁是特殊的因为它们涉及对象的生命周期管理。你不能简单地记录一个快照因为销毁后对象不存在了无法对其应用状态。对于创建操作撤销就是销毁重做就是再次创建。我们需要记录足够的信息来重新创建完全相同的对象。这通常意味着需要记录Prefab的引用、实例化时的位置/旋转/父节点以及所有覆盖的属性。public class CreateObjectCommand : ICommand { private GameObject prefab; private GameObject instance; private Vector3 position; private Quaternion rotation; private Transform parent; public CreateObjectCommand(GameObject prefab, Vector3 pos, Quaternion rot, Transform parent) { this.prefab prefab; this.position pos; this.rotation rot; this.parent parent; } public void Execute() { if (instance null) { instance GameObject.Instantiate(prefab, position, rotation, parent); instance.name prefab.name (Clone); } else { instance.SetActive(true); } } public void Undo() { if (instance ! null) { // 注意这里不能直接Destroy因为重做时需要恢复。 // 通常做法是SetActive(false)并放入一个“回收站”列表或者使用对象池。 instance.SetActive(false); // 或者CommandManager.Instance.RegisterForCleanup(instance); // 由管理器统一在合适时机销毁 } } }对于销毁操作则正好相反。撤销时需要重新激活或实例化对象重做时需要再次“软销毁”SetActive(false)。这里的关键是不要立即调用GameObject.Destroy否则重做就无法实现。应该使用一个延迟销毁或对象池的机制。注意事项对象生命周期管理是撤销系统中最容易内存泄漏的地方。如果你采用SetActive(false)来“软删除”一定要有一个清晰的机制来最终清理这些不再被引用的对象例如在加载新场景时或者当撤销栈被清空时。一个常见的做法是命令管理器维护一个“待销毁对象列表”在确认这些对象不会再被重做引用后例如当有新的命令覆盖了它们所在的上下文再真正销毁它们。4. 性能优化与内存管理一个功能完善的撤销系统可能会记录大量状态快照如果不加控制很容易导致内存膨胀和性能下降。以下是几个关键的优化方向4.1 差分序列化与压缩全量快照虽然简单但对于具有大量属性的对象如一个复杂的UI面板序列化整个状态字符串会非常庞大。我们可以采用差分序列化只记录本次操作中实际发生变化的属性。例如在CaptureState时我们不是序列化整个对象而是与一个“基准状态”进行比较只序列化差异部分。这需要为每种对象类型定义一套属性路径和比较逻辑实现起来更复杂但内存收益显著。对于初学者可以前期采用全量快照在遇到性能瓶颈后再考虑引入差分机制。另外可以对序列化后的JSON字符串进行压缩如使用GZipStream进行轻量压缩虽然会增加一些CPU开销但能大幅减少内存占用对于需要支持大量撤销步骤的应用来说是值得的。4.2 撤销栈深度限制不可能无限地记录操作。必须为撤销栈设置一个最大深度例如50或100步。当栈满时加入新命令需要丢弃最旧的那个命令。public class CommandManager { private int maxStackDepth 100; private StackICommand undoStack new StackICommand(); public void ExecuteCommand(ICommand command) { command.Execute(); undoStack.Push(command); // 限制栈深度 if (undoStack.Count maxStackDepth) { // 移除最旧命令时需要小心如果该命令持有对游戏对象的唯一引用可能导致对象无法被垃圾回收。 // 一种做法是让命令实现IDisposable在出栈时清理资源。 var oldCommand RemoveOldestCommand(); (oldCommand as IDisposable)?.Dispose(); } redoStack.Clear(); } private ICommand RemoveOldestCommand() { // 由于Stack是LIFO要移除最旧的需要转换为List或使用Queue辅助。 // 更高效的做法是直接使用LinkedListICommand来管理。 } }实操心得栈深度限制不仅仅是内存问题也是产品逻辑问题。你需要和策划或产品经理确定一个合理的步数。对于策略游戏可能需要支持很多步比如50步对于动作游戏可能10步就足够了。同时在丢弃旧命令时一定要确保命令中持有的资源特别是对GameObject的引用能被正确释放防止内存泄漏。一个健壮的设计是让命令对象在确定不再需要时即被移出撤销/重做栈且没有其他引用主动释放其持有的临时资源。4.3 针对Transform的特别优化Transform是游戏中最常被修改的组件。全量序列化一个Transform位置、旋转、缩放的JSON字符串大约有150-200个字符。如果每帧都记录内存增长会很快。一个优化策略是对于连续的Transform更新如拖拽我们只在操作开始和结束时各记录一次完整快照。在操作过程中我们记录的是“增量”而不是“全量”。但这需要更精细的命令设计例如一个TransformDragCommand它内部包含一个起始快照和一个结束快照中间的帧不产生独立的命令。另一种更简单的优化是使用更紧凑的二进制格式而不是JSON来存储Transform数据。例如将三个Vector3和一个Quaternion直接转换为byte数组存储可以节省大量空间。5. 与Unity编辑器模式协同工作我们的系统主要面向运行时但有时我们也希望在编辑器扩展Editor Tool中也能使用同一套逻辑同时又不与Unity自带的Editor Undo系统冲突。一个理想的架构是抽象出一个IUndoSystem接口然后提供两个实现RuntimeUndoSystem使用我们自研的命令模式和EditorUndoSystem包装Unity的Undo类。public interface IUndoSystem { void RecordObject(object obj, string name); void RegisterCreatedObjectUndo(Object obj, string name); void PerformUndo(); void PerformRedo(); void ClearAll(); } #if UNITY_EDITOR public class EditorUndoSystem : IUndoSystem { public void RecordObject(object obj, string name) { if (obj is UnityEngine.Object uObj) UnityEditor.Undo.RecordObject(uObj, name); } // ... 包装其他Undo.XXX方法 } #endif public class RuntimeUndoSystem : IUndoSystem { private CommandManager commandManager new CommandManager(); // ... 使用我们自研的命令管理器实现接口 }在代码中通过条件编译或依赖注入在编辑器模式下使用EditorUndoSystem在运行时使用RuntimeUndoSystem。这样你的工具代码只需要面向IUndoSystem接口编写无需关心底层实现既能在编辑器中获得完美的Undo集成支持Unity内置的CtrlZ又能在运行时提供自定义的撤销功能。6. 常见问题与调试技巧即使设计得再完善在实际集成中还是会遇到各种问题。这里记录几个我踩过的坑和解决方法。6.1 状态恢复后组件引用丢失这个问题非常典型。假设你的一个Monster脚本持有一个对Weapon脚本的引用。你保存状态时序列化的是这个引用的实例IDGetInstanceID()。当你撤销操作时如果这个Weapon对象被销毁后又以新的实例ID重新创建那么用旧的ID就无法找到正确的对象导致引用为null。解决方案不要直接序列化引用对象的实例ID。改为序列化一个能够稳定定位该对象的路径例如在场景中的Transform路径transform.GetPath()或者一个全局唯一的GUID可以在对象创建时为其附加一个GuidComponent。在恢复状态时通过这个路径或GUID去动态查找对象。public class StableReference { public string objectPath; // 如 Canvas/Panel/Button // 或者 public string guid; } // 在RestoreState时 Transform targetTransform GameObject.Find(objectPath)?.transform; // 或者通过一个全局的Guid管理器查找 GameObject targetObj GuidManager.Instance.GetObject(guid);6.2 撤销/重做时的副作用有些操作除了修改目标对象的状态还会产生其他副作用。例如移动一个单位可能触发“OnUnitMoved”事件通知其他系统如寻路网格更新、触发器检测。如果你在撤销时仅仅恢复了单位的位置但没有触发相应的事件就可能导致游戏状态不一致。解决方案命令的Execute和Undo方法中不仅要修改数据还要确保触发所有必要的副作用事件。更好的设计是让命令本身不直接触发事件而是返回一个“结果”或“副作用描述”由一个外部的“副作用处理器”来统一执行。这样可以使命令对象保持纯净只负责状态管理。6.3 多线程与异步操作撤销/重做操作必须在主线程执行因为它们涉及Unity对象的创建和属性修改。如果你的游戏逻辑中有异步操作如从服务器加载数据后修改状态你需要确保将这些异步操作封装成命令时命令的Execute和Undo是同步的或者能安全地在主线程回调中执行。一个简单的模式是使用UnityEngine.UnityAction或System.Action来包装异步操作的结果处理public class AsyncDataLoadCommand : ICommand { private DataModel dataModel; private string previousData; private string newData; private System.ActionDataModel, string applyDataAction; public AsyncDataLoadCommand(DataModel model, System.ActionDataModel, string applyAction) { dataModel model; applyDataAction applyAction; previousData dataModel.Serialize(); } public async void Execute() { // 假设这是一个异步加载 newData await LoadDataFromServerAsync(); // 应用数据必须在主线程 UnityMainThreadDispatcher.Instance.Enqueue(() { applyDataAction?.Invoke(dataModel, newData); }); } public void Undo() { UnityMainThreadDispatcher.Instance.Enqueue(() { applyDataAction?.Invoke(dataModel, previousData); }); } }6.4 调试与可视化为了便于调试最好能为你的撤销系统添加一个简单的可视化窗口仅在开发时显示可以实时查看UndoStack和RedoStack的内容、深度以及每个命令的描述。这能帮助你快速定位是哪个命令执行出错或者为什么状态没有按预期恢复。public class UndoRedoDebugWindow : MonoBehaviour { private CommandManager manager; void OnGUI() { GUILayout.Label($Undo Stack: {manager.UndoStackCount}); foreach(var cmd in manager.UndoStackPreview) { GUILayout.Label($- {cmd.GetDescription()}); } GUILayout.Label($Redo Stack: {manager.RedoStackCount}); // ... 类似显示重做栈 } }7. 完整项目结构与源码导读附带的源项目按照一个中等规模Unity项目的标准进行组织力求清晰和可扩展。主要目录结构如下/UndoRedoSystem ├── /Runtime │ ├── /Core │ │ ├── ICommand.cs // 命令接口 │ │ ├── CommandManager.cs // 命令管理器核心 │ │ ├── CompositeCommand.cs // 组合命令用于分组 │ │ └── IUndoSystem.cs // 抽象接口 │ ├── /Commands │ │ ├── TransformCommand.cs // Transform状态命令 │ │ ├── GenericPropertyCommand.cs // 通用属性命令通过反射 │ │ ├── CreateDestroyCommand.cs // 创建销毁命令 │ │ └── ... // 其他具体命令 │ ├── /Components │ │ ├── UndoableBehaviour.cs // 可撤销组件的基类 │ │ └── TransformUndoable.cs // Transform组件实现 │ ├── /Utilities │ │ ├── SerializationHelper.cs // 序列化工具 │ │ └── GuidManager.cs // 全局GUID管理器 │ └── UndoRedoSystem.asmdef // 程序集定义便于模块化管理 ├── /Editor (可选) │ └── EditorUndoSystem.cs // 编辑器模式下的Undo系统包装器 └── /Demo ├── DemoScene.unity // 演示场景 └── DemoController.cs // 演示场景控制器核心文件解析CommandManager.cs这是系统的大脑。它不仅是栈的管理者还负责处理事务分组、栈深度限制、以及命令的最终生命周期清理资源。它被设计成单例模式方便全局访问。GenericPropertyCommand.cs这是一个“瑞士军刀”式的命令。它利用C#的反射机制可以记录和恢复任何对象的任何公有属性。虽然反射有性能开销但对于快速原型开发或者处理那些不常变化的复杂配置对象非常有用。在生产环境中对于性能关键的路径建议还是为特定类型编写专用的命令。CreateDestroyCommand.cs展示了如何处理对象生命周期的复杂性。它内部使用了一个简单的对象池来缓存“被销毁”的对象只有当撤销栈被清空或场景切换时才真正销毁它们完美支持了多次撤销/重做。UndoableBehaviour.cs这是一个MonoBehaviour基类提供了默认的CaptureState和RestoreState实现使用JsonUtility序列化所有标记了[SerializeField]的字段。你的任何组件只需要继承这个类就自动获得了撤销支持无需额外编写代码。这是实现“零配置”撤销的便捷途径。在Demo场景中你可以操作几个立方体尝试移动、旋转、缩放、改变颜色以及创建和删除物体。UI界面提供了清晰的按钮和键盘快捷键CtrlZ, CtrlY提示并且实时显示撤销/重做栈的状态帮助你直观理解整个系统的工作流程。实现一个完整的撤销重做系统就像给游戏世界安装了一个“时间控制器”。它不仅能提升产品的专业度和用户体验在开发阶段本身也是一个强大的调试工具。希望这篇长文和附带的项目能为你打下坚实的基础。记住从简单的命令模式开始逐步根据你的项目需求迭代和优化才是工程实践的正道。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门