123

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

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

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

一个人将如何实施呢?

4

22 回答 22

94

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

于 2008-09-08T14:00:58.273 回答
36

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

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

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

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

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

于 2009-02-17T21:09:48.373 回答
17

如果您在谈论 GoF,Memento模式专门解决了撤消问题。

于 2008-09-08T15:00:38.817 回答
16

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

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

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

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

于 2008-09-08T17:12:38.420 回答
8

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

-亚当

于 2008-09-08T14:05:50.883 回答
6

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

于 2008-09-08T14:10:15.160 回答
6

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

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

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

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

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

于 2008-09-08T17:03:29.973 回答
5

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

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

更多信息在这里

于 2008-09-08T14:02:08.550 回答
4

同意 Mendelt Siebenga的观点,即您应该使用命令模式。您使用的模式是 Memento 模式,随着时间的推移,它会变得非常浪费。

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

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

于 2008-09-08T15:51:54.667 回答
3

Codeplex 项目

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

于 2009-06-30T06:04:43.677 回答
2

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

于 2008-09-08T14:13:27.073 回答
2

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

于 2008-09-08T16:08:18.357 回答
2

作为参考,这里是 C# 中撤消/重做命令模式的简单实现:C# 的简单撤消/重做系统

于 2009-03-19T18:49:24.390 回答
2

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

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

于 2014-09-05T12:09:40.543 回答
1

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

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

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

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

于 2008-09-08T16:43:12.860 回答
1

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

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

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

于 2016-05-04T09:44:32.243 回答
0

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

于 2008-09-08T15:17:19.133 回答
0

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

于 2009-02-17T20:46:05.763 回答
0

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

使用持久性数据结构,并坚持保留对旧状态的引用列表。(但这只有在你的状态类中的所有数据都是不可变的操作时才真正有效,并且对它的所有操作都返回一个新版本——但新版本不需要是深拷贝,只需替换更改的部分 'copy -写时'。)

于 2016-07-26T06:36:06.280 回答
0

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

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

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

于 2018-03-12T23:24:06.287 回答
-1

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

于 2010-09-28T15:56:31.177 回答
-1

在我看来,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. AfterRedo()。这些方法必须在基本的撤消/重做命令(而不是上下文命令)中发布,以便所有对象也实现这些方法以获得特定的操作。

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

于 2016-05-06T13:29:54.727 回答