UndoManager并不是普通游戏流程中的常用系统它主要用于游戏内 UGC 编辑器。例如家园摆放、地图制作、关卡编辑、角色捏脸等功能玩家会频繁修改作品并希望随时撤销或恢复上一次操作。MyFramework 通过UndoManager保存这些编辑记录同时利用同一套操作生成对应的重做记录。项目地址https://github.com/ZHOURUIH/MyFramework一、适合哪些 UGC 功能典型使用场景包括家园和家具摆放 地图与关卡编辑器 角色捏脸和换装 建筑建造模式 技能节点编辑器 游戏内剧情编辑器这些功能都有共同特点玩家会连续进行大量编辑 每次编辑都会改变作品数据 操作结果需要立即预览 玩家可能随时撤销或重做普通战斗、任务和角色移动通常不需要记录撤销历史。另外本文讨论的是游戏运行时的 UGC 编辑器。如果只是开发者在 Unity Editor 中编辑场景应优先使用 Unity 提供的UnityEditor.Undo。二、UndoManager 的内部结构框架中的MyUndo非常简单// 撤销操作基类 public abstract class MyUndo : ClassObject { public abstract void undo(); }每一种可撤销操作都继承MyUndo并实现如何恢复之前的状态。UndoManager继承自FrameSystem内部主要保存两个列表protected ListMyUndo mUndoList new(); // 撤销操作列表 protected ListMyUndo mRedoList new(); // 重做操作列表 protected int mMaxUndo 10; protected bool mUndoing; protected bool mRedoing;两个列表分别表示mUndoList 当前可以撤销的操作 mRedoList 当前可以重新执行的操作它们都是后进先出的结构。最后一次编辑会最先被撤销。三、为什么 MyUndo 只需要一个 undo()通常实现撤销重做会分别定义undo redo但 MyFramework 只要求实现一个public abstract void undo();原因是执行撤销时会自动记录当前状态作为反向操作。假设玩家把家具从 A 移动到 B正常编辑 A → B 记录的撤销操作 恢复到 A执行撤销时家具当前位于 B。恢复到 A 之前框架又会记录恢复到 B这条记录就是重做操作。因此整个过程是A → B 记录“回到A” 撤销 B → A 自动记录“回到B” 重做 A → B 自动记录“回到A”撤销和重做本质上都是“恢复到某个历史状态”所以可以共用同一个undo()。四、addUndo() 如何区分撤销和重做记录UndoManager.addUndo()的核心实现如下public void addUndo(MyUndo undo) { bool lastCanUndo canUndo(); bool lastCanRedo canRedo(); // 正在执行撤销时新产生的是重做操作 if (mUndoing) { mRedoList.Add(undo); } else { if (mUndoList.Count mMaxUndo) { UN_CLASS(mUndoList.removeAt(0)); } mUndoList.Add(undo); // 普通编辑会产生新的操作分支需要清空重做列表 if (!mRedoing) { clearRedo(); } } if (canUndo() ! lastCanUndo || canRedo() ! lastCanRedo) { mUndoRedoChangeCallback?.Invoke(); } }它根据当前状态决定记录放到哪里。正常编辑mUndoing false mRedoing false新的记录进入mUndoList同时清空原来的mRedoList。正在撤销mUndoing true恢复过程中产生的反向记录进入mRedoList正在重做mRedoing true重做过程中产生的反向记录重新进入mUndoList因此业务代码不需要自己操作两个列表。五、undo() 的内部实现框架中的撤销逻辑是public void undo() { bool lastCanUndo canUndo(); mUndoing true; if (canUndo()) { mUndoList[^1].undo(); UN_CLASS(mUndoList.popBack()); } mUndoing false; if (canUndo() ! lastCanUndo) { mUndoRedoChangeCallback?.Invoke(); } }执行过程为将 mUndoing 设置为 true ↓ 执行最后一个 MyUndo ↓ MyUndo 内部产生的反向记录进入 mRedoList ↓ 移除并回收已经执行的撤销对象 ↓ 结束撤销状态这里最关键的是mUndoing true;只要MyUndo.undo()内部继续通过正常业务命令修改数据命令创建的新记录就会被addUndo()自动放进重做列表。六、redo() 的内部实现重做的逻辑与撤销基本相同public void redo() { bool lastCanRedo canRedo(); mRedoing true; if (canRedo()) { mRedoList[^1].undo(); UN_CLASS(mRedoList.popBack()); } mRedoing false; if (canRedo() ! lastCanRedo) { mUndoRedoChangeCallback?.Invoke(); } }执行重做时从 mRedoList 取出最后一条记录 ↓ 调用同一个 undo() ↓ 恢复过程中创建新的反向记录 ↓ 由于 mRedoing 为 true反向记录进入 mUndoList所以MyUndo.undo()中不能只直接修改数据它通常需要调用正常的编辑命令才能生成反向记录。七、实现家具位置撤销下面以 UGC 家具编辑器为例。创建一个保存历史位置的撤销对象using UnityEngine; public class UndoSetPosition : MyUndo { protected ITransformable mTarget; protected Vector3 mPosition; public UndoSetPosition setData( ITransformable target, Vector3 position) { mTarget target; mPosition position; return this; } public override void undo() { if (mTarget null || mTarget.isDestroy()) { return; } CmdUGCSetPosition.execute( mTarget, mPosition); } public override void resetProperty() { base.resetProperty(); mTarget null; mPosition Vector3.zero; } }它保存编辑对象 需要恢复的位置MyUndo继承自ClassObject所以全部字段都必须在resetProperty()中重置。八、封装正常的位置修改命令家具位置不能在各处直接修改需要统一通过 Commandusing UnityEngine; using static FrameBaseHotFix; using static FrameUtility; public class CmdUGCSetPosition { public static void execute( ITransformable target, Vector3 position) { if (target null || target.isDestroy()) { return; } Vector3 oldPosition target.getPosition(); if (oldPosition position) { return; } mUndoManager.addUndo( CLASSUndoSetPosition().setData( target, oldPosition)); target.setPosition(position); } }正常编辑时CmdUGCSetPosition.execute( furniture, newPosition);命令先保存旧位置再应用新位置。执行撤销时UndoSetPosition.undo()也会调用同一个命令CmdUGCSetPosition.execute( mTarget, mPosition);此时命令读取当前位置并生成对应的重做记录。调用关系如下普通编辑 CmdUGCSetPosition ├── 记录旧位置到 UndoList └── 设置新位置 执行撤销 UndoSetPosition.undo ↓ CmdUGCSetPosition ├── 记录当前位置到 RedoList └── 恢复旧位置 执行重做 UndoSetPosition.undo ↓ CmdUGCSetPosition ├── 记录当前位置到 UndoList └── 恢复重做位置这正是UndoManager内部两个状态标记的作用。九、拖拽不能每帧添加记录UGC 编辑器中家具拖拽会持续很多帧。不能在拖拽过程中反复调用CmdUGCSetPosition.execute( mDragTarget, position);否则一次拖拽会生成大量撤销记录。正确方式是开始拖拽 保存初始位置 拖拽过程中 直接更新预览位置 结束拖拽 只添加一条撤销记录示例protected ITransformable mDragTarget; protected Vector3 mDragStartPosition; protected void onDragStart( ITransformable target) { mDragTarget target; mDragStartPosition target.getPosition(); } protected void onDragging(Vector3 position) { mDragTarget?.setPosition(position); } protected void onDragEnd() { if (mDragTarget null || mDragTarget.isDestroy() || mDragTarget.getPosition() mDragStartPosition) { mDragTarget null; return; } mUndoManager.addUndo( CLASSUndoSetPosition().setData( mDragTarget, mDragStartPosition)); mDragTarget null; }现在一次完整拖拽只会生成一条历史记录。撤销时命令会读取家具拖拽结束后的位置从而自动生成重做记录。十、为什么新操作会清空重做列表假设编辑历史是A → B → C撤销后回到 B可撤销B → A 可重做B → C此时玩家没有重做而是直接将内容编辑为 DA → B → D原来的 C 已经不属于当前编辑分支所以必须清空重做列表。对应的内部判断是if (!mRedoing) { clearRedo(); }普通编辑时mRedoing为false所以会清空重做记录。重做过程中产生撤销记录时mRedoing为true不会误删剩余的重做列表。十一、记录上限与对象池回收UndoManager默认最多保存十条撤销记录protected int mMaxUndo 10;UGC 编辑器可以调整mUndoManager.setMaxUndoCount(50);超过最大数量时会删除最早的记录if (mUndoList.Count mMaxUndo) { UN_CLASS(mUndoList.removeAt(0)); }撤销对象来自ClassPool记录被删除、执行或清空后都会通过UN_CLASS回收。清空所有记录mUndoManager.clearAll();内部会回收两个列表中的全部对象UN_CLASS_LIST(mUndoList); clearRedo();进入新的 UGC 作品、切换地图或退出编辑器时都应该调用clearAll()避免旧记录继续引用上一份作品中的对象。十二、更新撤销和重做按钮UndoManager支持监听可用状态变化mUndoManager.addUndoRedoChangeCallback( refreshUndoRedo);刷新按钮protected void refreshUndoRedo() { mUndoButton.setActive( mUndoManager.canUndo()); mRedoButton.setActive( mUndoManager.canRedo()); }界面关闭时注销mUndoManager.removeUndoRedoChangeCallback( refreshUndoRedo);按钮点击protected void onUndoClick() { mUndoManager.undo(); } protected void onRedoClick() { mUndoManager.redo(); }这样编辑器界面不需要自己判断列表数量。十三、总结UndoManager主要面向游戏内 UGC 编辑器而不是普通游戏业务。它的内部实现依赖四个关键点mUndoList 保存可以撤销的操作 mRedoList 保存可以重做的操作 mUndoing 让撤销过程中产生的反向操作进入 RedoList mRedoing 让重做过程中产生的反向操作进入 UndoList业务层接入时需要1. 将编辑行为封装成 Command 2. 创建对应的 MyUndo 保存历史状态 3. 正常编辑前调用 addUndo 4. MyUndo.undo() 继续调用正常编辑命令 5. 由 UndoManager 自动生成反向记录 6. 一次连续拖拽只记录一次 7. 离开 UGC 编辑器时调用 clearAll这样位置、旋转、缩放、创建、删除和属性修改等不同 UGC 操作都可以共用同一套撤销与重做机制。