ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

1. 项目概述

在Unity编辑器里做开发,Ctrl+Z和Ctrl+Y这两个快捷键大概是除了鼠标点击之外,我们按得最多的组合键了。无论是调整一个物体的位置、修改一个材质球的颜色,还是不小心删掉了一堆精心摆放的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 Stack<ICommand> undoStack = new Stack<ICommand>(); private Stack<ICommand> redoStack = new Stack<ICommand>(); 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来执行移动命令,再调用UndoRedo。但是,它离“生产可用”还差得很远。接下来,我们要解决几个关键问题。

2.2 从“记录指令”到“记录状态快照”

上面的MoveCommand记录的是位移的起点和终点。这适用于移动,但对于其他操作呢?比如改变一个Renderer的材质颜色。颜色是一个Color值,我们同样可以记录旧值和新值。但如果是“创建一个复杂的游戏物体”呢?这个物体可能包含多个子物体、一堆组件、以及它们各自的属性。记录“创建”这个指令本身很简单,但撤销“创建”就意味着要“删除”,而删除时需要知道这个物体的完整引用和它在场景树中的位置,以便能精确地还原。

因此,一个更通用、更强大的思路是:为每一个需要支持撤销的操作对象,创建其状态快照(Snapshot)。快照应该包含恢复该对象到某一时刻所需的最小数据集。在Unity中,最通用的序列化状态的方式就是利用UnityEngine.JsonUtility或者自定义的序列化结构。

我们可以设计一个通用的SnapshotCommand

public abstract class SnapshotCommand<T> : 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); }

然后,我们创建一个通用的ComponentSnapshotCommand

public 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.FromJson<TransformData>(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)

这是提升用户体验的关键。想象一下,玩家在拖拽一个物体时,鼠标每移动一帧,我们都记录一个撤销点。那么当他完成拖拽,想撤销时,可能需要按几十次Ctrl+Z才能回到起点。这显然是不可接受的。

我们需要将一系列连续的操作合并成一个“组”,一次撤销就回退整个组。这对应着Unity Editor API中的Undo.IncrementCurrentGroup()概念。

在我们的命令管理器里,可以引入“事务”(Transaction)的概念。在开始一系列操作前,开始一个事务;操作结束后,提交事务。事务内部的所有命令会被打包成一个CompositeCommand(组合命令)。

public class CommandManager { private Stack<ICommand> undoStack = new Stack<ICommand>(); private Stack<ICommand> redoStack = new Stack<ICommand>(); private List<ICommand> currentTransaction = null; // 当前正在构建的事务 public void BeginTransaction() { // 如果已经在一个事务中,先提交旧的(或抛出异常,取决于需求) if (currentTransaction != null) { EndTransaction(); } currentTransaction = new List<ICommand>(); } 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 List<ICommand> commands; public CompositeCommand(List<ICommand> cmds) { commands = new List<ICommand>(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 Stack<ICommand> undoStack = new Stack<ICommand>(); 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辅助。 // 更高效的做法是直接使用LinkedList<ICommand>来管理。 } }

实操心得:栈深度限制不仅仅是内存问题,也是产品逻辑问题。你需要和策划或产品经理确定一个合理的步数。对于策略游戏,可能需要支持很多步(比如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内置的Ctrl+Z),又能在运行时提供自定义的撤销功能。

6. 常见问题与调试技巧

即使设计得再完善,在实际集成中还是会遇到各种问题。这里记录几个我踩过的坑和解决方法。

6.1 状态恢复后组件引用丢失

这个问题非常典型。假设你的一个Monster脚本持有一个对Weapon脚本的引用。你保存状态时,序列化的是这个引用的实例ID(GetInstanceID())。当你撤销操作时,如果这个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”事件,通知其他系统(如寻路网格更新、触发器检测)。如果你在撤销时仅仅恢复了单位的位置,但没有触发相应的事件,就可能导致游戏状态不一致。

解决方案:命令的ExecuteUndo方法中,不仅要修改数据,还要确保触发所有必要的副作用事件。更好的设计是,让命令本身不直接触发事件,而是返回一个“结果”或“副作用描述”,由一个外部的“副作用处理器”来统一执行。这样可以使命令对象保持纯净,只负责状态管理。

6.3 多线程与异步操作

撤销/重做操作必须在主线程执行,因为它们涉及Unity对象的创建和属性修改。如果你的游戏逻辑中有异步操作(如从服务器加载数据后修改状态),你需要确保将这些异步操作封装成命令时,命令的ExecuteUndo是同步的,或者能安全地在主线程回调中执行。

一个简单的模式是使用UnityEngine.UnityActionSystem.Action来包装异步操作的结果处理:

public class AsyncDataLoadCommand : ICommand { private DataModel dataModel; private string previousData; private string newData; private System.Action<DataModel, string> applyDataAction; public AsyncDataLoadCommand(DataModel model, System.Action<DataModel, 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 调试与可视化

为了便于调试,最好能为你的撤销系统添加一个简单的可视化窗口(仅在开发时显示),可以实时查看UndoStackRedoStack的内容、深度,以及每个命令的描述。这能帮助你快速定位是哪个命令执行出错,或者为什么状态没有按预期恢复。

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 // 演示场景控制器

核心文件解析:

  1. CommandManager.cs:这是系统的大脑。它不仅是栈的管理者,还负责处理事务分组、栈深度限制、以及命令的最终生命周期(清理资源)。它被设计成单例模式,方便全局访问。
  2. GenericPropertyCommand.cs:这是一个“瑞士军刀”式的命令。它利用C#的反射机制,可以记录和恢复任何对象的任何公有属性。虽然反射有性能开销,但对于快速原型开发,或者处理那些不常变化的复杂配置对象非常有用。在生产环境中,对于性能关键的路径,建议还是为特定类型编写专用的命令。
  3. CreateDestroyCommand.cs:展示了如何处理对象生命周期的复杂性。它内部使用了一个简单的对象池来缓存“被销毁”的对象,只有当撤销栈被清空或场景切换时,才真正销毁它们,完美支持了多次撤销/重做。
  4. UndoableBehaviour.cs:这是一个MonoBehaviour基类,提供了默认的CaptureStateRestoreState实现(使用JsonUtility序列化所有标记了[SerializeField]的字段)。你的任何组件只需要继承这个类,就自动获得了撤销支持,无需额外编写代码。这是实现“零配置”撤销的便捷途径。

在Demo场景中,你可以操作几个立方体,尝试移动、旋转、缩放、改变颜色,以及创建和删除物体。UI界面提供了清晰的按钮和键盘快捷键(Ctrl+Z, Ctrl+Y)提示,并且实时显示撤销/重做栈的状态,帮助你直观理解整个系统的工作流程。

实现一个完整的撤销重做系统,就像给游戏世界安装了一个“时间控制器”。它不仅能提升产品的专业度和用户体验,在开发阶段本身也是一个强大的调试工具。希望这篇长文和附带的项目能为你打下坚实的基础。记住,从简单的命令模式开始,逐步根据你的项目需求迭代和优化,才是工程实践的正道。

返回列表