上下文敏感历史记录。第 1 部分,共 2 部分






4.98/5 (55投票s)
一个桌面和 Silverlight 用户操作管理系统,支持撤销、重做和重复。允许监视操作,并根据上下文(如 UI 控件)进行分组,顺序或并行执行,甚至在失败时回滚。
- 请访问 Codeplex 项目站点 获取最新版本和源代码。

目录
引言
在大多数应用程序的开发过程中,都会在某个时候出现允许用户撤销操作的需求。WPF 和 Silverlight 4 都未提供真正的基础设施来实现这一点,而是要求开发人员在每个组件中构建此功能。为应用程序提供一个通用的机制来管理作者称之为“任务”(即应用程序中执行的工作单元)具有一些真正的优势。这些优势包括允许监视任务,并根据上下文(如 UI 控件)进行分组,按顺序或并行执行,甚至在失败时回滚。
本文提供的任务管理系统的主要功能是
- 任务可以撤销、重做和重复。
- 任务执行可以被取消。
- 复合任务允许按顺序和并行执行任务,并在单个任务失败时自动回滚。
- 任务可以与上下文关联,例如 UserControl,以便撤销、重做和重复操作可以根据 UI 焦点启用。
- 任务可以是全局的,不与任何特定 UI 上下文关联。
- 任务系统可以与 ICommands 连接。
- 任务可以被链接,即一个任务可以使用 Task Service 来执行另一个任务。
- 通过指定撤销点,可以返回到历史记录中的某个点。
- 通过不允许在 Task Service 之外执行任务来保持系统的一致性。
- 任务模型与 Silverlight 和 Desktop CLR 兼容
虽然本文中的示例主要基于 Calcium,但 Task Service 和任务与 Calcium 完全分离,实际上可以被任何 Desktop CLR 或 Silverlight CLR 应用程序使用。本系列将有两篇文章。第一部分,即本文,详细介绍了 Task Model 及其用法。第二部分将展示 Task Model 如何集成到 Calcium 中,并演示一个简单的图表设计器模块。
背景
早在 2007 年,我就写了一篇关于我为游戏创建的 Command Management 系统的文章。本文提供的代码遵循了我在那篇文章中探索的一些相同原则,但这次我极大地扩展了范围和深度。
WPF(以及现在的 Silverlight 4)的命令基础结构不直接通过 CommandManager 提供对撤销重做机制的支持。像 `TextBox` 这样的控件通过 `RoutedCommands` 提供内部支持。因此,使用现有的基础结构很难获得统一的系统。我设计的系统利用了这些控件现有的撤销/重做功能;添加了对未处理的 Routed Commands 的处理程序;同时提供了执行取消等新功能,并允许重复某个任务。
任务模型
如引言所述,任务是作者定义为应用程序执行的工作单元。任务本身是 `ITask` 的实例,它封装了完成目标所需的数据和逻辑,例如在 UI 中移动一个项目。
两种方法
有两种任务管理方法。第一种方法是规定引起状态更改的每个操作都封装在一个任务中。这种方法可能很繁琐,因为每个操作都必须创建一个新的任务实例。这种方法还存在风险,即由任务外部执行的更改可能会改变组件的状态,从而导致撤销任务功能无效。
第二种方法是提供状态感知。这意味着在状态更改发生时捕获组件状态的快照。撤销操作的实现就是简单地恢复 UI 组件的先前状态。对于许多操作不可撤销的界面,例如图形程序中使用滤镜(如模糊)的操作无法撤销,这种方法可能是强制性的。这种方法很有吸引力,因为它具有一次性创建序列化和反序列化组件状态的功能成本。缺点是需要考虑存储效率,因为每个新任务都会保存整个组件状态,而不仅仅是更改。可以想象采用类似 diffgram 的方法来应对额外的存储需求,但这超出了本文的范围。
这两种方法都可以通过本文提供的 Task Model 基础结构来实现。然而,我们将重点关注第一种。
在任务中捕获逻辑
通过将特定操作的逻辑和状态封装在任务中,我们可以更好地管理任务。最值得注意的是,我们可以对任务进行排队,撤销由一个或一组任务执行的工作,并在任务支持的情况下重复该工作。我们还可以为任务提供通知和取消系统,以便任务服务的订阅者能够在执行特定任务时进行干预。我们还支持上下文系统。也就是说,我们可以为 *Task Service* 提供一个标识符,用于划分一组任务,以便围绕这些任务的所有操作都可以与其他任务分开管理。例如,上下文可以是 UI 中的一个视图,这样当视图失去焦点时,该视图的任务撤销就会被禁用。我们也允许全局任务,这些任务与任何特定 UI 上下文都没有关联。
任务服务
所有任务执行活动都由 *Task Service* 执行。事实上,只有 Task Service 才能执行任务。否则,它可能会导致 UI 状态(例如)与 Task Service Undo 或 Repeat 堆栈不同步,从而导致后续的任务撤销或重复产生意外结果。这在任务顺序很重要时很重要,而通常情况下都是如此。
以下流程图显示了 Task Service 如何执行任务。
图:任务执行流程图
Task Service 本身包含几个与执行上下文关联的 *堆栈*。执行上下文可以是控件,也可以是控件的视图模型(例如)。在演示的 Diagram Designer 模块中,我们使用 guid 标识符作为视图模型。通过使用执行上下文,我们可以将一组任务与特定的 UI 元素相关联,从而允许在“编辑”菜单中显示一套不同的撤销/重复任务,具体取决于哪个控件具有焦点。
`ITaskService` 的默认实现是 `TaskService`。另外,我在 Calcium 实现中进一步扩展了这个 `TaskService`,通过事件聚合提供应用程序范围的通知。
图:`ITaskService` 和默认实现 `TaskService`。
任务和可撤销任务
`ITaskService` 实现执行的任务单元由任务表示。有些任务是可撤销的,有些则不是。`ITask` 接口代表所有任务的基本功能。基类 Task(`TaskBase`)继承自 `IInternalTask`。`IInternalTask` 提供与 `TaskService` 实现直接相关的能力,并允许 `TaskService` 重复执行任务。如前所述,用户代码被禁止在没有 `TaskService` 的情况下执行任务。这是通过显式实现内部接口来完成的。禁止直接执行任务的其他原因(绕过 `ITaskService`)包括防止 `TaskService` 事件的订阅者错过通知和取消任务的机会。此外,`ITaskService` 提供了一个用于提供审计和日志记录等的单点。
图:`Task` 类和接口层次结构。
`TaskBase` 和 `UndoableTaskBase` 是创建新任务的起点,这些任务封装了任务的行为。也就是说,通过继承这两个类中的任何一个,我们都可以将任务逻辑和状态封装在子类本身中。或者,如果这太繁重,您又不想创建新的 Task 类,可以使用 `Task
继承自 TaskBase 和 UndoableTaskBase
`TaskBase` 和 `UndoableTaskBase` 都提供可以订阅以执行自定义活动的事件。`TaskBase` 提供 `Execute` 事件,而 `UndoableTaskBase` 提供 `Execute` 事件(继承自 `TaskBase`)和 `Undo` 事件。我发现偏爱事件而非虚拟方法是一个好习惯,因为当使用虚拟方法时,可能会引起关于是否应调用基类实现的困惑,同样它可能创建对基类实现的依赖,并且在这种情况下,我们还可以防止绕过 `TaskService`,因为自定义任务无法调用任务上的 `Execute` 方法。
`MoveDesignerHostTask`(来自 Diagram Designer 模块)是 `UndoableTaskBase` 的自定义实现示例。它在此处完整呈现。
C#class MoveDesignerHostTask : UndoableTaskBase<MoveDesignerHostArgs>
{
Point? point;
public MoveDesignerHostTask()
{
Execute += OnExecute;
Undo += OnUndo;
}
void OnUndo(object sender, TaskEventArgs<MoveDesignerHostArgs> e)
{
if (!point.HasValue)
{
throw new InvalidOperationException("Previous Point is not set.");
}
var viewModel = e.Argument.DesignerItemViewModel;
viewModel.Left = point.Value.X;
viewModel.Top = point.Value.Y;
}
void OnExecute(object sender, TaskEventArgs<MoveDesignerHostArgs> e)
{
var newPoint = e.Argument.NewPoint;
point = e.Argument.OldPoint;
var viewModel = e.Argument.DesignerItemViewModel;
viewModel.Left = newPoint.X;
viewModel.Top = newPoint.Y;
}
public override string DescriptionForUser
{
get
{
return "Move Item"; /* TODO: Make localizable resource. */
}
}
}
VB.NETFriend Class MoveDesignerHostTask
Inherits UndoableTaskBase(Of MoveDesignerHostArgs)
' Methods
Public Sub New()
AddHandler MyBase.Execute, New EventHandler(Of TaskEventArgs(Of MoveDesignerHostArgs))(AddressOf Me.OnExecute)
AddHandler MyBase.Undo, New EventHandler(Of TaskEventArgs(Of MoveDesignerHostArgs))(AddressOf Me.OnUndo)
End Sub
Private Sub OnExecute(ByVal sender As Object, ByVal e As TaskEventArgs(Of MoveDesignerHostArgs))
Dim newPoint As Point = e.Argument.NewPoint
Me.point = New Point?(e.Argument.OldPoint)
Dim viewModel As DesignerItemViewModel = e.Argument.DesignerItemViewModel
viewModel.Left = newPoint.X
viewModel.Top = newPoint.Y
End Sub
Private Sub OnUndo(ByVal sender As Object, ByVal e As TaskEventArgs(Of MoveDesignerHostArgs))
If Not Me.point.HasValue Then
Throw New InvalidOperationException("Previous Point is not set.")
End If
Dim viewModel As DesignerItemViewModel = e.Argument.DesignerItemViewModel
viewModel.Left = Me.point.Value.X
viewModel.Top = Me.point.Value.Y
End Sub
' Properties
Public Overrides ReadOnly Property DescriptionForUser As String
Get
Return "Move Item"
End Get
End Property
' Fields
Private point As Point?
End Class
我们可以看到此任务的目的是仅仅通过设置 `Left` 和 `Top` 属性来重新定位 `DesignerItemViewModel`。
使用轻量级 Task 和 UndoableTask 类
虽然通常最好将 Task 逻辑放在类中,因为这可以提高可重用性,并有助于解耦应用程序逻辑(即策略模式),但有时我们可能希望使用 *委托* 来进行内联操作。为此,我们可以使用 `Task` 和 `UndoableTask`,它们都接受 `Action` 参数。以下摘录自 `DiagramDesignerViewModel`,演示了 `UndoableTask` 及其用于添加新设计器项的用法。
C#DesignerItemViewModel designerItemViewModel;
var removedItems = new Stack<DesignerItemViewModel>();
UndoableTask<object> task = new UndoableTask<object>(
delegate
{
if (removedItems.Count > 0)
{
designerItemViewModel = removedItems.Pop();
}
else
{
if (lastAddedAt.Y > 200)
{
offset += offsetIncrement;
lastAddedAt = new Point(offset, 0);
}
lastAddedAt = new Point(lastAddedAt.X + offsetIncrement,
lastAddedAt.Y + offsetIncrement);
designerItemViewModel = new DesignerItemViewModel
{
Left = lastAddedAt.X, Top = lastAddedAt.Y
};
}
designerItems.Add(designerItemViewModel);
},
delegate
{
if (lastAddedAt.X > offset && lastAddedAt.Y > offset)
{
lastAddedAt = new Point(lastAddedAt.X - offset, lastAddedAt.Y - offset);
}
int removalIndex = designerItems.Count - 1;
var viewModel = designerItems[removalIndex];
designerItems.RemoveAt(removalIndex);
removedItems.Push(viewModel);
/* The align left command may not be executable now. */
alignLeftCommand.RaiseCanExecuteChanged();
}, "Add Designer Item");
task.Repeatable = true;
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
taskService.PerformTask(task, null, Id);
VB.NETDim designerItemViewModel As DesignerItemViewModel
Dim removedItems As New Stack(Of DesignerItemViewModel)
Dim task As New UndoableTask(Of Object)(Function
If (removedItems.Count > 0) Then
designerItemViewModel = removedItems.Pop
Else
If (Me.lastAddedAt.Y > 200) Then
Me.offset = (Me.offset + Me.offsetIncrement)
Me.lastAddedAt = New Point(Me.offset, 0)
End If
Me.lastAddedAt = New Point((Me.lastAddedAt.X + Me.offsetIncrement), _
(Me.lastAddedAt.Y + Me.offsetIncrement))
designerItemViewModel = New DesignerItemViewModel { _
.Left = Me.lastAddedAt.X, _
.Top = Me.lastAddedAt.Y _
}
End If
Me.designerItems.Add(designerItemViewModel)
End Function, Function
If ((Me.lastAddedAt.X > Me.offset) AndAlso (Me.lastAddedAt.Y > Me.offset)) Then
Me.lastAddedAt = New Point((Me.lastAddedAt.X - Me.offset), (Me.lastAddedAt.Y - Me.offset))
End If
Dim removalIndex As Integer = (Me.designerItems.Count - 1)
Dim viewModel As DesignerItemViewModel = Me.designerItems.Item(removalIndex)
Me.designerItems.RemoveAt(removalIndex)
removedItems.Push(viewModel)
Me.alignLeftCommand.RaiseCanExecuteChanged
End Function, "Add Designer Item")
task.Repeatable = True
ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService).PerformTask(Of Object)(_
DirectCast(task, UndoableTaskBase(Of Object)), Nothing, MyBase.Id)
请注意,为了允许此任务被重复,我们必须将其 `Repeatable` 属性设置为 true。这样做会指示 `TaskService` 将其放置在与 `DiagramDesignerViewModel`(通过其 `Id` 属性)关联的可重复 *堆栈* 中。
复合任务
有时我们可能希望允许将一组任务与单个用户操作相关联。在这种情况下,我们可能会试图通过将各种逻辑上不同的活动放入单个任务来绕过任务系统,这有违反 单一职责原则 的风险。为了防止这种情况,我创建了 *复合任务* 的概念。复合任务允许您在其中放入任意数量的任务,然后在执行、撤销或重复复合任务时;所有任务都会被执行等。使用复合任务还允许我们选择按顺序或并行执行子任务,并在单个任务引发异常时自动撤销任务。
就像我们有 `Task` 和 `UndoableTask` 类来表示单个活动一样,我们也有 `CompositeTask` 和 `CompositeUndoableTask` 类。
执行复合可撤销任务
要执行一组任务,我们实例化一个 `CompositeUndoableTask`;将任务和相关任务参数的 `IDictionary` 传递给它,如下面的摘录所示,它演示了图表设计器的对齐功能
C#var tasksAndArgs = designerItems.ToDictionary(
x => (UndoableTaskBase<MoveDesignerHostArgs>)new MoveDesignerHostTask(),
x => new MoveDesignerHostArgs(x, new Point(20, x.Top)));
var undoableTask = new CompositeUndoableTask<MoveDesignerHostArgs>(tasksAndArgs, "Align Left");
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
taskService.PerformTask(undoableTask, null, Id);
VB.NETDim undoableTask As New CompositeUndoableTask(Of MoveDesignerHostArgs)( _
Me.designerItems.ToDictionary(Of DesignerItemViewModel, _
UndoableTaskBase(Of MoveDesignerHostArgs), _
MoveDesignerHostArgs)(Function (ByVal x As DesignerItemViewModel)
Return New MoveDesignerHostTask
End Function, Function (ByVal x As DesignerItemViewModel)
Return New MoveDesignerHostArgs(x, New Point(20, x.Top))
End Function), "Align Left")
ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService) _
.PerformTask(Of MoveDesignerHostArgs)( _
DirectCast(undoableTask, UndoableTaskBase(Of MoveDesignerHostArgs)), Nothing, MyBase.Id)
对用户来说,这看起来就像一个单一的操作,并且如果执行撤销,它也会按顺序撤销所有任务。
复合任务的并行执行
默认情况下,复合任务是按顺序执行的,也就是说,子任务在同一线程上逐个执行,如下面的 `CompositeUndoableTask` 摘录所示
C#static void ExecuteSequentially(Dictionary<UndoableTaskBase<T>, T> taskDictionary)
{
var performedTasks = new List<UndoableTaskBase<T>>();
foreach (KeyValuePair<UndoableTaskBase<T>, T> pair in taskDictionary)
{
var task = (IInternalTask)pair.Key;
try
{
task.PerformTask(pair.Value);
performedTasks.Add(pair.Key);
}
catch (Exception)
{
SafelyUndoTasks(performedTasks.Cast<IUndoableTask>());
throw;
}
}
}
VB.NETPrivate Shared Sub ExecuteSequentially( _
ByVal taskDictionary As Dictionary(Of UndoableTaskBase(Of T), T))
Dim performedTasks As New List(Of UndoableTaskBase(Of T))
Dim pair As KeyValuePair(Of UndoableTaskBase(Of T), T)
For Each pair In taskDictionary
Dim task As IInternalTask = pair.Key
Try
task.PerformTask(pair.Value)
performedTasks.Add(pair.Key)
Catch exception1 As Exception
CompositeUndoableTask(Of T).SafelyUndoTasks(performedTasks.Cast(Of IUndoableTask)())
Throw
End Try
Next
End Sub
然而,有时我们可能会有一组独立的任务,它们可能受益于在不同线程上执行。
static void ExecuteInParallel(Dictionary<UndoableTaskBase<T>, T> taskDictionary)
{
/* When we move to .NET 4 we may use System.Threading.Parallel for the Desktop CLR. */
var performedTasks = new List<UndoableTaskBase<T>>();
object performedTasksLock = new object();
var exceptions = new List<Exception>();
object exceptionsLock = new object();
var events = taskDictionary.ToDictionary(x => x, x => new AutoResetEvent(false));
foreach (KeyValuePair<UndoableTaskBase<T>, T> pair in taskDictionary)
{
var autoResetEvent = events[pair];
var task = (IInternalTask)pair.Key;
var undoableTask = pair.Key;
var arg = pair.Value;
ThreadPool.QueueUserWorkItem(
delegate
{
try
{
task.PerformTask(arg);
lock (performedTasksLock)
{
performedTasks.Add(undoableTask);
}
}
catch (Exception ex)
{
/* TODO: improve this to capture undone task errors. */
lock (exceptionsLock)
{
exceptions.Add(ex);
}
}
autoResetEvent.Set();
});
}
foreach (var autoResetEvent in events.Values)
{
autoResetEvent.WaitOne();
}
if (exceptions.Count > 0)
{
SafelyUndoTasks(performedTasks.Cast<IUndoableTask>());
throw new CompositeException("Unable to undo tasks", exceptions);
}
}
在这种情况下,我们只需要在执行前更改 `CompositeTask` 的 `Parallel` 属性。下面的单元测试摘录对此进行了演示
C#void CompositeTasksShouldBePerformedInParallel(object contextKey)
{
var tasks = new Dictionary<TaskBase<string>, string>();
for (int i = 0; i < 100; i++)
{
tasks.Add(new MockTask(), i.ToString());
}
var compositeTask = new CompositeTask<string>(tasks, "1") { Parallel = true };
var target = new TaskService();
target.PerformTask(compositeTask, null, contextKey);
foreach (KeyValuePair<TaskBase<string>, string> keyValuePair in tasks)
{
var mockTask = (MockTask)keyValuePair.Key;
Assert.AreEqual(1, mockTask.ExecutionCount);
}
}
VB.NETPrivate Sub CompositeTasksShouldBePerformedInParallel(ByVal contextKey As Object)
Dim tasks As New Dictionary(Of TaskBase(Of String), String)
Dim i As Integer
For i = 0 To 100 - 1
tasks.Add(New MockTask, i.ToString)
Next i
Dim compositeTask As New CompositeTask(Of String)(tasks, "1")
compositeTask.Parallel = True
New TaskService().PerformTask(Of String)(compositeTask, Nothing, contextKey)
Dim keyValuePair As KeyValuePair(Of TaskBase(Of String), String)
For Each keyValuePair In tasks
Dim mockTask As MockTask = DirectCast(keyValuePair.Key, MockTask)
Assert.AreEqual(Of Integer)(1, mockTask.ExecutionCount)
Next
End Sub
任务链
任务本身可以利用 `ITaskService` 来执行子任务。这很有用,当我们在条件执行任务时,需要根据某个内部任务活动的结果。链式功能解释了 `TaskService` 类中各种 `PerformTask` 重载实现中一个奇怪的方面。
C#public TaskResult PerformTask<T>(TaskBase<T> task, T argument, object contextKey)
{
ArgumentValidator.AssertNotNull(task, "task");
if (contextKey == null)
{
return PerformTask(task, argument);
}
var eventArgs = new CancellableTaskServiceEventArgs(task);
OnExecuting(eventArgs);
if (eventArgs.Cancel)
{
return TaskResult.Cancelled;
}
int dictionaryKey = contextKey.GetHashCode();
/* Clear the undoable tasks for this context. */
undoableDictionary.Remove(dictionaryKey);
redoableDictionary.Remove(dictionaryKey);
ReadWriteSafeStack<IInternalTask> tasks;
if (!repeatableDictionary.TryGetValue(dictionaryKey, out tasks))
{
tasks = new ReadWriteSafeStack<IInternalTask>();
repeatableDictionary[dictionaryKey] = tasks;
}
tasks.Push(task);
var result = task.PerformTask(argument);
OnExecuted(new TaskServiceEventArgs(task));
return result;
}
VB.NETPublic Function PerformTask(Of T)(ByVal task As TaskBase(Of T), _
ByVal argument As T, ByVal contextKey As Object) As TaskResult
Dim tasks As ReadWriteSafeStack(Of IInternalTask)
ArgumentValidator.AssertNotNull(Of TaskBase(Of T))(task, "task")
If (contextKey Is Nothing) Then
Return Me.PerformTask(Of T)(task, argument)
End If
Dim eventArgs As New CancellableTaskServiceEventArgs(task)
Me.OnExecuting(eventArgs)
If eventArgs.Cancel Then
Return TaskResult.Cancelled
End If
Dim dictionaryKey As Integer = contextKey.GetHashCode
Me.undoableDictionary.Remove(dictionaryKey)
Me.redoableDictionary.Remove(dictionaryKey)
If Not Me.repeatableDictionary.TryGetValue(dictionaryKey, tasks) Then
tasks = New ReadWriteSafeStack(Of IInternalTask)
Me.repeatableDictionary.Item(dictionaryKey) = tasks
End If
tasks.Push(task)
Dim result As TaskResult = task.PerformTask(argument)
Me.OnExecuted(New TaskServiceEventArgs(task))
Return result
End Function
我们看到任务本身是在任务推送到任务 *堆栈* 之后才执行的。从而允许以正确的顺序撤销或重复任何链式任务。
单元测试
`TaskServiceTest` 类包含整个 Task Model 的单元测试。行为驱动的方法在开发此类组件时非常宝贵,因为需要测试的场景很多。这个类值得一看,以了解一些在演示应用程序中未涵盖的行为。
图:任务模型单元测试结果
结论
在本文中,我们了解了任务(应用程序的工作单元)如何通过任务管理系统执行、撤销和重复。我们看到了复合任务(包含任意数量的子任务)如何按顺序或并行执行,以及任务如何在不损害撤销/重复功能的情况下进行链接。然后,我们讨论了 *Task Model* 在简单图表工具中的实际应用。在本系列的下一部分中,我们将介绍 *Task Model* 如何集成到 WPF 应用程序中,并更详细地探讨示例图表设计器。希望您届时能与我一起。
我希望您觉得这个项目有用。如果有用,我将不胜感激您能对其进行评分和/或在下方留下反馈。这将帮助我写出更好的下一篇文章。
历史记录
2010 年 2 月
- 首次发布。