撤消引擎的设计模式

时间:2008-09-08 13:58:07

标签: design-patterns undo

我正在为民用机器应用程序编写结构建模工具。我有一个代表整个建筑的巨大模型类,其中包括节点,线元素,载荷等的集合,它们也是自定义类。

我已经编写了一个撤消引擎,它在每次修改模型后都会保存一份深层拷贝。现在我开始考虑是否可以进行不同的编码。我可以使用相应的反向修改器保存每个修改器动作的列表,而不是保存深层副本。这样我就可以将反向修改器应用于要撤消的当前模型,或者将修改器应用于重做。

我可以想象你将如何执行改变对象属性等的简单命令。但复杂命令如何?就像将新节点对象插入模型并添加一些线对象来保持对新节点的引用一样。

如何实现这一目标?

22 个答案:

答案 0 :(得分:86)

我见过的大多数示例都使用了Command-Pattern的变体。每个可撤消的用户操作都会获得自己的命令实例,其中包含执行操作并将其回滚的所有信息。然后,您可以维护已执行的所有命令的列表,并且可以逐个回滚。

答案 1 :(得分:31)

我认为当你处理OP所暗示的大小和范围的模型时,memento和命令都不实用。它们可以工作,但维护和扩展需要做很多工作。

对于此类问题,我认为您需要建立对数据模型的支持,以支持模型中涉及的每个对象的差异检查点。我曾经做过一次,它非常灵活。您要做的最重要的事情是避免在模型中直接使用指针或引用。

对另一个对象的每个引用都使用一些标识符(如整数)。只要需要该对象,就可以从表中查找对象的当前定义。该表包含每个包含所有先前版本的对象的链接列表,以及有关它们处于活动状态的检查点的信息。

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

代码中需要一些规则,但有许多优点:因为您正在对模型状态进行差异存储,所以不需要深层拷贝;您可以使用任意数量的内存或内存来确定要使用的内存量(非常对于CAD模型等重要事项)。对模型上运行的函数具有极高的可扩展性和低维护性,因为它们不需要执行任何操作来实现撤消/重做。

答案 2 :(得分:17)

如果您正在谈论GoF,Memento模式专门针对撤消。

答案 3 :(得分:15)

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

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

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

现在,诚然,很多人都在想自己“嗯,这不是命令模式的一部分吗?”是的,但是我看到太多的命令系统有两组命令,一组用于立即操作,另一组用于撤销/重做。我不是说不存在特定于立即操作和撤销/重做的命令,但减少重复将使代码更易于维护。

答案 4 :(得分:8)

你可能想要引用Paint.NET code来取消它们 - 它们有一个非常好的撤销系统。它可能比你需要的更简单,但它可能会给你一些想法和指导。

- 亚当

答案 5 :(得分:7)

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

答案 6 :(得分:6)

我已经成功地使用Memento模式实现了复杂的撤销系统 - 非常简单,并且还具有自然提供Redo框架的好处。更微妙的好处是聚合操作也可以包含在单个撤消中。

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

如何实现更改文档状态的方法完全取决于您的实现。如果您可以简单地进行API调用(例如ChangeColour(r,g,b)),则在其前面加上查询以获取并保存相应的状态。但该模式还将支持制作深拷贝,内存快照,临时文件创建等 - 这完全取决于您,因为它只是一个虚拟方法实现。

要进行聚合操作(例如,用户Shift-选择一组对象来执行操作,例如删除,重命名,更改属性),您的代码会创建一个新的Undo堆栈作为单个纪念品,并将其传递给实际操作添加单个操作。因此,您的操作方法不需要(a)要担心全局堆栈,并且(b)可以编码相同,无论它们是独立执行还是作为一个聚合操作的一部分执行。

许多撤消系统只在内存中,但如果您愿意,可以将撤消堆栈保留在外面。我想。

答案 7 :(得分:5)

刚刚阅读了我的敏捷开发书中的命令模式 - 也许这有可能吗?

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

更多信息here

答案 8 :(得分:4)

我与Mendelt Siebenga关于你应该使用命令模式的事实。您使用的模式是Memento模式,随着时间的推移,它会变得非常浪费。

由于您正在处理内存密集型应用程序,因此您应该能够指定允许撤消引擎占用的内存量,保存的撤消级别数或者将其保留的某些存储空间。如果你不这样做,你很快就会因机器内存不足而面临错误。

我建议你检查是否有一个框架已经在你选择的编程语言/框架中为undos创建了一个模型。发明新东西很不错,但最好是在真实场景中编写已经编写,调试和测试过的东西。如果您添加了您正在编写的内容,这将有所帮助,因此人们可以推荐他们知道的框架。

答案 9 :(得分:3)

Codeplex project

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

答案 10 :(得分:2)

我读过的大多数例子都是使用命令或纪念模式来做的。但是你可以通过一个简单的deque-structure来完成没有设计模式。

答案 11 :(得分:2)

作为参考,这里是C#中撤消/重做命令模式的简单实现:Simple undo/redo system for C#

答案 12 :(得分:2)

一种处理撤销的聪明方法,即使您的软件也适合多用户协作,正在实施operational transformation数据结构。

这个概念不是很受欢迎,但定义明确且有用。如果定义看起来过于抽象,this project就是如何在Javascript中定义和实现JSON对象的操作转换的成功示例

答案 13 :(得分:1)

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

由于指针(C ++)指向永远不会修复的对象,因为执行了一些奇怪的撤消重做序列(那些未更新为更安全的撤消感知“标识符”),因此存在许多错误。这个地区的虫子经常......嗯......有趣。

某些操作可能是速度/资源使用的特殊情况 - 比如调整大小,移动东西。

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

答案 14 :(得分:1)

在编写一个peg-jump益智游戏的解算器时,我必须这样做。我让每次移动都成为一个Command对象,它拥有足够的信息,可以完成或撤消。在我的情况下,这就像存储起始位置和每个移动的方向一样简单。然后我将所有这些对象存储在一个堆栈中,这样程序就可以在回溯时轻松撤消所需的移动。

答案 15 :(得分:1)

您可以在PostSharp中尝试现成的撤消/重做模式实现。 https://www.postsharp.net/model/undo-redo

它允许您向应用程序添加撤消/重做功能,而无需自己实现模式。它使用可记录模式来跟踪模型中的变化,并使用INotifyPropertyChanged模式,该模式也在PostSharp中实现。

您将获得UI控件,您可以决定每个操作的名称和粒度。

答案 16 :(得分:0)

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

答案 17 :(得分:0)

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

答案 18 :(得分:0)

你可以让你的初步想法表现出色。

使用persistent data structures,并坚持保留list of references to old state around。 (但是只有在操作状态类中的所有数据都是不可变的时才能真正起作用,并且它上面的所有操作都返回一个新版本---但是新版本不需要是深层复制,只需替换已更改的部分的副本-on-写”。)

答案 19 :(得分:0)

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

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

点击此处查看示例: https://github.com/thilo20/Undo/

答案 20 :(得分:-1)

我不知道这对你有什么用处,但是当我不得不在我的一个项目上做类似的事情时,我最终从http://www.undomadeeasy.com下载了UndoEngine - 一个很棒的引擎我真的不太关心发动机罩下的东西 - 它只是起作用。

答案 21 :(得分:-1)

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

命令级别:正如许多答案所指出的,使用Memento模式可以有效地实现这一点。如果该命令还支持记录操作,则可以轻松支持重做。

限制:一旦命令范围结束,撤销/重做就不可能,这会导致文档级别(全局)撤消/重做

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

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

    1. 对象级别撤销/重做:当调用撤消/重做的通知时,每个对象实现流操作,其中,流传输器从对象获得被编程的旧数据/新数据。不受干扰的数据不受干扰。每个对象都获得一个拖缆作为参数,在UNDo / Redo调用中,它会对对象的数据进行流/取流。
    2. 1和2都可以有类似的方法 1. BeforeUndo() 2. AfterUndo() 3. BeforeRedo() 4. AfterRedo()。这些方法必须在基本的Undo / redo命令(而不是上下文命令)中发布,以便所有对象也实现这些方法以获得特定的操作。

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