C# 实现撤销与重做

发表于2018-12-14
评论5 1.35w浏览

本篇文章未经作者本人授权,禁止任何形式的转载,谢谢!如果在第三方阅读发现公式等格式有问题,请到下列地址阅读。

原文地址

知乎专栏地址
·

概要

编辑器开发以及一些特定类型的游戏开发过程中,经常会遇到撤销(Undo)和重做(Redo)的功能,即常见的 Crtl + Z 相关的功能。此功能看起来简单,但如果不进行一定的设计,会导致代码爆炸(每一个操作都要硬编码撤销和重做代码)。

我在给项目做地图编辑器的时候,自然也需要实现撤销和重做的功能,在这篇文章中我会分享一下我的做法。先说一下,我的想法是基于《游戏编程模式》一书中【命令模式】这一章中所讲的内容,但是书中对撤销与重做的例子过于简单,只有单个命令的撤销和重做,在这篇文章中,我会完整实现一个撤销与重做的框架并用 Unity 实现一个编辑器例子。

为了方便,本文中除了一个自己编写的双端队列数据结构外,其余所有代码都放在 Windsmoon 这个 namespace 下。

命令模式

此功能需要用到一种叫做命令模式的设计模式,就是把操作封装成一个命令,通俗易懂地说就是设计一个 Command 类,其中包含 Excute 和 Undo 两个方法。Excute 用于执行此命令,Undo 用于撤销此操作,但 Excute 还有一个功能,就是还可以 Redo 即重做之前 Undo 方法撤销的操作。代码很简单:

namespace Windsmoon
{
    public abstract class Command
    {
        #region methods
        public abstract void Excute();
        public abstract void Undo();

        public override string ToString()
       {
            return "This is a command without info";
        }
        #endregion
    }
}

当然这个 Command 是个基类,具体的命令要继承自它,并重写它的方法。当要执行某个操作的时候,需要编写特定的 XXXCommand 并继承自 Command,然后执行,撤销和重做就是调用其中的 Undo 和 Excute(前面说过 Excute 兼具 Redo 的功能)。

命令池数据结构

要撤销或者重做某个 Command,自然需要一个数据结构去存储这些 Command,否则就无法找到需要的 Command。这里我姑且称之为命令池,之前我叫做 UndoPool,但是感觉容易引起混淆,因为它是把 Undo 和 Redo 合在一起的。设计这个数据结构之前,可以从需求入手。

撤销需求

假设 ABC 分别是三个操作,当以 ABC 的顺序执行之后,想要全被撤销它们,显然是要以 CBA 的顺序进行撤销,这是一个典型的【后进先出】数据结构,很简单就不做过多解释了,可以以一个栈去保存这些操作过的命令,然后从这个栈中去取栈顶的命令进行撤销。

重做需求

重做一定是在撤销之后的,因为没有撤销的操作,就没有重做的对象。所以当以 ABC 的顺序执行操作,再以 CBA 的方式进行撤销之后,此时又想要重做这三个操作(当然顺序只能是 ABC,这是正常逻辑),稍加思考就可以得知撤销的 CBA 和重做的 ABC 又是一个后进先出,所以可以再用一个栈去保存进行过撤销的操作,然后就可以从这个栈中取出栈顶的命令进行重做。

撤销和重做的联系

取出待重做栈中的命令进行重做后,这个命令就被丢弃了吗,并不是,因为这个命令还可以再次被撤销。所以,这两个栈之间可以互相存取。即执行操作时,入【待撤销栈】,撤销时,从【待撤销栈】取出,入【待重做栈】,重做时,从【待重做栈】取出,入【待撤销栈】。顺着这个思路用 ABC 三个操作进行一下思考,就可以知道不一定要 ABC 全部撤销才可以开始重做,可以从这中间任何一个地方开始操作。有一点稍微特殊的是,当有一部分但不是全部操作从【待撤销栈】进入到【待重做栈】时,此时再进行正常的操作,就会有新的命令进入【待撤销栈】,这时【待重做栈】要如何处理呢?我问了一圈其他编辑工具的使用者,发现这时候就不允许再进行重写操作了,即进入了一个新的周期,所以这种情况下直接把【待重做栈】清空即可。

双端队列

看起来只需要两个栈就可以实现了,但实际上,我并没有用两个栈,因为用两个栈去操作会遇到一个问题:当你设定了一个最大可撤销数量时(一般都会有这个设定,毕竟内存不是无限大的),试想一下【待撤销栈】已经满了,当下一个命令准备入栈时,显然不能把这个命令丢弃掉而留下之前的操作,这会导致撤销时跳过了一个命令,所以我们需要把这个命令入栈,还要把栈底的命令踢出去。这不符合栈的数据结构设计,不过我们可以用【队列】来代替,但是用【队列】会造成无法取出正确的命令进行撤销(因为队列是一头进另一头出),所以最终的数据结构是【双端队列】,这样就可以在两端进行入队和出队操作了。

双端队列不在本文的讲解范围内,有问题的同学自行查阅即可,值得注意的是,C# 并没有内置的双端队列实现(至少我用的 Unity 版本没有),所以我仿照 .Net 的风格自己写了一个双端队列的实现,除了基础数据结构功能外也实现了迭代器等操作,基本和 C# 内置的 Queue 差不太多,代码直接在 Github 上看就可以了 C# 双端队列,基本就是 AddHead/Tail,RemoveHead/Tail 这样的命名。

设置的命令数量上限是针对待撤销的命令而言的,【待重做栈】是不需要考虑这个的。当【待撤销队列】(代替之前的【待撤销栈】)已经满了的时候,【待重做栈】一定是空的,否则就说明有操作被撤销了,即【待撤销队列】中有了空位(如前所说,每次【待撤销队列】有新元素加入时,【待重做栈】都会被清空)。所以【待重做栈】的数据结构不需要变动。

代码实现

以下是命令池的关键代码实现,完整代码可以参考 Github 上的源码 CommandPool

using System;
using System.Collections.Generic;

namespace Windsmoon
{
    public class CommandPool
   {
        #region fields
        private Stack<command> undidStack;
        private int maxCommandCount;
        private Deque<command> didDeque;
        #endregion

        #region properties
        public int TotalCommandCount
      {
            get
          {
                return didDeque.Count;
            }
        }

        public int UndidCommandCount
       {
            get
           {
                return undidStack.Count;
            }
        }

        public int DidCommandCount
      {
            get
          {
                return didDeque.Count;
            }
        }
        #endregion

        #region constructors
        public CommandPool(int maxCommandCount)
       {
            didDeque = new Deque<command>(maxCommandCount);
            undidStack = new Stack<command>();
            this.maxCommandCount = maxCommandCount;
        }
        #endregion

        #region methods
        public void Register(Command command)
      {
            undidStack.Clear();

            if (didDeque.Count == maxCommandCount)
          {
                didDeque.RemoveHead();
            }

            didDeque.AddTail(command);
        }

        public void Undo()
      {
            if (didDeque.Count == 0)
          {
                return;
            }

            Command command = didDeque.RemoveTail();
            command.Undo();
            undidStack.Push(command);
        }

        public void Redo()
      {
            if (undidStack.Count == 0)
          {
                return;
            }

            Command command = undidStack.Pop();
            command.Excute();
            didDeque.AddTail(command);
        }
        #endregion
    }
}

我只贴了关键的字段和方法,逻辑很简单, 在适当的地方 new 一个 CommandPool 对象,当要执行一个操作的时候,new 一个对应的 Command 子类,然后调用 CommandPool.Register 方法把它注册到【待撤销队列 】中。要执行撤销的时候就执行 CommandPool.Undo 方法,要执行重做的时候就执行 CommandPool.Redo 方法,其内部的操作就和之前描述的一样。

至此,一个命令池就完成了,后面我会用这些框架代码和 Unity 游戏引擎去实现一个编辑器撤销重做的例子。没有接触过 Unity 的同学也不必担心,我不会过多讲例子中的细节,只会给出其中方法的大概含义,不过需要安装 Unity 才能观察效果。如果你想在别的地方使用这套代码,直接把上面提到的文件拷贝走就可以使用了。

注意

下面的例子是拿 Unity 做的一个小编辑器扩展,不了解 Unity 的同学或者前面代码已经明白了的同学可以直接跳过例子章节到后面的经验总结部分。

下面的例子是拿 Unity 做的一个小编辑器扩展,不了解 Unity 的同学或者前面代码已经明白了的同学可以直接跳过例子章节到后面的经验总结部分。

下面的例子是拿 Unity 做的一个小编辑器扩展,不了解 Unity 的同学或者前面代码已经明白了的同学可以直接跳过例子章节到后面的经验总结部分。

例子

Unity 的例子工程地址是 Unity 例子,代码中有两个文件夹(在 Assets/Srcipts 文件夹下),Core 就是功能的核心代码,直接拷贝这个文件夹下的代码就可以在任意 C# 项目中使用,UnityCode 文件夹是存放 Unity 例子相关的代码的。

例子的功能是创建一个 10 x 10 的网格,鼠标点击网格会在其上生成一个立方体(一个格子只有一个),按住 Ctrl 再点击会把立方体删除掉。

在 UnityCode 中,有以下几个文件,每个文件对应一个类:

  • UndoRedoManager 用来引用 CommandPool 的实例,并且创建了三个按钮,分别是生成场景,Undo,Redo,在 Unity 上方菜单栏找到【UndoRedo】按钮,点开即可看到。
  • Raycaster 这个类用来处理点击事件,当鼠标点中时,会根据是否按住 Ctrl 来做不同的操作。
  • SceneMonobehaviour 用来和 Raycaster 一起工作的类,在这个例子里实际是个空类,这是 Unity 的编程风格,不必纠结。
  • CreateCubeCommand 继承自 Command 类,把生成立方体的操作封装成类,以便用于 Undo 和 Redo。
  • DestoryCubeCommand 继承自 Command 类,把销毁立方体的操作封装成类,以便用于 Undo 和 Redo。

下面我会大致讲讲这个例子,不必细看,实际上把例子之前的那些代码拷贝过去就可以用了。

首先点击按钮生成网格后,在选中生成的 Scene 物体(不用纠结为什么),单纯点击鼠标会调用到 Raycaster.CreateCube 方法, 代码如下:

private void CreateCube(Event currentEvent)
{
    ... // 判断是否已有立方体等操作
    CreateCubeCommand createCubeCommand = new            CreateCubeCommand(CreateCubeImpl, DestroyCubeImpl, raycastHit); // 1
    UndoRedoManager.CommandPool.Register(createCubeCommand); // 2
    createCubeCommand.Excute(); // 3
}

我只把关键的三行放在上面,第 1 行,new 一个 CreateCubeCommand 对象,构造函数代码如下:

        #region fields
        private RaycastHit raycastHit;
        private Action<raycasthit> excuteAction;
        private Action<raycasthit> undoAction;
        #endregion

        #region constructors
        public CreateCubeCommand(Action<raycasthit> excuteAction, Action<raycasthit> undoAction, RaycastHit raycastHit)
      {
            this.excuteAction = excuteAction;
            this.undoAction = undoAction;
            this.raycastHit = raycastHit;
        }
        #endregion

三个参数分别是执行函数,撤销函数,以及一个 RaycastHit 对象(在这里是用来存储点击位置的信息的,不用纠结)。继承自 Command 类要重写 Excute 和 Undo 方法,在这个类中是非常简单的实现:

        public override void Excute()
      {
            excuteAction(raycastHit);
        }

        public override void Undo()
      {
            undoAction(raycastHit);
        }

代码如上所示,就是直接调用传入构造函数的方法。

第 2 行,把 Command 的实例注册到 CommandPool 中,第 3 行,执行此次操作。至此,一个操作就做完并且注册到命令池中了。之后只要调用 CommandPool.Undo 和 CommandPool.Redo 方法就可以进行撤销和重做了。

执行操作的时机也可以手动调用操作的函数,不用 Command.Excute 方法,并且具体的执行和撤销代码也可以直接放在 Command 中的 Excute 和 Undo 方法中,不必像例子中那样传入两个函数。

当按住 Ctrl 再点击鼠标,会执行 DestroyCube 方法,

        private void DestroyCube(Event currentEvent)
      {
            ... /// 各种判断
            DestoryCubeCommand destoryCubeCommand = new DestoryCubeCommand(DestroyCubeImpl, CreateCubeImpl, raycastHit);
            UndoRedoManager.CommandPool.Register(destoryCubeCommand);
            destoryCubeCommand.Excute();
        }

这个方法是与前面 Create 正好相反的(注意构造函数的参数),核心代码就这么多,有 Unity 的同学可以试一试。

一些经验总结

我自己在使用的过程中,遇到了一些小坑,总结了一些经验:

  • 当你有相反的操作的时候,例如上面例子中的创建与删除时,可以只用一个 Command 子类,只需要传入一个创建还是销毁的标记即可,但是我不建议这么做,因为很容易乱,尤其是操作复杂以及有很多种操作的时候。可以自己再重新划分继承树的结构,总之建议每个操作对应一个单独的类。
  • 是传入数据参数,直接在 Excute 和 Undo 里写具体的操作代码,还是在外部写好作文函数传入,这个我个人觉得都可以,但我比较喜欢作为函数传入,这样在操作需要涉及很多变量的时候会方便一些。

结尾

这篇文章中的代码与我在项目中使用的略有不同(没有本质的区别),但这里的代码,测试只是靠上面开发的 Unity 的例子进行测试,如果发现了 bug,还请告诉我,谢谢~(应该没什么问题,毕竟项目里都用了好久了)

感谢阅读~

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引