在C#中实现撤销重做的最佳实践

29

我需要为我的窗口应用程序(类似于PowerPoint的编辑器)实现撤销/重做框架,应该遵循哪些最佳实践?如何处理对象的所有属性更改以及它们在UI上的反映。


可能会有帮助的是:C#中的多级撤销和重做实现[1](https://www.codeproject.com/Articles/33371/Multilevel-Undo-and-Redo-Implementation-in-C-Part)和[2](https://www.codeproject.com/Articles/33384/Multilevel-Undo-and-Redo-Implementation-in-Cshar-2)。 - Avatar
5个回答

41
有两种经典的模式可供使用。第一种是备忘录模式,用于存储对象状态的快照。这可能比命令模式更加系统密集,但它可以轻松地回滚到旧的快照。您可以像PaintShop/PhotoShop那样将快照存储在磁盘上,也可以将它们保留在内存中以供不需要持久性的较小对象使用。您正在做的正是此模式设计的目的,因此它应该比其他人建议的命令模式更适合您的需求。
另外,还要注意的是,由于它不需要您具有撤消先前完成的操作的互惠命令,这意味着任何潜在的单向函数[例如哈希或加密],不能使用互惠命令轻松地撤消,仍然可以通过简单地回滚到旧的快照来轻松地撤消。
同时,如指出的那样,命令模式可能更少占用资源,因此我将承认,在以下特定情况下:
  • 需要持久化大型对象状态和/或
  • 没有破坏性方法且
  • 可以非常轻松地使用互惠命令来撤销任何操作的情况下
命令模式可能更适合[但不一定,这将在很大程度上取决于情况]。在其他情况下,我会使用备忘录模式。

我可能会避免使用这两种模式的混合,因为我关心接替我的开发人员能够维护我的代码,并且这也是我对雇主的道德责任,使这个过程尽可能简单和廉价。我认为这两种模式的混合很容易变成一个难以维护的不适之处,需要花费大量成本来维护。


1
如果你的对象状态不是很大,那么这种实现方式比撤销/重做命令模式要简单得多,后者很容易变得复杂。而且通常你会有一些破坏性的命令,无法在没有完整状态副本的情况下撤消。但对于非常大的状态,这种方法可能不切实际。 - Josh
看看纯函数数据结构;它们可以在当前实时数据和备忘录之间提供相当大的共享。 - Tommy McGuire
看一下我回答中描述的状态差异,根据您的用例可能是更好的选择。 - vincent
1
时光模式的链接已失效。 - Moritz Schmidt
您也可以采用混合方法,其中命令仍然可能具有破坏性,状态很大,但您会不时地保留状态快照。这意味着,要撤消一步操作,您需要恢复到上一个快照,然后重新播放命令,直到当前步骤的前一步。然后,您可以通过选择存储完整快照的频率来控制内存消耗和单个撤消操作的性能之间的权衡。 - Nitsan Avni

33

这里有三种可行的方法:备忘录模式(快照)、命令模式和状态差分。它们都有利有弊,实际上取决于你的用例、你使用的数据以及你愿意实现的内容。

如果可以适用状态差分,则建议采用它,因为它将内存减少与易于实现和维护相结合。

我将引用一篇介绍这三种方法的文章(参见下文引用)。

注意,在文章中提到的VoxelShop是开源的。因此,您可以查看此处命令模式的复杂性: https://github.com/simlu/voxelshop/tree/develop/src/main/java/com/vitco/app/core/data/history

以下是文章的摘录。但我建议您全文阅读。


备忘录模式

enter image description here

每个历史状态都存储完整的副本。动作创建一个新状态,并使用指针在状态之间移动,允许撤消和重做。

优点

  • 实现独立于应用的动作。一旦实现,我们可以添加动作而不必担心破坏历史记录。
  • 快速前进到预定义的历史位置。当当前位置和所需历史位置之间应用的操作具有计算成本时,这是很有趣的。

缺点

  • 与其他方法相比,内存要求可能显着更高。
  • 如果快照很大,则加载时间可能会很慢。

命令模式

在此输入图片描述

类似于备忘录模式,但不是存储完整状态,而是仅存储状态之间的差异。差异以可应用和可取消的操作形式存储。引入新操作时,需要实现应用和取消应用。

优点

  • 内存占用小。我们只需要存储模型的更改,如果这些更改很小,则历史堆栈也很小。

缺点

  • 我们无法直接跳转到任意位置,而是需要取消应用历史堆栈,直到到达目标位置。这可能耗费时间。
  • 每个操作及其反向操作都需要封装在对象中。如果您的操作不是简单的,那么这可能很困难。在(反向)操作中出现错误真的很难调试,并且很容易导致致命崩溃。即使看起来很简单的操作通常也涉及相当复杂的内容。例如,在3D编辑器的情况下,添加到模型的对象需要存储添加了什么,当前选择的颜色是什么,被覆盖了什么,是否激活了镜像模式等。
  • 当操作没有简单的反向操作时(例如模糊图像),实现可能具有挑战性并且占用内存。

状态差异

在此输入图片描述

类似于命令模式,但是差异是独立于操作而存储的,只需通过异或状态即可。引入新操作不需要任何特殊考虑。

优点

  • 实现与应用的操作无关。一旦添加了历史功能,我们可以添加操作而不必担心破坏历史记录。
  • 内存需求通常比快照方法低得多,在许多情况下与命令模式方法相当。然而,这高度取决于所应用的操作类型。例如,使用命令模式反转图像的颜色应该非常便宜,而状态差分会保存整个图像。相反,对于绘制长自由形线条,如果为每个像素链接历史记录条目,命令模式方法可能会使用更多内存。
  • 缺点/限制

    • 我们不能直接进入任意位置,而是需要取消应用历史堆栈,直到达到那里。
    • 我们需要计算状态之间的差异。这可能很昂贵。
    • 实现模型状态之间的异或差分可能很难实现,这取决于您的数据模型。

    参考:

    https://www.linkedin.com/pulse/solving-history-hard-problem-lukas-siemon


    这些没有解释的踩是真的让人感到沮丧 :/ - vincent
    我觉得这是因为你没有确切地解释任何东西。楼主明确询问最佳实践,而你只是提供了利弊,甚至没有概括这些方法论。 - arkon
    2
    @b1nary.atr0phy终于有一些有建设性的反馈了!我已经调整了答案,并希望您能取消您的负评! - vincent
    1
    就上下文而言,这要好得多。愿望实现 :) - arkon
    1
    我不明白为什么这个答案没有被标记为正确答案。它提供了所有我所知道的合法方法的公正利弊分析。BenAlabaster的回答省略了状态差异方法,并且在结尾处往往显得非常自以为是。 - 16807

    6

    经典的做法是遵循命令模式

    您可以使用命令封装执行操作的任何对象,并使用Undo()方法执行相反的操作。您可以将所有操作存储在堆栈中,以便轻松地通过它们进行倒带。


    2
    这种模式不允许使用单向函数,比如哈希或数学函数,因为这些函数无法使用逆方法“撤销”。 因此,对于此方法应谨慎使用。 - BenAlabaster
    1
    这是非常真实的。 对于一般的UI东西来说,它能够帮助很大程度上。 - womp
    从一个从未实现过这些模式的人那里提出了一个随机问题:命令模式不能很容易地存储先前更改的快照以便于撤消吗?有点像命令和备忘录之间的不可告人的联盟?特别是如果许多命令可以轻松撤消,为每个操作存储快照可能会有点昂贵。 - Joey
    @Johannes - 我猜你可以使用混搭方式,对于一些简单的功能使用命令模式,对于一些不那么简单的功能使用备忘录模式。这种方式应该性能良好,但可能会增加后期维护的难度。考虑到存储空间的成本微不足道,我建议你选择其中一种方式。如果你没有一些函数是单向的,那就使用命令模式,如果有,那就用备忘录模式。 - BenAlabaster
    2
    @Ben,这通常是命令方法的情况。对于每个命令,您需要存储一些状态以撤消该命令。例如,DeleteSelectedTextCommand的撤消命令需要删除的文本。在某些情况下,撤消状态只需要包括整个状态即可。但对于大多数情况,我认为您始终使用完整状态副本的建议是最实际的解决方案。 - Josh

    2

    看一下命令模式。 你需要将对模型的每个更改封装到单独的命令对象中。


    请查看我在Womp的答案中关于使用命令模式实现“撤销”机制的评论。 - BenAlabaster

    0

    我编写了一个非常灵活的系统来跟踪更改。我有一个绘图程序,它实现了两种类型的更改:

    • 添加/删除形状
    • 形状属性更改

    基类:

    public abstract class Actie
    {
        public Actie(Vorm[] Vormen)
        {
            vormen = Vormen;
        }
    
        private Vorm[] vormen = new Vorm[] { };
        public Vorm[] Vormen
        {
            get { return vormen; }
        }
    
        public abstract void Undo();
        public abstract void Redo();
    }
    

    用于添加形状的派生类:

    public class VormenToegevoegdActie : Actie
    {
        public VormenToegevoegdActie(Vorm[] Vormen, Tekening tek)
            : base(Vormen)
        {
            this.tek = tek;
        }
    
        private Tekening tek;
        public override void Redo()
        {
            tek.Vormen.CanRaiseEvents = false;
            tek.Vormen.AddRange(Vormen);
            tek.Vormen.CanRaiseEvents = true;
        }
    
        public override void Undo()
        {
            tek.Vormen.CanRaiseEvents = false;
            foreach(Vorm v in Vormen)
                tek.Vormen.Remove(v);
            tek.Vormen.CanRaiseEvents = true;
        }
    }
    

    用于删除形状的派生类:

    public class VormenVerwijderdActie : Actie
    {
        public VormenVerwijderdActie(Vorm[] Vormen, Tekening tek)
            : base(Vormen)
        {
            this.tek = tek;
        }
    
        private Tekening tek;
        public override void Redo()
        {
            tek.Vormen.CanRaiseEvents = false;
            foreach(Vorm v in Vormen)
                tek.Vormen.Remove(v);
            tek.Vormen.CanRaiseEvents = true;
        }
    
        public override void Undo()
        {
            tek.Vormen.CanRaiseEvents = false;
            foreach(Vorm v in Vormen)
                tek.Vormen.Add(v);
            tek.Vormen.CanRaiseEvents = true;
        }
    }
    

    属性更改的派生类:

    public class PropertyChangedActie : Actie
    {
        public PropertyChangedActie(Vorm[] Vormen, string PropertyName, object OldValue, object NewValue)
            : base(Vormen)
        {
            propertyName = PropertyName;
            oldValue = OldValue;
            newValue = NewValue;
        }
    
        private object oldValue;
        public object OldValue
        {
            get { return oldValue; }
        }
    
        private object newValue;
        public object NewValue
        {
            get { return newValue; }
        }
    
        private string propertyName;
        public string PropertyName
        {
            get { return propertyName; }
        }
    
        public override void Undo()
        {
            //Type t = base.Vorm.GetType();
            PropertyInfo info = Vormen.First().GetType().GetProperty(propertyName);
            foreach(Vorm v in Vormen)
            {
                v.CanRaiseVeranderdEvent = false;
                info.SetValue(v, oldValue, null);
                v.CanRaiseVeranderdEvent = true;
            }
        }
        public override void Redo()
        {
            //Type t = base.Vorm.GetType();
            PropertyInfo info = Vormen.First().GetType().GetProperty(propertyName);
            foreach(Vorm v in Vormen)
            {
                v.CanRaiseVeranderdEvent = false;
                info.SetValue(v, newValue, null);
                v.CanRaiseVeranderdEvent = true;
            }
        }
    }
    

    每次 Vormen = 提交更改的项目数组。 应该像这样使用:

    声明堆栈:

    Stack<Actie> UndoStack = new Stack<Actie>();
    Stack<Actie> RedoStack = new Stack<Actie>();
    

    添加新形状(例如,点)

    VormenToegevoegdActie vta = new VormenToegevoegdActie(new Vorm[] { NieuweVorm }, this);
    UndoStack.Push(vta);
    RedoStack.Clear();
    

    删除所选形状

    VormenVerwijderdActie vva = new VormenVerwijderdActie(to_remove, this);
    UndoStack.Push(vva);
    RedoStack.Clear();
    

    注册属性更改

    PropertyChangedActie ppa = new PropertyChangedActie(new Vorm[] { (Vorm)e.Object }, e.PropName, e.OldValue, e.NewValue);
    UndoStack.Push(ppa);
    RedoStack.Clear();
    

    最后是撤销/重做操作

    public void Undo()
    {
        Actie a = UndoStack.Pop();
        RedoStack.Push(a);
        a.Undo();
    }
    
    public void Redo()
    {
        Actie a = RedoStack.Pop();
        UndoStack.Push(a);
        a.Redo();
    }
    

    我认为这是实现撤销重做算法最有效的方法。 例如,可以查看我网站上的此页面:DrawIt

    我在文件Tekening.cs的第479行左右实现了撤销重做功能。您可以下载源代码。它可以被任何类型的应用程序实现。


    2
    请注意,如果您想推广自己的产品/博客,必须在答案中披露您的关联方身份,否则,您的答案可能会被标记为垃圾邮件。如果您与该网站没有关联,建议您明确说明以防止此类情况发生。请阅读如何避免成为垃圾邮件发送者 - Mithical

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接