撤消引擎的设计模式

我正在为一个土木工程应用程序编写一个结构建模工具。我有一个表示整个建筑的巨大模型类,它包括节点、线元素、负载等的集合,这些也是自定义类。

我已经编写了一个撤销引擎,它在每次修改模型之后都会保存一个深度拷贝。现在我开始想,如果我可以有不同的编码。除了保存深度副本,我还可以使用相应的反向修饰符保存每个修饰符操作的列表。这样我就可以对当前模型应用反向修饰符来撤消,或者对要重做的修饰符应用反向修饰符。

我可以想象您将如何执行改变对象属性的简单命令,等等。但是复杂的命令呢?比如向模型中插入新的节点对象,并添加一些保留对新节点引用的行对象。

如何实现这一点呢?

37821 次浏览

我看到的大多数例子都使用了 命令模式的一个变体。每个可撤销的用户操作都有自己的命令实例,其中包含执行操作和回滚操作的所有信息。然后,您可以维护所有已执行命令的列表,并逐个回滚这些命令。

刚刚在我的敏捷开发书中读到了命令模式——也许它有潜力?

您可以让每个命令实现命令接口(它有一个 Execute ()方法)。如果要撤消,可以添加撤消方法。

更多信息

你可能想参考 Paint.NET 代码的撤销-他们有一个非常好的撤销系统。它可能比你需要的要简单一些,但是它可能会给你一些想法和指导方针。

亚当

这可能是适用 CSLA的情况。它旨在为 Windows 窗体应用程序中的对象提供复杂的撤消支持。

我必须这样做,当写一个钉跳益智游戏的解决方案。我让每个移动一个命令对象,其中包含足够的信息,可以做或撤消。在我的情况下,这就像存储起始位置和每个动作的方向一样简单。然后,我将所有这些对象存储在一个堆栈中,这样程序就可以在回溯时轻松地撤消所需的任何移动。

如果你说的是 GoF,那么 纪念品模式专门解决撤销问题。

我曾经在一个应用程序中工作过,在这个应用程序中,所有由命令对应用程序模型所做的更改(比如 CDocument... 我们使用的是 MFC)都通过更新模型中维护的内部数据库中的字段来保持在命令的末尾。因此,我们不必为每个操作编写单独的撤消/重做代码。撤消堆栈只是在每次更改记录时(在每个命令的末尾)记住主键、字段名和旧值。

我同意 门德尔特 · 西本加的观点,你应该使用命令模式。你使用的模式是记忆碎片模式,随着时间的推移,它会变得非常浪费。

因为您正在处理一个内存密集型应用程序,所以您应该能够指定允许撤消引擎占用多少内存,节省多少撤消级别,或者指定它们将持久存储到哪些存储中。如果您不这样做,您将很快面临由于机器内存不足而导致的错误。

我建议您检查是否有一个框架已经在您选择的编程语言/框架中创建了撤销模型。发明新东西固然不错,但是最好是在实际场景中使用已经编写、调试和测试过的东西。如果您添加了正在编写的内容,将会有所帮助,这样人们就可以推荐他们熟悉的框架。

我读过的大多数例子都是通过使用命令或者记忆模式来完成的。但是你可以做到这一点,没有设计模式太与一个简单的 建筑结构

我们重用了文件加载,并将“对象”的序列化代码保存为一种方便的形式,以保存和恢复对象的整个状态。我们将这些序列化的对象推送到撤销堆栈上-同时提供一些关于执行了什么操作的信息,以及在从序列化数据中收集到的信息不足时撤销该操作的提示。撤销和重做通常只是用一个对象替换另一个(理论上)。

There have been many MANY bugs due to pointers (C++) to objects that were never fixed-up as you perform some odd undo redo sequences (those places not updated to safer undo aware “identifiers”). Bugs in this area often ...ummm... interesting.

有些操作可能是速度/资源使用的特殊情况——比如调整尺寸、移动物体。

多重选择也提供了一些有趣的复杂性。幸运的是,我们在代码中已经有了分组的概念。克里斯托弗 · 约翰逊关于子项的评论与我们的工作非常接近。

I've implemented complex undo systems sucessfully using the Memento pattern - very easy, and has the benefit of naturally providing a Redo framework too. A more subtle benefit is that aggregate actions can be contained within a single Undo too.

简而言之,你有两堆纪念品。一个是撤销,另一个是重做。每个操作都会创建一个新的记忆体,理想情况下,这将是一些调用来更改您的模型、文档(或任何东西)的状态。这将添加到撤消堆栈中。执行撤消操作时,除了对 Memento 对象执行撤消操作以再次更改模型之外,还将撤消堆栈中的对象弹出,并将其直接推送到重做堆栈中。

How the method to change the state of your document is implemented depends completely on your implementation. If you can simply make an API call (e.g. ChangeColour(r,g,b)), then precede it with a query to get and save the corresponding state. But the pattern will also support making deep copies, memory snapshots, temp file creation etc - it's all up to you as it is is simply a virtual method implementation.

为了执行聚合操作(例如用户 Shift-选择一个对象负载来执行一个操作,例如删除、重命名、更改属性) ,你的代码创建一个新的撤销堆栈作为一个单一的记忆体,并将其传递给实际操作来添加单个操作。因此,您的操作方法不需要(a)有一个需要担心的全局堆栈,而且(b)可以相同地编码,无论它们是单独执行还是作为一个聚合操作的一部分执行。

许多撤销系统只在内存中,但是如果您愿意,可以将撤销堆栈持久化。

正如其他人所说,命令模式是实现 Undo/Redo 的一种非常强大的方法。但是我想提到的指挥模式有一个重要的优势。

在使用命令模式实现撤消/重做时,可以通过抽象(在一定程度上)对数据执行的操作并利用撤消/重做系统中的这些操作来避免大量重复的代码。例如,在文本编辑器中,剪切和粘贴是互补的命令(除了管理剪贴板之外)。换句话说,剪切的撤消操作是粘贴,而剪切的撤消操作是剪切。这适用于键入和删除文本等更简单的操作。

这里的关键是您可以使用撤消/重做系统作为编辑器的主要命令系统。不需要编写诸如“创建撤销对象,修改文档”之类的系统,您可以“创建撤销对象,对撤销对象执行重做操作以修改文档”。

现在,不可否认的是,许多人正在思考他们自己: “好吧,这不是命令模式的一部分吗?”是的,但是我见过太多的命令系统有两组命令,一组用于立即操作,另一组用于撤销/重做。我并不是说没有特定于立即操作和撤销/重做的命令,但是减少重复将使代码更易于维护。

设计模式的第一部分(GoF,1994)有一个将撤销/重做实现为设计模式的用例。

我认为,当您处理 OP 所暗示的大小和范围的模型时,memento 和 command 都是不实用的。它们可以工作,但需要大量的工作来维护和扩展。

对于这种类型的问题,我认为需要构建对数据模型的支持,以支持模型中涉及的 所有物品的差异检查点。我试过一次,效果很好。您必须做的最大的事情是避免在模型中直接使用指针或引用。

对另一个对象的每个引用都使用某个标识符(如整数)。无论何时需要该对象,都可以从表中查找该对象的当前定义。该表包含每个对象的链表,其中包含所有以前的版本,以及关于它们活动于哪个检查点的信息。

实现撤消/重做很简单: 执行操作并建立一个新的检查点; 将所有对象版本回滚到前一个检查点。

它在代码中需要一些规则,但有很多优点: 你不需要深拷贝,因为你正在做模型状态的差异存储; 你可以通过重做或内存使用的数量来确定你想要使用的内存量(very对于 CAD 模型很重要) ; 对于在模型上操作的函数来说,非常可伸缩和低维护,因为它们不需要做任何实现撤销/重做的事情。

作为参考,下面是 C # : C # 的简单撤销/重做系统中撤销/重做命令模式的一个简单实现。

科德拉克斯项目 :

这是一个基于经典 Command 设计模式向应用程序添加 Undo/Redo 功能的简单框架。它支持合并操作、嵌套事务、延迟执行(在顶级事务提交上执行)和可能的非线性撤消历史(您可以选择重做多个操作)。

I don't know if this is going to be of any use to you, but when I had to do something similar on one of my projects, I ended up downloading UndoEngine from http://www.undomadeeasy.com - a wonderful engine and I really didn't care too much about what was under the bonnet - it just worked.

处理撤消的一种聪明方法是实现数据结构的 操作转换,这将使您的软件也适合于多用户协作。

这个概念不是很流行,但定义明确,很有用。如果你觉得这个定义太抽象,那么 这个项目就是一个成功的例子,说明了如何在 Javascript 中定义和实现 JSON 对象的操作转换

您可以在 PostSharp. https://www.postsharp.net/model/undo-redo中尝试现成的 Undo/Redo 模式实现

It lets you add undo/redo functionality to your application without implementing the pattern yourself. It uses Recordable pattern to track the changes in your model and it works with INotifyPropertyChanged pattern which is also implemented in PostSharp.

提供了 UI 控件,您可以决定每个操作的名称和粒度。

我认为,UNDO/REDO 可以通过两种方式广泛地实施。 1. 命令级(称为命令级撤销/重做) 2. 文档级别(称为全局撤销/重做)

命令级别: 正如许多答案指出的那样,这是通过 Memento 模式有效实现的。如果该命令还支持记录操作,则很容易支持重做。

限制: 一旦命令的作用域超出,撤销/重做就不可能了,这将导致文档级别(全局)撤销/重做

我想您的情况适合于全局撤消/重做,因为它适合于涉及大量内存空间的模型。此外,这也适用于有选择地撤消/重做。有两种基本类型

  1. All memory undo/redo
  2. 对象级撤消重做

在“所有内存撤销/重做”中,整个内存被视为一个连接的数据(如树、列表或图表) ,内存由应用程序而不是操作系统管理。因此,如果在 C + + 中,new 和 delete 操作符被重载,以包含更多特定的结构,从而有效地实现诸如。如果任何节点被修改,b 保存和清除数据等, 它的工作方式基本上是复制整个内存(假设内存分配已经由应用程序使用高级算法进行了优化和管理)并将其存储在堆栈中。如果请求内存的副本,则根据需要进行浅拷贝或深拷贝来复制树结构。深度副本只为被修改的变量生成。由于每个变量都是使用自定义分配进行分配的,因此应用程序在需要的时候拥有删除它的最终决定权。 当我们需要以编程方式选择性地撤销/重做一组操作时,如果我们必须对撤销/重做进行分区,那么事情就会变得非常有趣。在这种情况下,只有那些新的变量、已删除的变量或已修改的变量被赋予一个标志,以便 Undo/Redo 只撤消/重做那些内存 如果我们需要在对象内部执行部分撤销/重做,那么事情就变得更有趣了。在这种情况下,使用“访问者模式”的新思想。它被称为“对象级撤销/重做”

  1. 对象级撤销/重做: 当调用撤销/重做通知时,每个对象实现一个流操作,其中,流媒体从对象获取已编程的旧数据/新数据。不受干扰的数据不受干扰。每个对象都获得一个流注作为参数,在 UNDo/Redo 调用中,它流/取消流注对象的数据。

1和2都可以具有如下方法 1. 恢复前() 2. AfterUndo () 3. 重做之前() 4.After Redo ().这些方法必须在基本的撤销/重做命令(而不是上下文命令)中发布,以便所有对象也实现这些方法以获得特定的操作。

一个好的策略是创建1和2的混合体。美妙之处在于这些方法(1 & 2)本身使用命令模式

你可以把你最初的想法付诸行动。

使用 持久性数据结构,并坚持保持 附近旧州的参考文献列表。(但是,只有当操作状态类中的所有数据都是不可变的,并且对其进行的所有操作都返回一个新版本时,这种方法才能真正起作用——但是新版本不需要是深拷贝,只需要替换已更改的部分“即写即拷”即可。)

我发现指挥模式在这里非常有用。我没有实现多个反向命令,而是在 API 的第二个实例上使用具有延迟执行的回滚。

如果您想要较低的实现工作量和易于维护性(并且能够为第二个实例提供额外的内存) ,那么这种方法似乎是合理的。

这里有一个例子: Https://github.com/thilo20/undo/