上下文感知的历史记录。第 2 部分(共 2 部分)






4.98/5 (36投票s)
一个桌面和 Silverlight 用户操作管理系统,具有撤销、重做和重复功能;允许监视操作,并根据上下文(例如 UI 控件)进行分组,顺序或并行执行,甚至在失败时回滚。
- 请访问 Codeplex 项目站点 获取最新版本和源代码。
目录
引言
在我们第一篇文章中,我们探讨了一个任务管理系统。我们看到了任务(应用程序的工作单元)是如何执行、撤销和重复的。我们还研究了复合任务(包含任意数量的子任务)如何按顺序或并行执行,以及任务如何在不损害撤销/重复能力的情况下进行链接。
在本文中,我们将看到任务模型是如何集成到 WPF 应用程序中的。更具体地说,是集成到Calcium 应用程序框架中。我们将看到如何在简易的图表设计器中执行撤销、重做和重复操作,还将研究如何使用 Prism 的 DelegateCommands
和 CompositeCommands
的增强版本,通过复合任务将多个任务作为组来执行和撤销。我们还将研究如何使用增强的 Prism DelegateCommands
和 CompositeCommands
将任务服务操作连接到 UI。
如果您还没有阅读,我强烈建议您先阅读第一篇文章。
虽然本文中的示例主要基于Calcium,但任务服务和任务与 Calcium 完全分离,实际上可以被任何桌面 CLR 或 Silverlight CLR 应用程序使用。
功能回顾
本文提供的任务管理系统的主要功能包括:
- 任务可以被撤销、重做和重复。
- 任务执行可以被取消。
- 复合任务允许按顺序和并行执行任务,并在单个任务失败时自动回滚。
- 任务可以与上下文关联,例如用户控件,这样撤销、重做和重复操作就可以根据 UI 焦点来启用。
- 任务可以是全局的,没有上下文关联。
- 任务系统可以与 ICommands 连接。
- 任务可以被链接,一个任务可以使用任务服务执行另一个任务。
- 通过指定撤销点,可以恢复到历史记录中的某个点。
- 通过禁止在任务服务之外执行任务来维护系统的一致性。
- 任务模型与 Silverlight 和桌面 CLR 兼容。
一个示例设计器
为了演示任务模型,我创建了 Calcium 的一个简易图表设计器模块的雏形。该设计器大量使用了 MVVM 模式,其中与设计器项的交互(例如创建新项或移动项)通过一个 ViewModelBase
类完成,该类恰好实现了 INotifyPropertyChanged
。这使得我们的 UI 逻辑易于测试。
图表设计器模块的结构如下所示。
图:图表设计器模块概念布局
DiagramDesignerView
DiagramDesignerView
通过一个可观察的集合呈现设计器项(小的 Calcium 立方体)。我们使用 ItemsControl
,其 ItemsTemplate
由一个 Canvas 组成。这比直接使用 Canvas 提供了某些优势。通过结合使用 ItemsControl
和 Canvas,我们获得了两全其美的效果。我们能够将数据绑定到项的 ObservableCollection
,并且能够使用 Canvas
中的 XY 坐标灵活地放置项。
<ItemsControl ItemsSource="{Binding Path=DesignerItems}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas x:Name="Canvas_Root"
ClipToBounds="True"
SnapsToDevicePixels="True" Margin="2"
Background="{DynamicResource ControlBackgroundBrush}"
Thumb.DragStarted="Canvas_Root_DragStarted"
Thumb.DragCompleted="Canvas_Root_DragCompleted"
PreviewMouseDown="Canvas_PreviewMouseDown" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Module:DesignerHost >
<ContentControl Content="{Binding}"
ContentTemplateSelector="{StaticResource DesignerItemTemplateSelector}" />
</Module:DesignerHost>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemContainerStyle>
<Style>
<Setter Property="Canvas.Top" Value="{Binding Path=Top}" />
<Setter Property="Canvas.Left" Value="{Binding Path=Left}" />
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
我们看到 DesignerHost
的位置实际上由 DesignerItemViewModel
的 Top
和 Left
属性决定。是 ViewModel 决定了位置,这很有用,因为它简化了 UI 状态的测试、开发和持久化,因为我们不必担心 UI 控件的生命周期。
ItemTemplate
生成 DesignerHosts
,而 DesignerHost
充当 DesignerItems
的宿主。DesignerItems
是使用 DesignerItemTemplateSelector
生成的。这用于根据 ViewModel 或任何我们喜欢的东西来获取模板。创建设计器并非易事,过去我曾将 sukrams 的设计器文章作为有用的资源。但我认为更多地依赖 MVVM 方法可以改善事情,而新的 Calcium Diagram Designer 为未来的发展提供了一个良好的基础。
与任务服务交互的命令
用于与 ITaskService
交互的 ICommands
通过 TaskCommandProxies
类公开。我们在绑定到 MenuItems
和 ToolBar
按钮时使用此类。
图:命令通过 TaskCommandProxies
类进行消费。
每个 CommandLogic
类包含一个 UICommand
实例。UICommands
继承自 Prism 的 DelegateCommand
,并为我们提供了两个额外的、重要的功能:
1. 通过订阅 System.Windows.Input.CommandManager
的 RequerySuggested
事件和 Calcium 的 ActiveViewChangedInShellEvent
来重新评估 CanExecute
处理程序。
2. 一个 Text 属性,用于绑定到菜单项等。
Prism 的 DelegateCommands
被设计为 UI 无关的。因此,CanExecute
的重新评估不像 WPF 的 RoutedCommands
那样自动发生。所以,我们订阅这两个事件以便在与用户界面的交互期间发生重新评估。
在 Calcium 的 DesktopShellView
类中,有一些关于活动视图和活动工作区(或文档)视图的逻辑。活动视图被认为是具有焦点或活动工作区文档的视图(通过回退机制到另一个 TabControl
区域)。窗口中的一个顶级容器订阅了 UIElement.GotFocus
RoutedEvent
。当一个控件获得焦点时,我们会重新评估谁是活动视图;是“文本编辑器”还是“图表设计器”等。当活动视图被确定为已更改时,我们会发布 ActiveViewChangedInShell
事件。此外,当 ActiveAwareUIElementAdapter
检测到视图具有 GotFocus
、LostFocus
、Loaded
、Unloaded
时,它会发布一个 CompositePresentationEvent
,该事件在 IShellView
中处理。这些措施允许视图更新其内容等。在我们的任务模型中,任务命令逻辑会被重新评估,这会导致菜单项被禁用/启用,以及 MenuItem.Header
属性被更新。所以,我们可以看到,这里有很多的协调工作。
图:与 ITaskService
交互的命令处理程序放置在 CommandLogic
类中。
任务命令已数据绑定到 StandardMenu
控件中的菜单项。
<MenuItem Header="Edit" cal:RegionManager.RegionName="{x:Static Client:MenuNames.Edit}">
<MenuItem Command="ApplicationCommands.Undo" />
<MenuItem Command="ApplicationCommands.Redo" />
<MenuItem Command="{Binding Source={x:Static TaskModel:TaskCommandProxies.RepeatTaskCommandLogic}, Path=Command}"
Header="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}" />
<MenuItem Command="{Binding Source={x:Static TaskModel:TaskCommandProxies.UndoGlobalTaskCommandLogic}, Path=Command}"
Header="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}" />
<MenuItem Command="{Binding Source={x:Static TaskModel:TaskCommandProxies.RedoGlobalTaskCommandLogic}, Path=Command}"
Header="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}" />
...
</MenuItem>
请注意 Command
类中 Command.Text
的使用。在这里,我们看到文本在命令评估期间会发生变化。例如,当您向图表设计器添加新项时,您会注意到显示“Repeat”的菜单项会变为“Repeat Add Designer Item.”
对于常规的撤销和重做,我们依赖于普通的 RoutedCommands
:ApplicationCommands.Undo
和 ApplicationCommands.Redo
。Calcium 有一个 ICommandService
,它允许我们将处理程序注册到顶层窗口(在 Prism 术语中称为“shell”),如下面的摘录所示:
commandService.AddCommandBinding(ApplicationCommands.Undo,
delegate
{
var activeView = GetActiveView();
if (activeView == null)
{
return false;
}
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
var taskResult = taskService.Undo(id);
return true; /* We do not allow the event to bubble. */
},
delegate
{
var activeView = GetActiveView();
if (activeView == null)
{
return false;
}
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
var result = taskService.CanUndo(id);
return result;
},
new KeyGesture(Key.Z, ModifierKeys.Control));
事实上,TaskService
的撤销和重做命令处理程序的连接本身是在一个名为 TaskServiceCommandRegistrationTask
的 Task
中完成的,该任务继承自 TaskBase
,并在执行期间需要 ICommandService
作为任务参数。
class TaskServiceCommandRegistrationTask : TaskBase<ICommandService>
{
public TaskServiceCommandRegistrationTask()
{
Execute += OnExecute;
}
void OnExecute(object sender, TaskEventArgs<ICommandService> e)
{
ArgumentValidator.AssertNotNull(e, "e");
var commandService = e.Argument;
#region Undo with Context
commandService.AddCommandBinding(ApplicationCommands.Undo,
delegate
{
var activeView = GetActiveView();
if (activeView == null)
{
return false;
}
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
var taskResult = taskService.Undo(id);
return true; /* We do not allow the event to bubble. */
},
delegate
{
var activeView = GetActiveView();
if (activeView == null)
{
return false;
}
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
var result = taskService.CanUndo(id);
return result;
},
new KeyGesture(Key.Z, ModifierKeys.Control));
#endregion
#region Redo with Context
commandService.AddCommandBinding(ApplicationCommands.Redo,
delegate
{
var activeView = GetActiveView();
if (activeView == null)
{
return false;
}
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
var taskResult = taskService.Redo(id);
return true; /* We do not allow the event to bubble. */
},
delegate
{
var activeView = GetActiveView();
if (activeView == null)
{
return false;
}
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
var id = activeView.ViewModel != null ? (object)activeView.ViewModel.Id : activeView;
return taskService.CanRedo(id);
},
new KeyGesture(Key.Y, ModifierKeys.Control));
#endregion
}
IView GetActiveView()
{
var viewService = ServiceLocatorSingleton.Instance.GetInstance<IViewService>();
var activeView = viewService.ActiveView;
return activeView;
}
public override string DescriptionForUser
{
get
{
/* TODO: Make localizable resource. */
return "Register default Task Service commands";
}
}
}
VB.NETFriend Class TaskServiceCommandRegistrationTask
Inherits TaskBase(Of ICommandService)
Public Sub New()
AddHandler MyBase.Execute, New EventHandler( _
Of TaskEventArgs(Of ICommandService))(AddressOf Me.OnExecute)
End Sub
Private Function GetActiveView() As IView
Return ServiceLocatorSingleton.Instance.GetInstance(Of IViewService).ActiveView
End Function
Private Sub OnExecute(ByVal sender As Object, ByVal e As TaskEventArgs(Of ICommandService))
ArgumentValidator.AssertNotNull(Of TaskEventArgs(Of ICommandService))(e, "e")
Dim commandService As ICommandService = e.Argument
commandService.AddCommandBinding(ApplicationCommands.Undo, Function
Dim activeView As IView = Me.GetActiveView
If (activeView Is Nothing) Then
Return False
End If
Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _
DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
Dim taskResult As TaskResult = taskService.Undo(id)
Return True
End Function, Function
Dim activeView As IView = Me.GetActiveView
If (activeView Is Nothing) Then
Return False
End If
Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _
DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
Return taskService.CanUndo(id)
End Function, New KeyGesture(Key.Z, ModifierKeys.Control))
commandService.AddCommandBinding(ApplicationCommands.Redo, Function
Dim activeView As IView = Me.GetActiveView
If (activeView Is Nothing) Then
Return False
End If
Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _
DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
Dim taskResult As TaskResult = taskService.Redo(id)
Return True
End Function, Function
Dim activeView As IView = Me.GetActiveView
If (activeView Is Nothing) Then
Return False
End If
Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
Dim id As Object = IIf((Not activeView.ViewModel Is Nothing), _
DirectCast(activeView.ViewModel.Id, Object), DirectCast(activeView, Object))
Return taskService.CanRedo(id)
End Function, New KeyGesture(Key.Y, ModifierKeys.Control))
End Sub
Public Overrides ReadOnly Property DescriptionForUser As String
Get
Return "Register default Task Service commands"
End Get
End Property
End Class
这就是我们如何利用常规的撤销和重做 RoutedUICommands
。如果撤销或重做命令一路冒泡到主窗口,它将触发与 ITaskService
交互的命令处理程序。
The Diagram Designer ToolBar
DiagramDesignerToolBar
是一个 ToolBar
,它被包装在一个 UserControl
中,以便支持设计时。
熟悉 Calcium SDK 和 IViewService
的读者可以跳过几段。对于不熟悉或想回顾的读者,请继续阅读。与 Calcium 环境中的其他界面控件一样,IViewService
已被用来将其可见状态与内容类型 DiagramDesignerView
相关联。当 DiagramDesignerView
是活动工作区项时,ToolBar
就会显示。就是这么简单。这是通过将 ToolBar
注册到 IViewService
来实现的,如下面的摘录所示:
viewService.AssociateVisibility(typeof(DiagramDesignerView), new UIElementAdapter(toolBar), Visibility.Collapsed);
当 DiagramDesignerModule
初始化时,控件被实例化,ToolBar
被分离,这恰好是 WPF 的一个要求,因为 ToolBar
在分离之前不能被分配给另一个父容器。这已通过在用户控件中创建一个分离 ToolBar 的属性来实现,如下所示:
public ToolBar ToolBar
{
get
{
LayoutRoot.Children.Remove(toolBar_Main);
return toolBar_Main;
}
}
VB.NETPublic ReadOnly Property ToolBar As ToolBar
Get
Me.LayoutRoot.Children.Remove(Me.toolBar_Main)
Return Me.toolBar_Main
End Get
End Property
ToolBar 本身是简易的,只有两个按钮,用于演示向 Diagram Designer View 添加项以及将视图中的所有项对齐到左侧。
<Button x:Name="Button_AddItem"
Command="{x:Static Commands:DiagramDesignerCommands.AddDesignerItemCommand}"
ToolTip="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}"
Width="18" Height="18">
<Canvas>...</Canvas>
</Button>
<Button x:Name="Button_AlignLeft"
Command="{x:Static Commands:DiagramDesignerCommands.AlignLeftCommand}"
ToolTip="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}"
Width="18" Height="18">
<Canvas>...</Canvas>
</Button>
与前面提到的撤销重做 MenuItems
一样,我们使用一个名为 DesignerDiagramCommands
的代理来将相关命令绑定到 ToolBar
按钮。但是,不同之处在于代理公开的是 Prism CompositeCommands
。(详细了解 CompositeCommands 和 Prism 的命令模型)DiagramDesignerViewModel
通过代理注册了几个 DelegateCommands
。这些命令利用了 ViewModel 对自身是否处于活动状态(拥有用户焦点)的了解能力。我在这里更详细地讨论了如何在 Calcium 中实现这一点。
以下来自 DiagramDesignerViewModel
的摘录显示了 DelegateCommands
的连接方式。
var commandsProxy = new DiagramDesignerCommandsProxy();
addDesignerItemCommand = new UICommand<object>(AddDesignerItem, arg => Active);
commandsProxy.AddDesignerItemCommand.RegisterCommand(addDesignerItemCommand);
alignLeftCommand = new UICommand<object>(OnAlignLeft, arg => Active && designerItems.Count > 0);
commandsProxy.AlignLeftCommand.RegisterCommand(alignLeftCommand);
VB.NETDim commandsProxy As New DiagramDesignerCommandsProxy
Me.addDesignerItemCommand = New UICommand(Of Object)(New Action(Of Object)(AddressOf Me.AddDesignerItem), _
Function (ByVal arg As Object)
Return MyBase.Active
End Function)
commandsProxy.AddDesignerItemCommand.RegisterCommand(Me.addDesignerItemCommand)
Me.alignLeftCommand = New UICommand(Of Object)(New Action(Of Object)(AddressOf Me.OnAlignLeft), _
Function (ByVal arg As Object)
Return (MyBase.Active AndAlso (Me.designerItems.Count > 0))
End Function)
commandsProxy.AlignLeftCommand.RegisterCommand(Me.alignLeftCommand)
AddHandler MyBase.ActiveChanged, New EventHandler(AddressOf Me.OnActiveChanged)
移动设计器项
移动设计器项有点奇怪。任务第一次执行时实际上什么也没做,因为是用户用鼠标移动设计器项。但是,当移动被撤销然后再重做时,任务就会执行移动操作。我们执行任务的方式如下:
C#var task = new MoveDesignerHostTask();
var args = new MoveDesignerHostArgs(viewModel, startPoint, endPoint);
taskService.PerformTask(task, args, Id);
VB.NETDim task As New MoveDesignerHostTask
Dim taskService As ITaskService = ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService)
Dim args As New MoveDesignerHostArgs(viewModel, startPoint, endPoint)
taskService.PerformTask(Of MoveDesignerHostArgs)( _
DirectCast(task, UndoableTaskBase(Of MoveDesignerHostArgs)), args, MyBase.Id)
我们创建任务,收集任务执行和可能撤销任务所需的一些数据,然后将其馈送给 ITaskService
。
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)
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
Public Overrides ReadOnly Property DescriptionForUser As String
Get
Return "Move Item"
End Get
End Property
Private point As Point?
End Class
结论
在本文中,我们看到了任务模型如何集成到 WPF 应用程序中;特别是 Calcium 应用程序框架。我们看到了如何在简易的图表设计器中执行撤销、重做和重复操作,并研究了如何通过复合任务将多个任务(例如移动设计器项)作为组来执行和撤销。我们还研究了如何使用 Prism 的 DelegateCommands
和 CompositeCommands
的增强版本将任务服务操作连接到 UI。这样,我们就结束了这个两部分系列,希望您觉得它很有用。如果觉得有用,我将非常感谢您进行评分和/或在下方留下反馈。这将帮助我更好地改进我的下一篇文章。
历史记录
2010 年 3 月
首次发布。