使您的应用程序可逆以支持撤销和重做






4.84/5 (42投票s)
如何让您的现有和新应用程序支持内存事务、多级和多文档撤销/重做,使用泛型和 C# 3.0 扩展方法和 lambda 表达式。
引言
您的应用程序支持撤销/重做吗?它们通常应该支持,因为撤销/重做通过支持用户探索来提高可用性。如今,它也可以成为一项重要的竞争优势。试想一下没有它的文字处理器。但是,您如何在 .NET 应用程序中实现这样的功能?本文介绍了一种新方法,使用泛型、扩展方法和 lambda 表达式,使新旧类都支持多级撤销和重做。
即使您的应用程序不需要支持用户撤销和重做,您仍然可能对使用这里提供的可逆事务支持来构建更具容错性的应用程序感兴趣。如果您曾使用过数据库,您应该熟悉 SqlTransaction
如何将一系列更改分组为原子操作,该操作要么完全成功(提交),要么完全回滚。使用这里提供的框架,您可以对内存对象执行相同的操作。
CodeProject 文章 使用 .NET 泛型自动撤销/重做 启发了我开始思考如何在应用程序中支持撤销和重做。如其中所示,通过将每个属性字段包装在一个泛型类中,可以轻松地使属性更改可撤销。我也广泛使用了泛型,但我的解决方案不需要更改字段定义。实际上,通过使用 C# 3.0 扩展方法,我的解决方案甚至可以相当轻松地应用于现有的不可逆类(和接口),正如您将很快看到的。
使用代码
可逆事务
我的可逆框架的一个基本原则是,所有将要可逆(可撤销)的内容都必须在事务中进行。一个事务可以跨越任意数量的方法调用和任意数量的可逆操作。此框架中事务的一个强大功能是它们可以嵌套。这意味着当在一个新事务创建时另一个事务仍然活动,一部分工作可以(在发生错误时)回滚。
如果一个事务跨越单个方法(该方法可能调用任意数量的其他方法),最好将其范围限定在 using
子句中,如下例所示:
using (Transaction txn = Transaction.Begin())
{
// Do reversible job here
txn.Commit();
}
这将启动一个新事务(如果在之前有任何正在进行的事务,则为嵌套事务),并且在发生任何异常时,所有可逆操作都将被自动回滚,因为在 Transaction
的 using
子句的末尾隐式调用的 IDisposable.Dispose()
实现中会调用 Rollback
。因此,这里一个非常重要的行是最后对 Commit
的调用。此调用将结束事务,防止其在 dispose 时被回滚。附加的项目包含一个代码片段,用于添加上面的代码(或将任何现有代码包装在此类范围内)。
支持撤销和重做
要使用此框架在应用程序中支持撤销和重做,您需要创建一个新的 UndoRedoSession
实例,它实际上代表一个长生命周期的事务。UndoRedoSession
派生自 Transaction
,使其能够将事务嵌套到任何级别。要向 UndoRedoSession
添加新的可撤销操作,您可以使用 UndoRedoSession.Begin()
在 using
语句中创建一个新事务,如下所示。当此事务被提交时,它将被添加为 UndoRedoSession
的一个新操作。
UndoRedoSession urSession = new UndoRedoSession();
// ...
using (Transaction txn = urSession.Begin("Name of operation"))
{
// Do reversible job here
txn.Commit();
}
请注意,您可以将操作名称作为参数传递给 Transaction.Begin()
。
在一个应用程序中,您可能有多个撤销会话,但任何时候只有一个是活动的。附加的演示是一个 MDI 应用程序,每个文档有一个会话。在使用多个撤销会话时,必须小心不要从多个撤销会话访问相同的可逆资源。否则,在一个会话中撤销操作很容易干扰另一个会话中的撤销操作。
正如您所料,UndoRedoSession
具有执行撤销和重做的方法。调用 Undo()
将撤销最后一个操作,调用 Redo()
将重做最后一个被撤销的操作。
if( urSession.CanUndo() )
{
urSession.Undo()
}
要撤销或重做多个操作,可以将操作计数作为参数传递给 Undo 和 Redo。UndoRedoSession
还有一个 HistoryChanged
事件,每当撤销和重做操作列表发生更改时都会引发该事件。这通常用于保持用户界面更新,如附加演示中所述。要检索可撤销的最后一个操作的名称,会调用 GetUndoText()
,要获取所有可撤销操作的名称,则使用 GetUndoTextList()
。存在相应的 Redo 方法。
通常,在撤销或重做操作后必须更新用户界面以反映更改。这可以通过监听 Reversed
事件来实现,该事件在任何操作被反转时都会触发。演示展示了如何在 Windows Forms 应用程序中做到这一点。它还演示了一种在事务中持久化窗体位置和状态的方法,以便焦点也得到反转。
使现有属性可逆
除了启动 Transaction
之外,您还必须确保每当您进行更改时,原始状态都会存储在该 Transaction
中。本文的其余部分将重点介绍属性和集合的更改,因为这应该代表大多数应用程序中最常见的内存更改。然而,Transaction
类还可以容纳其他类型的更改(例如删除文件或启动电机!)。
此框架支持三种使属性更改可逆的方法。第一种方法使用 C# 3.0 扩展方法,几乎无缝地为几乎任何现有类(包括 .NET 框架中的类)添加可逆支持。作为演示,让我们从一个简单的不可逆 Person
类(在附加演示中使用)开始。
public class Person
{
public Person()
{
Children = new List<person>();
}
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }
public IList<person> Children { get; private set; }
}
可逆属性扩展方法
为了在不修改声明的情况下对属性进行可逆更改,我们创建一个扩展类,其中为类的每个可写属性提供一个方法。对于 Person
类,最终声明如下:
public static class PersonReversibleExtension
{
public static void Name_SetReversible(this Person instance, string value)
{
Transaction.AddPropertyChange(Name_SetReversible, instance, instance.Name, value);
instance.Name = value;
}
public static void DateOfBirth_SetReversible(this Person instance, DateTime value)
{
Transaction.AddPropertyChange(DateOfBirth_SetReversible,
instance, instance.DateOfBirth, value);
instance.DateOfBirth = value;
}
public static IList<person> Children_Reversible(this Person instance)
{
return instance.Children.AsReversible();
}
}
所有扩展方法都必须声明在静态类中,并且每个可设置属性应该有一个方法。第一个参数前面的 this
关键字使其成为扩展方法。每个属性设置器方法都调用 AddPropertyChange
来将属性更改注册到当前 Transaction
。第一个参数应该是对方法本身的引用,在回滚、撤销或重做时将调用该方法。
这看起来可能需要编写太多代码,但对于每个属性来说,这都是大量的重复,并且通过使用演示项目包含的代码片段,在 Visual Studio 中编写这些扩展方法非常简单。而且,回报是您可以用一种非常直接的方式使任何类的属性可逆,并获得 Visual Studio 2008 中完整的 Intellisense 支持,如图 1 所示。
图 1:Visual C# Express 2008 中可逆扩展方法的 Intellisense 支持
要设置 Person
的姓名,您需要在事务范围内按以下方式编写:
currentPerson.Name_SetReversible(txtName.Text);
// currentPerson.Name = txtName.Text (non-reversible version)
不幸的是,C# 3.0 中没有对“扩展属性”的支持。这将使设置可逆属性的语法更类似于正常的不可逆语法。然而,通过使用以属性名开头的扩展方法名,这两种版本将在 Intellisense 弹出菜单中始终显示在彼此下方。这样,您就会被提醒它的存在以及如何使用它。
可逆集合扩展方法
那么集合呢?在 Person
示例中,我们有一个 Children
集合属性,我们还必须能够可逆地添加和删除项。由于扩展方法可以应用于泛型接口,我能够创建一些非常可重用的扩展方法,使任何实现泛型 ICollection<T>
或 IList<T>
的集合变得可逆。多亏了它们,我们只需编写
person.Children.Add_Reversible(newChild);
person.Children.Remove_Reversible(childToRemove);
即可从 Children
集合中可逆地添加和删除项。而且,得益于 VS2008 对扩展方法的出色 Intellisense 支持,集合方法的可逆版本将在您编写代码时显示(前提是您已在代码文件中导入了 Reversible.Extensions
命名空间)。
使用 AsReversible() 使集合可逆
使集合可逆的另一种便捷方法是调用当前可用于任何泛型 List<T>
、IList<T>
和 ICollection<T>
的 AsReversible()
扩展方法。这将把集合包装在一个可逆容器中,从而能够像往常一样进行可逆调用,例如:
IList<person> children = person.Children.AsReversible();
children.Add(newChild);
children.Remove(childToRemove);
感谢 Mars Marshall Rosenstein 提供使用扩展方法包装集合的这个想法。
内置可逆性
到目前为止使用扩展方法介绍的方法非常强大,因为它可以应用于许多现有类,而无需访问或更改原始代码。这样,您可以像往常一样编写一个不可逆类,然后选择性地选择在哪里对其进行可逆调用。在某些情况下,您不需要或不想要可逆性,例如,在初始加载大量文档数据或创建具有某些上下文默认值的实例时。然后,您可以直接调用原始版本。
扩展方法技术的一个缺点是您必须确保在每次需要时都调用扩展方法,以使操作可逆。在某些情况下,当您完全控制实现时,最好使类的方法固有地可逆。这样,调用者就不必费心了。但请记住,然后您将不可逆地为类添加一些开销,当它在不可逆的上下文中被使用时,这将对性能产生负面影响。
我探索了两种使属性固有可逆的方法。第一种方法(在大多数情况下推荐)是在属性设置器中添加一行,将属性更改注册到当前事务:
private string familyName;
public string FamilyName
{
get { return familyName; }
set
{
Transaction.AddPropertyChange(v => FamilyName = v, familyName, value);
familyName = value;
}
}
这里使用的 AddPropertyChange
版本将一个实例委托作为第一个参数,该委托只是调用属性设置器。在回滚或撤销时将调用此委托。在这里,我们使用新的 C# 3.0 lambda 表达式来大大减少需要编写的代码量。在附加的项目中,还有代码片段(revprop
和 revpropchg
),它们甚至进一步减少了编写量。第二个参数必须是属性的当前(旧)值,第三个参数必须是新值。前者在回滚和撤销时传递给委托,后者在重做时传递给委托。
在上面的示例中,属性只是存储在一个私有字段中,但是您可以添加逻辑来执行有效性检查和更改通知(通过引发事件)。当事务回滚或撤销时,框架将以与最初调用完全相同的方式再次调用属性设置器,但使用旧值。因此,任何事件侦听器(如 GUI)在反转时也会收到通知。
要使集合固有可逆,您可以使用上面描述的 AsReversible()
扩展方法将集合包装到可逆集合中。另一种选择是使用我实现的泛型 ReversibleList<T>
类,它是泛型 List<T>
类的可逆版本。
我探索的使简单属性可逆的第二种方法在用法上与 Sergio Arhipenko 展示的技术非常相似。您只需将属性字段包装在一个泛型 Reversible<T>
类型中,并使用 Value
属性来检索和设置当前值。Person
的 Name
属性的实现如下所示:
Reversible<string> name;
public string Name
{
get { return name.Value; }
set { name.Value = value; }
}
通过这种方式,所有事务支持都隐藏在 Reversible<T>
类型中,并且需要编写的代码量最少。缺点是它与属性设置器中执行的更改通知的兼容性较差。每个字段还将比未包装的版本消耗更多的内存。与 Sergio 提供的 UndoRedo<T>
类相比,Reversible<T>
类型被实现为结构,这减少了堆分配对象的总数(仅在事务范围内更改属性时创建它们),从而最小化了总内存消耗(只要大多数属性未被更改)。但是,每个 Reversible<T>
字段仍将消耗至少四个额外的字节来存储指向“存储对象”的引用。
实现细节
此框架大量使用泛型类、泛型方法和泛型委托来提高类型安全性和性能(避免装箱原始类型)。如前所述,扩展方法和 lambda 表达式在减少需要编写的代码量方面也起着核心作用。
为了启用回滚、撤销和重做,每个更改都必须存储在列表中。每个更改都由抽象基类 Edit
的一个子类表示。其单个抽象方法 Reverse()
规定了在反转时应该发生什么。您可能认出这是典型的 GoF 命令模式实现。对于每个属性更改,都会创建一个新的属性编辑实例并将其存储在当前事务的列表中。如果没有活动的事务,则不会创建或存储 Edit
实例。在 AddPropertyChange
中创建的泛型“属性编辑”存储了传递给 AddPropertyChange
的属性设置器委托的引用,以及旧值和新值。当调用 Reverse
时,它只是用新值调用委托。然后交换旧值和新值以启用重做。
可逆集合扩展方法和类实现并使用 Edit
的其他泛型子类来使操作可逆。
为了支持嵌套事务,我简单地让 Transaction
继承自 Edit
,并在其 Reverse
实现中,它会调用每个子编辑的 Reverse
。
图 2. 在 Visual Studio 2008 中创建的类图
任何人都可以扩展该框架,添加新的 Edit
子类类型,以支持其他 .NET 框架类和/或自己的类的可逆性。为了更多地了解扩展方法和泛型(尤其是此框架),一个很好的练习是为泛型 IDictionary
接口实现可逆支持。
结论
在本文中,我演示了一种简单而强大的方法,可以使用泛型和新的 C# 3.0 功能(扩展方法和 lambda 表达式)使 .NET 应用程序支持原子内存事务和撤销/重做。我希望这会让您考虑在未来的应用程序中实现此类支持,从而提高软件的可用性和/或健壮性。但是,我想强调的是,实现(甚至更不用说测试)一个可撤销的应用程序仍然可能是一个挑战。
最后,我想强调的是,附加的代码既不完整,也未经彻底测试。它在此发布是为了演示新方法并启发您思考新 .NET 语言功能。我也期待任何评论和改进建议。
兼容性
这是在 Visual Studio 2008 Beta 2 上开发和测试的,目标是 .NET 框架的 3.5 版本。对扩展方法的 Intellisense 支持非常好,但在 Beta 2 版本中,存在一个 bug,导致整个应用程序在编辑使用 ReversibleList
类的代码文件时崩溃。在后续版本中,这应该不是问题,因为该 bug 已修复。我已经确认,在 Visual C# Express 2008 中,不存在此问题。
历史
- 2007 年 12 月 28 日
- 原始版本已发布。
- 2008 年 6 月 2 日
- 版本 1.0.1:更改了
UndoRedoSession.Begin()
的行为,以便在新事务结束时恢复之前的事务,以避免事务范围之间添加操作的问题。还修复了Clear
方法中的 bug,并添加了对非泛型IList
的支持(请参阅菜单中的新 TreeView 示例)。