oops - A Cross-Platform, General Purpose Undo/Redo Framework
oops 框架的设计、实现和使用
引言
许多面向用户的应用程序通常希望为用户驱动的操作提供某种撤销/重做功能。通用的撤销/重做框架应该跨平台、可配置且结构松散,以适应不可预见的用法需求(由UI驱动?由服务驱动?并发访问者?等)。
背景
创建通用撤销/重做框架的难点在于解决(至少)两个核心问题:
1. 能够将一系列用户操作定义为可撤销
2. 使用户所做的每一件事都可撤销
-
例如,使每次输入的字符都可撤销,可能会导致糟糕的用户体验,但嘿,这是个开始。
- 理想的框架允许代码聚合更改
- oops 允许进行配置或介于两者之间的操作。这完全取决于你创建和使用的 Accumulator 的范围。
总体目标:以有序的方式观察系统中所有对象的更改,针对 N 个未知对象
例如,任何给定的用户操作可能会创建和/或修改 50 个对象,通过在更改时明确告知每个对象如何执行以及如何撤销,将所有更改汇总为一个用户撤销操作,这至少是繁琐的,在任何实际用例中都是不可能的。oops 通过观察 ViewModel(派生自 TrackableViewModel)中的任何和所有属性更改以及任何集合更改(TrackableCollection - list, stack, queue - 或 TrackableDictionary),将所有这些管理起来,并将它们聚合为全局或局部的单个撤销操作。
为什么区分全局和局部更改?很高兴你问了。
假设你的应用程序有一个主窗口/窗体/页面之类的东西。应用程序中的任何操作都将被观察并合并到撤销操作中,通过一个由菜单驱动的撤销/重做系统(就像我们习惯的后退和前进箭头一样)。
现在,假设你有一个弹出对话框,其中进行了一些可撤销的操作,然后又弹出了另一个进行了一些可撤销操作的对话框。根据项目经理的意愿,这两个对话框的所有操作都应该合并为一个可撤销的用户操作,或者应该有两个。oops 通过创建单例/全局作用域或局部作用域的选项,使得这两种情况(以及我认为能想到的任何情况)都可用,这两种作用域都可以添加到全局撤销堆栈中。
支持任何 ViewModel 或 Collection 更改的撤销/重做组件
-
TrackableCollection - ObservableCollection 的增强版
- 它会为你处理切换到 UI 线程的上下文(如果设置为在 UI 线程上触发)
- 无缝跟踪所有操作并使其可撤销 - 随时可以打开/关闭
- 按照 UI 所需的顺序、在正确的线程上、与调用线程同步地触发 CollectionChanged 和 PropertyChanged 事件 - 随时可以关闭
- 可以很好地处理并发(从多个线程对其进行大量操作,它会一切处理妥当)
- 还可以作为 Stack<> 或 Queue<> 工作(因为有时你需要将它们绑定到 UI)
- 高性能(这个词还在用吗?),只要 UI 允许
-
TrackableDictionary - Dictionary 的超级增强版
- 按照 UI 所需的顺序、在正确的线程上、与调用线程同步地触发 CollectionChanged 和 PropertyChanged 事件
- 可以很好地处理并发(从多个线程对其进行大量操作,它会一切处理妥当)
- 它会为你处理切换到 UI 线程的上下文(如果设置为在 UI 线程上触发)
- 无缝跟踪所有操作并使其可撤销 - 随时可以打开/关闭
- 高性能,只要 UI 允许并且绑定到 UI
-
ConcurrentList
- 一个高性能的 List<>,可以很好地处理并发
- 拥有许多便捷的方法,如 RemoveAndGetIndex、ReplaceAll 等,这些方法本身就是线程安全的
- 也可以作为 Stack<> 或 Queue<> 工作
-
Accumulator
- 记录所有操作以便以后撤销
- 可以作为单例用于应用程序范围的操作,也可以作为实例用于局部范围的操作(例如在对话框中)
-
AccumulatorManager
- 管理 Accumulator 的 Undo 和 Redo 堆栈
- 自动记录对任何撤销操作的重做操作,反之亦然
-
TrackableViewModel
- 自动跟踪其在引用的 Accumulator(单例或本地 Accumulator)中的所有属性更改
- 通过使用自定义的 Get<>/Set<> 方法来跟踪任何属性更改
-
TrackableScope
- 实用类,用于轻松创建 Accumulator 并在 Dispose 时将其推送到 AccumulatorManager
- 在其
using(new TrackableScope("testing"))
块内的所有更改都将聚合为一次用户撤销操作
使用代码
- 创建一个继承自 TrackableViewModel 的 ViewModel
- 为其添加一些属性,如下所示
public bool ILikeCheese
{
get => Get<bool>();
set
{
if(Set(value))
MessageBox.Show("Why did you change your mind?");
} = true;
}
- 对于你需要的任何集合,请使用 TrackableCollection(最常用)
- 当你想要跟踪更改时,创建你的作用域,如下所示
using (new TrackableScope("Undo me now!"))
{
... do some stuff ...
}
或者,只需将 Globals.ScopeEachChange
设置为 true。
就是这样!AccumulatorManager
为你提供了可在 UI 中使用的绑定属性/命令,用于撤销你已跟踪的任何内容。
关注点
该框架经过了 4 年多的设计、提炼和改进,我认为是时候发布它了,以便为任何想为其应用程序添加此功能的人提供帮助。
与任何处理高性能并发活动的庞大任务的框架一样,有可能创建线程锁,所以请继续阅读以了解如何避免这种情况。 :)
如何使用 oops 框架创建线程锁?
不幸的是,如果你不清楚 TrackableCollection/Dictionary 由于大多数 UI(如 WPF)的特性而如何进行并发锁定(集合更改和 CollectionChanged 事件需要同步发生),那么创建线程锁就非常简单。在这种情况下,锁必须设置在 UI 线程上。我真诚地希望情况并非如此,并且我确实付出了很多努力来找到一种方法来避免这种情况,但 WPF 在经历了大量的痛苦之后,扼杀了所有此类希望。我在 .NET Core Xaml 方面的经验并未让我相信它在这方面比 WPF 更具灵活性。
考虑到任何 TrackableCollection/Dictionary 的锁都会发生在 UI 线程上,创建线程锁定情况变得轻而易举。假设你有这个糟糕的扩展方法(即使这是一个糟糕的方法,你也能明白我的意思)
public static void AddAndRemoveOneSecondLater<TType>(this TrackableCollection<TType> coll, TType item)
{
lock(coll.SyncRoot)
{
coll.Add(item);
Thread.Sleep(60000);
coll.Remove(item);
}
}
如果你有这个方法,而且你不是新手,你不会因为 Thread.Sleep() 调用而从 UI 线程调用它,因为那样会很不专业。确实很不专业!
所以,如果从某个后台线程(比如线程池)调用它,你将在后台线程上锁定,然后 Add() 也将在 UI 线程上锁定同一个对象。由于 Add() 是同步的,你现在就造成了一个线程锁,因为 UI 线程在等待你的后台线程释放锁,但你的后台线程在等待 UI 线程上的 Add() 完成。