理解命令
所谓的命令——并将控件连接到命令,从而不需要重复编写事件处理代码。更重要的是,当连接的命令不可用时,命令特性通过自动禁用控件来管理用户界面的状态。命令模型还为存储和本地化命令的文本标题提供了一个中心场所。
虽然WPF命令系统是一款简化应用程序设计的优秀工具,但仍有一些很重要的问题没有解决。特别是,WPF 对以下方面没有提供任何支持:
- 命令跟踪(例如,保留最近命令的历史)
- “可撤销的”命令
- 具有状态并可处于不同“模式”的命令(例如,可被打开或关闭的命令)
命令模型
WPF命令模型由许多可变的部分组成。总之,它们具有如下4个重要元素:
- 命令:命令表示应用程序任务,并且跟踪任务是否能够被执行,然而,命令实际上不包含执行应用程序任务的代码。
- 命令绑定:每个命令绑定针对用户界面的具体区域,将命令连接到相关的应用程序逻辑。这种分解的设计是非常重要的,因为单个命令可用于应用程序中的多个地方,并且在每个地方具有不同的意义。为处理这一问题,需要将同一命令与不同的命令绑定。
- 命令源:命令源触发命令。例如,MenuItem和Button都是命令元。单击它们都会执行绑定命令。
- 命令目标:命令目标是在其中执行命令的元素。例如,Paste命令可在TextBox控件中插入文本,而 OpenFile 命令可在 DocumentViewer 中打开文档。根据命令的本质,目标可能很重要,也可能不重要。
ICommand接口
WPF命令模型的核心时System.Windows.ICommand
接口,看接口定义了命令的工作原理。看接口包含两个方法和一个事件:
1 | public interface ICommand |
RoutedCommand类
当创建自己的命令时,不会直接实现ICommand接口;而是使用System.Windows.Input.RoutedCommand
类,该类自动实现了ICommand 接口。RoutedCommand 类是 WPF 中唯一实现了 ICommand 接口的类。换句话说,所有 WPF 命令都是 RoutedCommand 类及其派生类的实例。
RoutedCommand 类为事件冒泡和隧道添加了一些额外的基础结构。鉴于 ICommand 接口封装了命令的思想——可被触发的动作并可被启用或禁用——RoutedCommand 类对命令进行了修改,使命令可在 WPF 元素层次结构中冒泡,以便获得正确的事件处理程序。
为支持路由事件,RoutedCommand 类私有地实现了ICommand 接口,并添加了ICommand 按口方法的一些不同版本。最明显的变化是,Execute()和 CanExecute()方法使用了一个额外参数。下面是新的签名:
1 | public void Execute(object parameter,IInputElement target) |
参数 target 是开始处理事件的元素。事件从target 元素开始,然后冒泡至高层的容器,直到应用程序为了执行合适的任务而处理了事件(为了处理Executed 事件,元素还需要借助于另一个类–CommandBinding类的帮助)。
RoutedUICommand类
在程序中处理的大部分命令不是RoutedCommand 对象,而是 RoutedUICommand 类的实例,RoutedUICommand 类继承自 RoutedCommand 类(实际上,WPF 提供的所有预先构建好的命令都是 RoutedUICommand 对象)。
RoutedUICommand 类用于具有文本的命令,这些文本显示在用户界面中的某些地方(例如菜单项文本、工具栏按钮的工具提示)。RoutedUICommand 类只增加了 Text 属性,该属性是为命令显示的文本
。
为命令定义命令文本(而不是直接在控件上定义文本)的优点是可在某个位置执行本地化。但如果命令文本永远不会在用户界面的任何地方显示,那么RoutedUICommand 类和RoutedCommand 类是等效的
。
命令库
WPF 设计者认识到,每个应用程序可能都有大量命令,并且对于许多不同的应用程序,很多命令是通用的。例如,所有基于文档的应用程序都有它们自己版本的 New、Open 以及 Save命令。为减少创建这些命令所需的工作,WPF提供了基本命令库,基本命令库中保存的命令超过100条。这些命令通过以下5个专门的静态类的静态属性提供:
- ApplicationCommands:看类提供了通用命令,包括剪贴板命令(如
Copy
、Cut
和Paste
)以及文档命令(如New
、Open
、Save
、Save As
和Print
等) - NavigationCommands:该类提供了用于导航的命令,包括为基于页面的应用程序设计的一些命令(如
BrowseBack
、BrowseForward
和NextPage
),以及其他适合于基于文档的应用程序的命令(如IncreaseZoom
和Refresh
) - EditingCommands:该类提供了许多重要的文档编辑命令,包括用于移动的命令(
MoveToLineEnd
、MoveLeftByWord
和MoveUpByPage
等),选择内容的命令(SelectToLineEnd
、SelectLeftByWord
),以及改变格式的命令(ToggleBold
和ToggleUnderline
) - ComponentCommands:该类提供了由用户界面组件使用的命令,包括用于移动和选择内容的命令,这些命令和
EditingCommands
类中的一些命令类似(甚至完全相同) - MediaCommands:看类提供了一组用于处理多媒体的命令(如
Play
、Pause
、NextTrack
以及IncreaseVolume
)
ApplicationCommands 类提供了一组基本命令,在所有类型的应用程序中都经常会用到这些命令,所以在此简单介绍一下。下面列出了所有这些命令:
执行命令
命令源
命令库中的命令始终可用。触发它们的最简单方法是将它们关联到实现了ICommandSource接口的控件,其中包括继承自 ButtonBase 类的控件(Button和 CheckBox 等)、单独的 ListBoxItem对象、Hyperlink 以及 Menultem。
名称 | 说明 |
---|---|
Command | 指向连接的命令,这是唯一必要的细节 |
CommandParameter | 提供其他希望随命令发生的数据 |
CommandTarget | 确定将在其中执行命令的元素 |
下面的按钮使用Command属性连接到
ApplicationCommands.New
命令
1 | <Button Command="ApplicationCommands.New">New</Button> |
命令绑定
当将命令关联到命令源时,会感到一些有趣的现象。命令源将会被自动禁用
这是因为按钮已经查询了命令的状态,而且由于命令还没有与其关联的绑定,所以按钮被认为是禁用的,为改变这种现状,需要为命令创建绑定以明确以下三件事情:
- 当命令被触发时执行什么操作
- 如何确定命令是否能够被执行(这是可选的。如果未提供这一细节,只需要提供了关联的事件处理程序,命令总是可用)
- 命令在何处起作用。例如,命令可被限制在当个按钮中使用,或在整个窗口中使用(这种情况更常见)
下面的代码片段为 New命令创建绑定。可将这些代码添加到窗口的构造函数中:
1 | // Create the binding. |
注意,上面创建的 CommandBinding 对象被添加到包含窗口的 CommandBindings 集合中,这通过事件冒泡进行工作。实际上,当单击按钮时,CommandBinding.Executed 事件从按钮冒泡到包含元素。
尽管习惯上为窗口添加所有绑定,但 CommandBindings 属性实际是在 UIElement 基类中定义的。这意味着任何元素都支持该属性。例如,如果将命令绑定直接添加到使用它的按钮中,这个示例仍工作得很好(尽管不能再将该绑定重用于其他高级元素)。为得到最大的灵活性,命令绑定通常被添加到顶级窗口。如果希望在多个窗口中使用相同的命令,需要在这些窗口中分别创建命令绑定。
上面的示例中,使用代码生成了命令绑定。然而,如果希望精简代码隐藏文件,使用XAML以声明方式关联命令同样很容易。下面是所需的标记:
1 | <Window x:Class="WpfApp.MainWindow" |
使用多命令源
按钮示例中触发普通事件的方式看起来不那么直接。然而,当添加使用相同命令的更多控件时,额外命令层的意义就会体现出来。例如,可添加如下也使用New命令的菜单项:
1 | <Menu> |
注意,New 命令的这个 Menultem 对象没有设置 Header 属性。这是因为 Menultem 类足够智能,如果没有设置 Header属性,它将从命令中提取文本(Button 控件不具有这一特性)
。虽然该特性带来的便利看起来不大,但如果计划使用不同的语言本地化应用程序,这一特性就很重要了。在这种情况下,只需要在一个地方修改文本即可(通过设置命令的 Text 属性),这比在整个窗口中进行跟踪更容易。
Menultem类还有一项功能:能自动提取Command.InputBindings 集合中的第一个快捷键(如果存在的话)
。对于 ApplicationCommands.New 命令对象,这意味着在菜单文本的旁边会显示Ctrl+N 快捷键
微调命令文本
既然菜单具有自动提取命令项文本的功能,您可能会好奇其他ICommandSource 类是否也具有类似功能,如 Button 控件。
可以,但需要完成一点额外的工作。可使用两种技术重用命令文本。一种选择是直接从静态命令对象中提取文本。XAML可使用 Static 标记扩展完成这一任务。下面的示例获取命令名 New,并将它作为按钮的文本:
1 | <!-- 这种方式只是调用命令对象的ToString()方法,因此,得到的是命令名,而不是命令的文本 --> |
直接调用命令
并非只能实现了ICommandSource
接口的类来触发希望执行的命令。也可以用Execute()
方法直接调用来自任何事件处理程序的方法。这是需要传递参数值(或null引用)对目标元素的引用:
1 | // 直接触发New命令绑定的命令 |
禁用命令
现在需要从窗口向命令绑定传递信息,使链接的控件可根据需要进行更新。技巧是处理命令绑定的 CanExecute 事件。可通过下面的代码为该事件关联事件处理程序:
1 | <Grid> |
1 | public MainWindow() |
具有内置命令的控件
一些输入控件可自行处理命令事件。例如,TextBox类处理Cut、Copy 以及 Paste 命令(还有 Undo、Redo 命令,以及一些来自 EditingCommand 类的用于选择文本以及将光标移到不同位置的命令)。
当控件具有自己的硬编码命令逻辑时,为使命令工作不需要做其他任何事情。例如,对于下图所示的简单文本编辑器,添加以下工具栏按钮,就会自动获得对剪切、复制和粘贴文本的支持:
1 | <Grid> |
如果在不同容器(不是 ToolBar 或 Menu 控件)中放置按钮,就不会获得这项优势。这意味着除非手动设置 CommandTarget属性,否则按钮不能工作。
为此,必须使用命名目标元素的绑定表达式。例如,如果文本框被命名为txtDocument,就应该像下面这样定义按钮:
1 | <StackPanel Orientation="Horizontal" Grid.Row="1"> |
另一个较简单的选择是使用附加属性FocusManager.IsFocusScope
创建新的焦点范围。当触发命令时,看焦点范围会通知WPF在父元素的焦点范围内查找元素:
1 | <StackPanel Orientation="Horizontal" Grid.Row="1" FocusManager.IsFocusScope="True"> |
该方法还有一个附加优点,即相同的命令可应用于多个控件,不像上个示例那样对CommandTarget 进行硬编码。此外,Menu和ToolBar 控件默认将FocusManager.IsFocusScope属性设置为 true,但如果希望简化命令路由行为,不在父元素上下文中查找具有焦点的元素,也可将该属性设为false。
在极少数情况下如果希望禁用控件的内置命令可以采用以下三种方式:
-
理想情况下,控件会提供关闭命令支持的属性,例如TextBox控件提供了
IsUndoEnabled
属性,为阻止Undo
特性,可将其设置为false -
为希望禁用的命令添加新的命令绑定,然后命令绑定可提供新的
CanExecute
事件处理程序,并总是响应false1
2
3
4
5
6
7
8
9
10
11
12
13
14public MainWindow()
{
InitializeComponent();
var binding = new CommandBinding(ApplicationCommands.Paste);
binding.CanExecute += PasteCommand_CanExecute;
txtDocument.CommandBindings.Add(binding);
}
private void PasteCommand_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = false; // 总是禁用Paste功能
e.Handled = true; // 阻止事件冒泡
} -
使用
InputBinding
集合删除触发命令的输入。例如,可使用代码禁用触发TextBox控件中的Copy
命令的Ctrl+C
组合键,如下所示:1
2
3
4
5
6
7// 技巧是使用特定的ApplicationCommands.NotACommand值,该命令什么都不做,它专门用于禁用输入绑定
// 注意:这种方式只是禁用了Ctrl+C快捷键,仍然可以通过ContextMenu触发Copy命令,除非将ContextMenu属性设置为null删除删除上下文
var keyBinding = new KeyBinding(
ApplicationCommands.NotACommand,
Key.C,
ModifierKeys.Control);
txtDocument.InputBindings.Add(keyBinding);
高级命令
自定义命令
在5个命令类(ApplicationCommands
、NavigationCommands
、EditingCommands
、ComponentCommands
以及MediaCommands
)中存储的命令,显然不会为应用程序提供所有可能需要的命令。幸运的是很方便地自定义命令,需要做的全部工作就是实例化一个新的RoutedUICommand
对象。
RoutedUICommand
类提供了几个构造函数。虽然可创建没有任何附加信息的RoutedUICommand
对象,但几乎总是希望提供命令名、命令文本以及所属类型。此外,可能希望为 InputGestures
集合提供快捷键。
最佳设计方式是遵循WPF库中的范例,并通过静态属性提供自定义命令。下面的示例定义了名为Requery的命令:
1 | public class DataCommands |
1 | <Window x:Class="WpfApp.MainWindow" |
当使用自定义命令时,可能需要调用静态的CommandManager.InvalidateRequerySuggested()
方法,通知 WPF 重新评估命令的状态。然后 WPF会触发 CanExecute
事件,并更新使用该命令的任意命令源。
在不同位置使用相同的命令
在 WPF 命令模型中,一个重要概念是范围(scope)。尽管每个命令仅有一份副本,但使用命令的效果却会根据触发命令的位置而异。例如,如果有两个文本框,它们都支持Cut、Copy 和Paste 命令,操作只会在当前具有焦点的文本框中发生。
1 | <Window.Resources> |
使用命令参数
通过设置CommandParameter
属性。可直接为ICOmmandSource
控件设置看属性(甚至可使用绑定表达式成其他控件获取值)。例如,下面的代码演示了如何通过成另一个文本框中读取数值,为链接到Zoom命令的按钮设置缩放百分比:
1 | <Button Command="NavigationCOmmands.Zoom" |
但该方法并不总是有效。例如,在具有两个文件的文本编辑器中,每个文本框重用同一个Save 按钮,但每个文本框需要使用不同的文件名。对于此类情况,必须在其他地方存储信息(例如,在 TextBox,Tag属性或在为区分文本框而索引文件名称的单独集合中存储信息),或者需要通过代码触发命令,如下所示:
1 | ApplicationCommands.New.Execute(theFileName,(Button)sender); |
跟踪和翻转命令
WPF 命令模型缺少的一个特性是翻转命令。尽管提供了 ApplicationCommands.Undo 命令但该命令通常用于编辑控件(如 TextBox 控件)以维护它们自己的 Undo 历史。如果希望支持应用程序范围内的 Undo 特性,需要在内部跟踪以前的状态,并当触发 Undo 命令时还原该状态。
遗憾的是,扩展 WPF 命令系统并不容易。相对来说没几个入口点可用于连接自定义逻辑,并且对于可用的几个入口点也没有提供说明文档。为创建通用的、可重用的 Undo 特性,需要创建一组全新的“能够撤销的”命令类,以及一个特定类型的命令绑定。本质上,必须使用自己创建的新命令系统替换 WPF 命令系统。
更好的解决方案是设计自己的用于跟踪和翻转命令的系统,但使用CommandManager 类保存命令历史。下图显示了一个这方面的例子。在该例中,窗口包含两个文本框和一个列表框,可以自由地在这两个文本框中输入内容,而列表框则一直跟踪在这两个文本框中发生的所有命令。可通过单击 Reverse Last Commmand 按钮翻转最后一个命令。
为构建这个解决方案,需要使用几项新技术。第一个细节是用于跟踪命令历史的类。为构建保存最近命令历史的撤销系统,可能需要用到这样的类(甚至可能喜欢创建派生的ReversibleCommand
类,提供诸如 Unexecute()
的方法来翻转以前的任务)。但该系统不能工作,因为所有 WPF 命令都是唯一的。这意味着在应用程序中每个命令只有一个实例。
为理解该问题,假设提供 EditingCommands.Backspace
命令,而且用户在一行中回退了几个空格。可通过向最近命令堆栈中添加 Backspace
命令来记录这一操作,但实际上每次添加的是相同的命令对象。因此,没有简单的方法用于存储命令的其他信息,例如刚刚删除的字符如果希望存储该状态,需要构建自己的数据结构。该例使用名为CommandHistoryltem
的类。
每个CommandHistoryItem
对象跟踪以下几部分信息:
- 命令名称
- 执行命令的元素。在该例中,有两个文本框,所以可以是其中的任意一个
- 在目标元素中被改变了的属性。在该例中是TextBox类的Text属性
- 可用于保存受影响元素以前的对象(例如,执行命令之前文本框中的文本)
可以为元素存储状态。如果存储整个窗口状态的快照,那么会显著增这一设计非常巧妙,加内存的使用量。然而,如果具有大量数据(比如文本框有几十行文本),Undo
操作的负担就很大了。解决方法是限制在历史中存储的项的数量,或使用更加智能(也更复杂)的方法只存储被改变的数据的信息,而不是存储所有数据。
CommandHistoryItem 类还提供了通用的 Undo()方法。该方法使用反射为修改过的属性应用以前的值,用于恢复TextBox控件中的文本。但对于更复杂的应用程序,需要使用CommandHistoryItem 类的层次结构,每个类都可以使用不同方式翻转不同类型的操作。
需要的下一个要素是执行应用程序范围内 Undo操作的命令。ApplicationCommands.Undo
命令是不适合的,原因是为了达到不同的目的,它已经被用于单独的文本框控件(翻转最后的编辑变化)。相反,需要创建一个新命令,如下MonitorCommands
所示:
1 | public class CommandHistoryItem |
1 | public class MonitorCommands |
1 | <Window x:Class="WpfApp.MainWindow" |
1 | public partial class MainWindow : Window |
到目前为止,除了执行 Undo 操作的反射代码比较有意义外,其他代码没有什么值得注意的地方。更困难的部分是将该命令历史集成进 WPF 命令模型中。理想的解决方案是使用能跟踪任意命令的方式完成该任务,而不管命令是被如何触发和绑定的。相对不理想的解决方案是,强制依赖于一整套全新的自定义命令对象(这一逻辑功能内置到这些自定义命令对象中),或手动处理每个命令的 Executed 事件。
响应特定的命令是非常简单的,但当执行任何命令时如何进行响应呢?技巧是使用CommandManager类,该类提供了几个静态事件。这些事件包括 CanExecute
、PreviewCanExecute
、Executed
以及 PreviewExecuted
。在该例中,Executed
和 PreviewExecuted
事件最有趣,因为每当执行任何一个命令时都会引发它们。
尽管 CommandManger
类挂起了 Executed
事件,但仍可使用 UIElement.AddHandler( )
方法关联事件处理程序,并为可选的第三个参数传递true值。这样将允许接收事件,即使事件已经被处理过也同样如此,如第4章所述。然而,Bxecuted 事件是在命令执行完之后被触发的,这时已经来不及在命令历史中保存被影响的控件的状态了。相反,需要响应 PreviewExecuted
事件,该事件在命令执行前一刻被触发。
当触发 PreviewExecuted
事件时,需要确定准备执行的命令是否是我们所关心的。如果是可创建 CommandHistoryItem对象,并将其添加到Undo 堆栈中。还需要注意两个潜在问题。第一个问题是,当单击工具栏按钮以在文本框上执行命令时,CommandExecuted 事件被引发了两次–一次是针对工具栏按钮,另一次是针对文本框。下面的代码通过忽略发送者是ICommandSource 的命令,避免在 Undo 历史中重复条目。第二个问题是,需要明确忽略不希望添加到 Undo 历史中的命令。例如 ApplicationUndo 命令,通过该命令可翻转上一步操作。
该例在 ListBox 控件中存储所有 CommandHistoryItem对象。ListBox 控件的 DisplayMemberPath 属因而会显示每个条目的 CommandHistoryItem.CommandName
属性。上面的代码只为性被设置为CommandName,由文本框引发的命令提供 Undo 特性。然而,处理窗口中的任何文本框通常就足够了。为了支持其他控件和属性,需要对代码进行扩展。
最后一个细节是执行应用程序范围内 Undo 操作的代码。使用CanExecute 事件处理程序可确保只有当在 Undo 历史中至少有一项时,才能执行此代码:
总结
尽管该示例程序演示了相关概念,并提供了一个简单的应用程序,且该应用程序具有几个完全支持 Undo 特性的控件,但要在实际应用程序中使用这一方法,还需要进行许多改进。例如,需要耗费大量时间改进 CommandManager.PreviewExecuted
事件的处理程序,以忽略那些明显不需要跟踪的命令(当前,诸如使用键盘选择文本的事件以及单击空格键引发的命令等)。类似地,可能希望为那些不是由命令表示的但应当被翻转的操作添加 CommandHistoryltem 对象。例如,输入一些文本,然后导航到其他控件。最后,可能希望将 Undo 历史限制在最近执行的命令范用之内。