【问题标题】:Undo/Redo implementation [closed]撤消/重做实施[关闭]
【发布时间】:2011-04-02 06:24:21
【问题描述】:

给我一​​些想法如何实现撤消/重做功能 - 就像我们在文本编辑器中所做的那样。 我应该使用什么算法以及我可以阅读什么。谢谢。

【问题讨论】:

  • 也许可以添加一些关于您的软件正在工作的领域(文本处理?图形?数据库?)以及可能的平台/编程语言的更多细节。

标签: algorithm design-patterns


【解决方案1】:

我知道撤销类型的两个主要部分

  • 保存状态: 一类撤消是您实际保存历史状态的地方。在这种情况下,发生的情况是,您在每一点都继续将状态保存在内存的某个位置。当您想要撤消时,您只需换出当前状态并换入内存中已经存在的状态。例如,在 Adob​​e Photoshop 中使用“历史记录”或在 Google Chrome 中重新打开已关闭的选项卡时就是这样做的。

  • 生成状态: 另一类是您无需维护状态本身,只需记住操作是什么。当您需要撤消时,您需要对该特定操作进行逻辑反转。举个简单的例子,当您在某些支持撤消的文本编辑器中执行 Ctrl+B 时,它会被记住为 Bold 操作。现在每个动作都有其逻辑反转的映射。因此,当您执行 Ctrl+Z 时,它会从反向操作表中查找并发现撤消操作是 Ctrl+B 再次。执行此操作后,您将获得以前的状态。因此,这里您之前的状态并未存储在内存中,而是在您需要时生成。

对于文本编辑器,以这种方式生成状态的计算量不是很大,但对于像 Adob​​e Photoshop 这样的程序,它可能计算量太大或者根本不可能。例如 - 对于 Blur 动作,您将指定 de-Blur 动作,但这永远无法让您恢复到原始状态,因为数据已经丢失。因此,根据情况 - 逻辑逆操作的可能性及其可行性,您需要在这两个广泛的类别之间进行选择,然后以您想要的方式实施它们。当然,也可以有适合您的混合策略。

此外,有时,就像在 Gmail 中一样,有时间限制的撤消是可能的,因为操作(发送邮件)一开始就没有完成。所以,你不是在那里“撤消”,你只是在“不做”动作本身。

【讨论】:

  • 有时混合保存状态和“前进”操作可能会有所帮助。作为一种简单的方法,如果一个人每 5 个动作保持一次“主要保存状态”,并且在最后一次“主要保存状态”之后还保持保存状态,则可以通过恢复保存状态来执行前几个撤消操作,然后可以执行通过重复上一次主要保存中的 4 个操作来撤消下一个操作。一种更通用的方法是对不同级别的保存状态使用二次幂级数,因此需要使用 O(1) 前向迭代存储 lg(N) 保存状态以用于 N 级撤消。
  • 答案也应该添加这种混合方法。在通过生成先前状态来撤消过于复杂且正在编辑的数据太大的情况下,这是非常可行的。两者之间的平衡。可以采用多种策略,而不是固定的 4 长度或 2 的幂长度级数。每当生成先前的状态太复杂时,就像保存状态一样。
【解决方案2】:

我从零开始编写了两个文本编辑器,它们都采用了非常原始的撤消/重做功能形式。 “原始”是指该功能非常容易实现,但在非常大的文件(例如 >> 10 MB)中它是不经济的。但是,该系统非常灵活;例如,它支持无限级别的撤消。

基本上,我定义了一个类似的结构

type
  TUndoDataItem = record
    text: /array of/ string;
    selBegin: integer;
    selEnd: integer;
    scrollPos: TPoint;
  end;

然后定义一个数组

var
  UndoData: array of TUndoDataItem;

然后这个数组的每个成员指定文本的保存状态。现在,在每次编辑文本时(按下字符键、按下退格键、按下删除键、剪切/粘贴、鼠标移动的选择等),我(重新)启动一个(比如说)一秒的计时器。触发后,计时器会将当前状态保存为UndoData 数组的新成员。

在撤消 (Ctrl+Z) 时,我将编辑器恢复到状态 UndoData[UndoLevel - 1] 并将 UndoLevel 减一。默认情况下,UndoLevel 等于 UndoData 数组的最后一个成员的索引。在重做(Ctrl+Y 或 Shift+Ctrl+Z)时,我将编辑器恢复到状态 UndoData[UndoLevel + 1] 并将 UndoLevel 加一。当然,如果在UndoLevel不等于UndoData数组的长度(减一)时触发了编辑定时器,我会清除UndoLevel之后这个数组的所有项,这在微软Windows平台上很常见(但如果我没记错的话,Emacs 更好——Microsoft Windows 方法的缺点是,如果您撤消大量更改然后意外编辑缓冲区,则先前的内容(被撤消的)将永久丢失)。你可能想跳过这个数组的缩减。

在不同类型的程序中,例如图像编辑器,可以应用相同的技术,但当然,使用完全不同的UndoDataItem 结构。一种不需要太多内存的更高级方法是仅保存撤消级别之间的更改(即,而不是保存“alpha\nbeta\gamma”和“alpha\nbeta\ ngamma\ndelta”,如果你明白我的意思,你可以保存“alpha\nbeta\ngamma”和“ADD \ndelta”)。在非常大的文件中,每次更改与文件大小相比都很小,这将大大减少撤消数据的内存使用量,但实现起来比较棘手,并且可能更容易出错。

【讨论】:

    【解决方案3】:

    有几种方法可以做到这一点,但您可以开始查看Command pattern。使用命令列表在您的操作中后退(撤消)或前进(重做)。可以在here 找到 C# 中的示例。

    【讨论】:

      【解决方案4】:

      有点晚了,但这里是:您专门指的是文本编辑器,下面解释了一种算法,该算法可以适应您正在编辑的任何内容。所涉及的原则是保留一个可以自动重新创建您所做的每个更改的操作/指令列表。不要更改原始文件(如果不是空的),请保留它作为备份。

      保留您对原始文件所做更改的前后链接列表。此列表会间歇性地保存到临时文件中,直到用户真正保存更改:当这种情况发生时,您将更改应用到新文件,复制旧文件并同时应用更改;然后将原始文件重命名为备份,并将新文件的名称更改为正确的名称。 (您可以保留保存的更改列表,也可以将其删除并替换为后续的更改列表。)

      链表中的每个节点都包含以下信息:

      • 更改类型:插入数据或删除数据:要“更改”数据意味着delete 后跟insert
      • 文件中的位置:可以是偏移量或行/列对
      • 数据缓冲区:这是与动作有关的数据;如果insert 是插入的数据;如果是delete,则表示删除的数据。

      要实现Undo,您从链表的尾部向后工作,使用“当前节点”指针或索引:更改为insert,您执行删除但不更新链接-列表;并且它是delete,您从链接列表缓冲区中的数据插入数据。对用户的每个“撤消”命令执行此操作。 Redo 将“当前节点”指针向前移动并根据节点执行更改。如果用户在撤消后对代码进行更改,则删除'current-node'指示符到尾部的所有节点,并将尾部设置为'current-node'指示符。然后将用户的新更改插入到尾部之后。就是这样。

      【讨论】:

        【解决方案5】:

        我唯一的两分钱是您希望使用两个堆栈来跟踪操作。每次用户执行某些操作时,您的程序都应该将这些操作放在“已执行”堆栈中。当用户想要撤消这些操作时,只需将操作从“执行”堆栈弹出到“调用”堆栈。当用户想要重做这些操作时,从“recall”堆栈中弹出项目并将它们推回“performed”堆栈。

        希望对你有帮助。

        【讨论】:

          【解决方案6】:

          Memento pattern 就是为此而生的。

          在自己实现之前,请注意这很常见,并且代码已经存在 - 例如,如果您在 .Net 中编码,则可以使用 IEditableObject,而在 javascript 世界中,您可以使用不可变库喜欢immer.js

          【讨论】:

            【解决方案7】:

            实现基本撤消/重做功能的一种方法是同时使用备忘录和命令设计模式。

            Memento 旨在保持对象的状态以便稍后恢复。 出于优化目的,此纪念品应尽可能小。

            command 模式将一些指令封装在一个对象(命令)中,以便在需要时执行。

            基于这两个概念,您可以编写基本的撤消/重做历史记录,如下面的 TypeScript 编码(来自前端库 Interactoextracted and adapted)。

            这样的历史依赖于两个堆栈:

            • 可以撤消的对象的堆栈
            • 可重做对象的堆栈

            算法中提供了注释。请注意,在撤消操作时,必须清除重做堆栈! 原因是让应用程序处于稳定状态:如果您回到过去重做您所做的某些操作,您之前的操作当你改变未来时,将不再存在。

            export class UndoHistory {
                /** The undoable objects. */
                private readonly undos: Array<Undoable>;
            
                /** The redoable objects. */
                private readonly redos: Array<Undoable>;
            
                /** The maximal number of undo. */
                private sizeMax: number;
            
                public constructor() {
                    this.sizeMax = 0;
                    this.undos = [];
                    this.redos = [];
                    this.sizeMax = 30;
                }
            
                /** Adds an undoable object to the collector. */
                public add(undoable: Undoable): void {
                    if (this.sizeMax > 0) {
                        // Cleaning the oldest undoable object
                        if (this.undos.length === this.sizeMax) {
                            this.undos.shift();
                        }
            
                        this.undos.push(undoable);
                        // You must clear the redo stack!
                        this.clearRedo();
                    }
                }
            
                private clearRedo(): void {
                    if (this.redos.length > 0) {
                        this.redos.length = 0;
                    }
                }
            
                /** Undoes the last undoable object. */
                public undo(): void {
                    const undoable = this.undos.pop();
                    if (undoable !== undefined) {
                        undoable.undo();
                        this.redos.push(undoable);
                    }
                }
            
                /** Redoes the last undoable object. */
                public redo(): void {
                    const undoable = this.redos.pop();
                    if (undoable !== undefined) {
                        undoable.redo();
                        this.undos.push(undoable);
                    }
                }
            }
            

            Undoable 界面非常简单:

            export interface Undoable {
                /** Undoes the command */
                undo(): void;
                /** Redoes the undone command */
                redo(): void;
            }
            

            您现在可以编写对您的应用程序进行操作的可撤消命令。

            例如(仍然基于Interacto示例),您可以编写如下命令:

            export class ClearTextCmd implements Undoable {
               // The memento that saves the previous state of the text data
               private memento: string;
            
               public constructor(private text: TextData) {}
               
               // Executes the command
               public execute() void {
                 // Creating the memento
                 this.memento = this.text.text;
                 // Applying the changes (in many 
                 // cases do and redo are similar, but the memento creation)
                 redo();
               }
            
               public undo(): void {
                 this.text.text = this.memento;
               }
            
               public redo(): void {
                 this.text.text = '';
               }
            }
            

            您现在可以执行命令并将其添加到 UndoHistory 实例:

            const cmd = new ClearTextCmd(...);
            //...
            undoHistory.add(cmd);
            

            最后,您可以将撤消按钮(或快捷方式)绑定到此历史记录(重做也是如此)。

            此类示例详见Interacto documentation page

            编辑:

            请注意,存在多种撤消/重做算法。 我详细介绍了经典的线性。 对于代码编辑器,研究人员提出了“选择性撤消算法”(research article)。 协作编辑的撤消/重做算法也是特定的(research article)

            【讨论】:

            • 不会是“this.undos.shift();”吗? UndoHistory.add 上的“pop()”插入?
            • @Noman_1 我不这么认为。它是后进先出(堆栈)。由于我们压入数组,我们必须弹出最后一个元素以将数组用作堆栈(而shift 删除头部)。
            • 如果您添加一个新元素并且我们的堆栈太大,您想删除最旧的元素,即需要使用 51 次撤消才能使用的元素。那是列表中的第一个,也是最古老的。我在谈论这个案例:if (this.undos.length === this.sizeMax)
            • @Noman_1 确实!我没有看这个块。我确定了我的答案。谢谢
            【解决方案8】:

            如果动作是可逆的。例如加 1,让玩家移动等参见how to use the Command Pattern to implement undo/redo。按照链接,您将找到有关如何执行此操作的详细示例。

            如果没有,请使用 Saved State,如 @Lazer 所述。

            【讨论】:

              【解决方案9】:

              您可以研究现有撤消/重做框架的示例,Google 的第一个点击是codeplex (for .NET)。我不知道这是否比任何其他框架更好或更差,它们有很多。

              如果您的目标是在您的应用程序中提供撤消/重做功能,您不妨选择一个看起来适合您的应用程序类型的现有框架。
              如果您想了解如何构建自己的撤消/重做,您可以下载源代码并查看模式以及如何连接的详细信息。

              【讨论】:

                猜你喜欢
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                • 2016-02-18
                • 2011-03-10
                • 1970-01-01
                • 1970-01-01
                • 2011-11-14
                • 2021-06-04
                相关资源
                最近更新 更多