【问题标题】:Design Pattern for Undo Engine撤消引擎的设计模式
【发布时间】:2010-09-08 03:55:58
【问题描述】:

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

我已经编写了一个撤消引擎,它在每次修改模型后保存一个深拷贝。现在我开始思考我是否可以用不同的方式编码。除了保存深层副本,我也许可以保存每个修改器操作的列表以及相应的反向修改器。这样我就可以将反向修饰​​符应用于当前模型以撤消,或将修饰符应用于重做。

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

如何实施?

【问题讨论】:

  • 如果我添加评论“撤消算法”,那么我可以搜索“撤消算法”并找到它吗?这就是我搜索的内容,我发现了一些作为重复项关闭的内容。
  • hay,我也想在我们正在开发的应用程序中开发undo/redo。我们使用QT4框架,需要有很多复杂的undo/redo动作..我想知道,你使用Command成功了吗-模式?
  • @umanga:它奏效了,但并不容易。最困难的部分是跟踪参考资料。例如,当一个框架对象被删除时,它的子对象:节点、作用于它的负载以及许多其他用户分配需要保留,以便在撤消时重新插入。但是其中一些子对象与其他对象共享,撤消/重做逻辑变得相当复杂。如果模型不是那么大,我会保留纪念品的方法;它更容易实现。
  • 这是一个有趣的问题,想想源代码仓库是如何做到的,比如 svn(它们保持提交之间的差异)。

标签: design-patterns undo


【解决方案1】:

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

【讨论】:

  • 这基本上就是 Cocoa 中的 undo 引擎,NSUndoManager 的工作原理。
  • 当您有一些应该可以撤消的命令和其他不应该撤消的命令时,您会说什么是合适的?特别是当您有一个保留一堆命令的撤消/重做管理器时?也许不可撤销的命令有自己的类,或者他们的 send-to-undo-manager 方法什么都不做?
  • @EricAuld 我认为您如何实现这在很大程度上取决于您的应用程序实际在做什么。无论如何,子类化命令听起来是个好主意。不仅适用于可撤销和不可撤销的命令,还适用于不同类型的命令。但就像我说的,这在很大程度上取决于实施。
【解决方案2】:

我认为当您处理 OP 所暗示的大小和范围的模型时,纪念品和命令都不实用。它们可以工作,但维护和扩展工作量很大。

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

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

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

它在代码中需要一些纪律,但有很多优点:您不需要深拷贝,因为您正在对模型状态进行差异存储;您可以通过重做次数或使用的内存来确定要使用的内存量(非常对于 CAD 模型之类的东西很重要);对于在模型上运行的函数来说,非常可扩展且维护成本低,因为它们不需要执行任何操作来实现撤消/重做。

【讨论】:

  • 如果您使用数据库(例如 sqlite)作为文件格式,这几乎是自动的
  • 如果您通过跟踪模型更改引入的依赖关系来增加这一点,那么您可能会拥有一个撤消树系统(即,如果我更改了梁的宽度,那么就去做一些单独的工作组件,我可以回来并撤消大梁更改而不会丢失其他东西)。其 UI 可能有点笨拙,但它会比传统的线性撤消功能强大得多。
  • 你能解释一下这个 id 的 vs 指针的想法吗?指针/内存地址肯定和 id 一样有效吗?
  • @paulm:实际上,实际数据是由(id,version)索引的。指针指的是对象的特定版本,但您正在寻求引用对象的当前状态,无论它是什么,因此您希望通过 id 来处理它,而不是通过 (id, version)。您可以对其进行重构,以便存储指向 (version => data) 表的指针,并且每次只选择最新的,但是当您持久化数据时,这往往会损害局部性,混淆关注很少,并且使执行某些常见查询变得更加困难,因此这不是通常的方式。
【解决方案3】:

如果您说的是 GoF,Memento 模式专门针对撤消。

【讨论】:

  • 不是真的,这解决了他最初的方法。他要求另一种方法。初始存储每个步骤的完整状态,而后者仅存储“差异”。
【解决方案4】:

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

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

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

现在,诚然,许多人都在想:“嗯,这不就是命令模式的重点吗?”是的,但我见过太多的命令系统有两组命令,一组用于立即操作,另一组用于撤消/重做。我并不是说不会有特定于立即操作和撤消/重做的命令,但减少重复将使代码更易于维护。

【讨论】:

  • 我从没想过pastecut^-1。
【解决方案5】:

您可能想参考Paint.NET code 以了解他们的撤消 - 他们有一个非常好的撤消系统。它可能比您需要的要简单一些,但它可能会给您一些想法和指导。

-亚当

【讨论】:

【解决方案6】:

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

【讨论】:

    【解决方案7】:

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

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

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

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

    许多撤消系统仅在内存中,但我猜如果您愿意,您可以将撤消堆栈持久化。

    【讨论】:

      【解决方案8】:

      刚刚在我的敏捷开发书中阅读了有关命令模式的内容 - 也许它有潜力?

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

      更多信息here

      【讨论】:

        【解决方案9】:

        我支持Mendelt Siebenga,您应该使用命令模式。您使用的模式是 Memento 模式,随着时间的推移会变得非常浪费。

        由于您正在处理内存密集型应用程序,因此您应该能够指定允许撤消引擎占用多少内存、保存多少级别的撤消或将它们持久保存到的一些存储空间.如果您不这样做,您很快就会遇到由于机器内存不足而导致的错误。

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

        【讨论】:

          【解决方案10】:

          Codeplex project:

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

          【讨论】:

            【解决方案11】:

            我读过的大多数示例都是通过使用命令或备忘录模式来实现的。但是您也可以使用简单的deque-structure 来完成此操作而无需设计模式。

            【讨论】:

            • 你会在双端队列中放什么?
            • 在我的例子中,我放置了我想要撤消/重做功能的操作的当前状态。通过有两个双端队列(撤消/重做),我在撤消队列上撤消(弹出第一项)并将其插入重做队列。如果出队中的项目数超过首选大小,我会弹出一个尾部项目。
            • 您所描述的实际上一种设计模式:)。这种方法的问题是当你的状态占用大量内存时——保持几十个状态版本变得不切实际甚至不可能。
            • 或者您可以存储代表正常和撤消操作的闭包对。
            【解决方案12】:

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

            【讨论】:

              【解决方案13】:

              处理撤消的一种巧妙方法是实现数据结构的operational transformation,这将使您的软件也适合多用户协作。

              这个概念不是很流行,但定义明确且有用。如果定义对您来说过于抽象,this project 是一个成功的例子,说明如何在 Javascript 中定义和实现 JSON 对象的操作转换

              【讨论】:

                【解决方案14】:

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

                【讨论】:

                  【解决方案15】:

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

                  由于指向对象的指针 (C++) 在您执行一些奇怪的撤消重做序列(那些未更新为更安全的撤消感知“标识符”的位置)时从未修复过,因此存在许多错误。这方面的错误经常......嗯......很有趣。

                  某些操作可能是速度/资源使用的特殊情况 - 例如调整大小、移动物体。

                  多重选择也提供了一些有趣的复杂性。幸运的是,我们在代码中已经有了分组概念。 Kristopher Johnson 对子项目的评论与我们所做的非常接近。

                  【讨论】:

                  • 随着模型大小的增长,这听起来越来越不可行。
                  • 以什么方式?随着新的“事物”被添加到每个对象中,这种方法在没有变化的情况下继续工作。随着对象的序列化形式的大小增加,性能可能是一个问题——但这并不是一个主要问题。该系统已经持续开发了 20 多年,已被 1000 多个用户使用。
                  【解决方案16】:

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

                  它允许您在应用程序中添加撤消/重做功能,而无需自己实现该模式。它使用 Recordable 模式来跟踪模型中的更改,并与 INotifyPropertyChanged 模式一起使用,该模式也在 PostSharp 中实现。

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

                  【讨论】:

                    【解决方案17】:

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

                    【讨论】:

                      【解决方案18】:

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

                      【讨论】:

                        【解决方案19】:

                        你可以让你最初的想法变得高效。

                        使用persistent data structures,并坚持使用list of references to old state around。 (但这只有在你的状态类中的所有数据都是不可变的操作时才真正有效,并且对它的所有操作都返回一个新版本——但新版本不需要是深拷贝,只需替换更改的部分 'copy -写时'。)

                        【讨论】:

                          【解决方案20】:

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

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

                          请参见此处的示例: https://github.com/thilo20/Undo/

                          【讨论】:

                            【解决方案21】:

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

                            【讨论】:

                            • 请仅在您有信心提供解决方案的情况下发布您的 cmets 作为答案!否则,更愿意将其发布为问题下的评论! (如果现在不允许这样做!请等你获得好名声)
                            【解决方案22】:

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

                            命令级别:正如许多答案所指出的,这可以使用 Memento 模式有效地实现。如果该命令还支持对操作进行日志记录,则很容易支持重做。

                            限制:一旦超出命令范围,就无法进行undo/redo,导致document level(global) undo/redo

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

                            1. 所有内存撤消/重做
                            2. 对象级撤消重做

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

                            1. 对象级撤消/重做:当撤消/重做通知被调用时,每个对象都执行一个流式操作,流式传输器从对象中获取已编程的旧数据/新数据。未受干扰的数据保持不受干扰。每个对象都有一个流式传输器作为参数,在 UNDo/Redo 调用中,它流式传输/取消流式传输对象的数据。

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

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

                            【讨论】:

                              猜你喜欢
                              • 1970-01-01
                              • 1970-01-01
                              • 2011-03-27
                              • 1970-01-01
                              • 2015-06-05
                              • 2016-12-29
                              • 2012-05-22
                              相关资源
                              最近更新 更多