深入浅出WPF的命令系统

1.什么是命令?

我们通过一个场景来说明这个问题。假设某天某个时间点,公司领导对小王说:“小王,去前台帮我取一下快递。”这里,领导对小王所说的话就可以理解为命令,简而言之,命令就是一段信息,那为什么还要牵扯出“领导”和“小王”呢?那是因为他们是和命令相关的且不可或缺的部分,他们是产生命令(命令源)和处理命令(命令目标)的人。与之类似,WPF中的命令系统也有这些元素,WPF中的命令模型可以分解为四个主要概念:ICommand,ICommandSource,命令目标及CommandBinding。

ICommand

命令,表示要执行的操作。

WPF 中的命令是通过实现 ICommand 接口创建的。 ICommand 公开了两种方法 Execute 和 CanExecute,以及一个事件 CanExecuteChanged。 Execute 执行与该命令关联的操作。 CanExecute 确定是否可以在当前命令目标上执行该命令。如果集中管理命令操作的命令管理器检测到命令源中存在一个可能使已引发命令无效但尚未由命令绑定执行的更改,则会引发 CanExecuteChanged。

ICommand在WPF中的默认实现是 RoutedCommand 和RoutedUICommand。

ICommandSource

命令源是产生命令或调用命令的对象。WPF中的命令源通常实现 ICommandSource接口,常见的有Button、MenuItem等。其定义如下:

 1 public interface ICommandSource
 2 {
 3     ICommand Command
 4     {
 5         get;
 6     }
 7 
 8     object CommandParameter
 9     {
10         get;
11     }
12 
13     IInputElement CommandTarget
14     {
15         get;
16     }
17 }

其中:

Command就是要执行的命令;

CommandParameter是用于将信息传递给命令执行程序的数据类;

CommandTarget即要在其上执行命令的命令目标,其必须是实现IInputElement的对象。

命令目标

在其上执行命令的对象。命令目标没有命令源那样有个顾名思义的约束(ICommandSource),但是命令目标必须实现IInputElement接口。

CommandBinding

是将命令逻辑映射到命令的对象。

CommandBinding公开了四个事件PreviewExecuted,Executed,PreviewCanExecute,CanExecute和两个方法OnCanExecute,OnExecuted,其中OnCanExecute方法会触发PreviewCanExecute和CanExecute事件,而OnExecuted方法则会触发PreviewExecuted和Executed事件。

是WPF专门为RoutedCommand提供的。

2.命令的用途

简单来说,命令有两个用途:

1、将命令发出者和命令执行者分开,这样做的好处是命令发出者可以将同一个命令传递给不同的命令执行者;

2、指示发出命令的操作是否可以执行。仍然是前面的场景,当领导发出命令后,小王认为自己没空或者不想去跑腿,对领导说:“你给我闭嘴!”(小王家里可能有矿),那么命令就不会被执行,对应到WPF中,假定ButtonA关联了命令A,那么当该命令A不可执行时(由命令系统判定),ButtonA会表现为禁用状态,即命令源无法发出命令。

3.如何使用命令?

以RoutedCommand为例。

Xaml中的实现

 1 <Window.Resources>
 2     <RoutedCommand x:Key="sampleCmd"/>
 3 </Window.Resources>
 4 <Window.CommandBindings>
 5     <CommandBinding Command="{StaticResource sampleCmd}" CanExecute="OnSampleCommandCanExecuted" Executed="OnSampleCommandExecuted"/>
 6 </Window.CommandBindings>
 7 <Grid>
 8     <Button x:Name="sampleButton"
 9             Content="sample button"
10             Command="{StaticResource sampleCmd}"/>
11 </Grid>

用C#来实现就是:

1 2 RoutedCommand sampleCmd = new RoutedCommand();
3 CommandBinding cmdBindng = new CommandBinding(sampleCmd, OnSampleCommandExecuted, OnSampleCommandCanExecuted);
4 CommandBindings.Add(cmdBindng);
5 sampleButton.Command = sampleCmd;
6
1 private void OnSampleCommandExecuted(object sender, ExecutedRoutedEventArgs e)
2 {
3     MessageBox.Show("Sample Button Clicked."); 
4 }
5 
6 private void OnSampleCommandCanExecuted(object sender, CanExecuteRoutedEventArgs e)
7 {
8     e.CanExecute = true;
9 }

4.命令是如何执行的?

下面以Button控件为例,来说明命令是如何执行的。我们知道,当Button被按下时,会调用Click方法,其实现如下:

1 protected virtual void OnClick()
2 {
3     RoutedEventArgs e = new RoutedEventArgs(ClickEvent, this);
4     RaiseEvent(e);
5     CommandHelpers.ExecuteCommandSource(this);
6 }

lick方法会先触发ClickedEvent路由事件,接着才通过CommandHelpers工具类进入命令调用逻辑:

 1 internal static void ExecuteCommandSource(ICommandSource commandSource)
 2 {
 3  4     object commandParameter = commandSource.CommandParameter;
 5     IInputElement inputElement = commandSource.CommandTarget;
 6     RoutedCommand routedCommand = command as RoutedCommand;
 7     if (routedCommand != null)
 8     {
 9         if (inputElement == null)
10         {
11              inputElement = (commandSource as IInputElement);
12         }
13 
14         if (routedCommand.CanExecute(commandParameter, inputElement))
15         {
16             routedCommand.ExecuteCore(commandParameter, inputElement, userInitiated);
17         }
18     }
19     else if (command.CanExecute(commandParameter))
20     {
21         command.Execute(commandParameter);
22     }
23 }

在ExecuteCommandSource方法中,会首先判断命令源发起的命令是否是RoutedCommand命令,如果不是,则直接调用ICommand接口的Execute方法,并将命令源的CommandParameter作为参数传入该方法,简单明了;而如果是RoutedCommand,下一步会指定命令目标,命令目标的指定逻辑如下:

Step1、如果传入的命令源中的命令目标有效,则以该命令目标作为最终的命令目标;

Step2、如果命令源中的命令目标无效,则以命令源作为命令目标;

Step3、如果命令源不是合法的命令目标,则以当前获得焦点的对象作为命令目标;

命令目标确定后,会在命令目标上先后触发CommandManager.PreviewExecutedEvent和CommandManager.ExecutedEvent事件,具体代码如下:

 1 internal bool ExecuteCore(object parameter, IInputElement target, bool userInitiated)
 2 {
 3     if (target == null)
 4     {
 5         target = FilterInputElement(Keyboard.FocusedElement);
 6     }
 7 
 8     return ExecuteImpl(parameter, target, userInitiated);
 9 }
10 
11 private bool ExecuteImpl(object parameter, IInputElement target, bool userInitiated)
12 {
13     if (target != null && !IsBlockedByRM)
14     {
15         UIElement uIElement = target as UIElement;
16         ContentElement contentElement = null;
17         UIElement3D uIElement3D = null;
18         ExecutedRoutedEventArgs executedRoutedEventArgs = new ExecutedRoutedEventArgs(this, parameter);
19         executedRoutedEventArgs.RoutedEvent = CommandManager.PreviewExecutedEvent;
20         if (uIElement != null)
21         {
22             uIElement.RaiseEvent(executedRoutedEventArgs, userInitiated);
23         }
24         else
25         {
26             ...
27         }
28 
29         if (!executedRoutedEventArgs.Handled)
30         {
31             executedRoutedEventArgs.RoutedEvent = CommandManager.ExecutedEvent;
32             if (uIElement != null)
33             {
34                 uIElement.RaiseEvent(executedRoutedEventArgs, userInitiated);
35             }
36             ...
37         }
38 
39         return executedRoutedEventArgs.Handled;
40     }
41 
42     return false;
43 }

从进入Button.OnClick开始就遇到了RaiseEvent,现在又遇到了,那这个方法到底做了什么是呢?长话短说,RaiseEvent的职责是构建指定事件的路由路径,并按照路由路径执行此事件。构建方法就是从元素树中查找某个元素(IInputElement)是否需要处理指定的路由事件(这里就是CommandManager.PreviewExecutedEvent或CommandManager.ExecutedEvent事件),如果某个元素需要处理指定的路由事件,那么这个元素将会被添加到路由路径中去。

那是如何做到让某个对象与某个路由事件关联的呢?可以通过两种方式:

方式一:调用IInputElement接口中的AddHandler(RoutedEvent routedEvent, Delegate handler)方法;

方式二:通过EventManager静态工具类提供的路由事件注册方法;

EventManager提供用于为类型映射路由事件处理器的方法,且经由EventManager映射的路由事件处理程序会在经由IInputElement.AddHandler方法映射的路由事件处理程序之前被调用,这是由IInputElement的实现类决定的,以UIElement为例,UIElement在构建事件的路由路径时,会先去匹配经由EventManager工具类映射的路由事件处理程序,其次才去匹配经由IInputElement实例映射的。

事件的路由路径构建好之后,RaiseEvent方法接下来会沿着路由路径一一执行该事件的事件处理程序。到此,一个事件的触发、执行便完成了。

命令成功执行!有没有疑惑?有!

疑惑1.按照上述的命令使用方法,命令是通过CommandBinding与其处理程序进行关联的,但如果去查看CommandBinding的实现,CommandBinding并没有通过上述的两个方式注册路由事件的处理程序;另外,上述的代码也没有显式的注册CommandManager.PreviewExecutedEvent或CommandManager.ExecutedEvent事件,命令怎么就被执行了呢?

疑惑2.即便某个对象隐式的注册了上述两个事件,那当事件被触发时,也是应当通过调用CommandBinding公开的方法来执行事件处理程序才对,但并没有发现UIElement中有调用CommandBinding的地方!

不急,接下来一 一解惑!

对于疑惑1,假定WPF仍然是通过路由事件的方式执行了命令,那么在此之前提到了注册路由事件的两种方式,不妨在UIElement中查找一下是否有注册过这两个事件,哎,果然,在其RegisterEvents(Type type)方法中对这两个事件进行了注册:

 1 internal static void RegisterEvents(Type type)
 2 {
 3     ...
 4     EventManager.RegisterClassHandler(type, CommandManager.PreviewExecutedEvent, new ExecutedRoutedEventHandler(OnPreviewExecutedThunk), handledEventsToo: false);
 5     EventManager.RegisterClassHandler(type, CommandManager.ExecutedEvent, new ExecutedRoutedEventHandler(OnExecutedThunk), handledEventsToo: false);
 6     ...
 7 }
 8 private static void OnExecutedThunk(object sender, ExecutedRoutedEventArgs e)
 9 {
10     ...
11     CommandManager.OnExecuted(sender, e);
12 }

好了,第一个疑点真相大白!即UIElement的静态构造函数通过调用其RegisterEvents方法,为UIElement类型注册了上述两个事件,而Button是UIElement的派生类,自然也就适用了。

对于疑惑2,能想到的直接办法就是看一下谁调用了CommandBinding公开的OnExecuted方法。通过ILSpy反编译工具,可以看到

可以看到,UIElement.OnExecutedThunk方法通过一系列调用最终执行了CommandBinding.OnExecuted方法。

OnExecutedThunk方法看着有点眼熟,对了,在说明疑惑1的时候提到此方法是被作为事件处理器给注册到了CommandManager.ExecutedEvent事件。哦,原来疑点2和疑点1是殊途同归啊!

好了,到这里对(路由)命令的执行过程应该有了一个清晰的了解。接下来说明下上文“命令用途”中提到的“指示发出命令的操作是否可以执行”,WPF命令系统是如何做到这一点的?是时候请出CommandManager了!

CommandManager

CommandManager提供了一组静态方法,用于在特定元素中添加和移除PreviewExecuted、Executed、PreviewCanExecute及CanExecute事件的处理程序。 它还提供了将 CommandBinding 和 InputBinding 对象注册到特定类的方法。除此之外,CommandManager还通过RequerySuggested事件提供了一种方法,用于通知Command触发其CanExecuteChanged事件。

而CommandManager提供的InvalidateRequerySuggested方法可以强制 CommandManager 引发 RequerySuggested事件,换句话说,调用InvalidateRequerySuggested方法就可以通知指定的Command触发其CanExecuteChanged事件。

那CanExecuteChanged事件又做了什么呢?不妨看看RoutedCommand中的实现:

 1 public class RoutedCommand : ICommand
 2 {
 3     ...
 4 
 5     public event EventHandler CanExecuteChanged
 6     {
 7         add
 8         {
 9             CommandManager.RequerySuggested += value;
10         }
11         remove
12         {
13             CommandManager.RequerySuggested -= value;
14         }
15     }
16     
17     ...
18 }

可以看到,RoutedCommand中的CanExecuteChanged事件不过是对CommandManager中RequerySuggested事件的封装,即注册到CanExecuteChanged的事件处理程序实际是注册到了CommandManager的RequerySuggested事件上。所以当通过调用CommandManager的InvalidateRequerySuggested方法来引发RequerySuggested事件时就会调用Command的CanExecuteChanged事件处理程序。然而,在使用RoutedCommand时,不用为其CanExecuteChanged指定事件处理程序,只消为关联的CommandBinding中的CanExecute指定事件处理器,命令源的状态就会按照CanExecute的事件处理器中的逻辑进行更新。是不是有点奇怪?既然是按照CanExecute中的逻辑来更新命令源的状态,那说明CanExecute事件被引发了,是被谁引发的呢?别着急,马上揭晓答案!

既然Command能够影响命令源的状态,那就追本溯源,来看看命令源中和它的Command属性,以Button为例,可以看到Button的CommandProperty依赖属性在初始化时被指定了一个名为OnCommandChanged的回调函数,

1 CommandProperty = DependencyProperty.Register("Command", typeof(ICommand), typeof(ButtonBase), new FrameworkPropertyMetadata(null, OnCommandChanged));

其实现如下:

 1 private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
 2 {
 3     ButtonBase buttonBase = (ButtonBase)d;
 4     buttonBase.OnCommandChanged((ICommand)e.OldValue, (ICommand)e.NewValue);
 5 }
 6 
 7 private void OnCommandChanged(ICommand oldCommand, ICommand newCommand)
 8 {
 9 10     if (newCommand != null)
11     {
12         HookCommand(newCommand);
13     }
14 }
15 
16 private void HookCommand(ICommand command)
17 {
18     CanExecuteChangedEventManager.AddHandler(command, OnCanExecuteChanged);
19     UpdateCanExecute();
20 }
21 
22 private void OnCanExecuteChanged(object sender, EventArgs e)
23 {
24     UpdateCanExecute();
25 }
26 
27 private void UpdateCanExecute()
28 {
29     if (Command != null)
30     {
31         CanExecute = CommandHelpers.CanExecuteCommandSource(this);
32     }
33     else
34     {
35         CanExecute = true;
36     }
37 }

最后一个方法UpdateCanExecute中的CanExecute属性与Button的IsEnabled属性关联,CommandHelpers.CanExecuteCommandSource方法实现如下:

 1 internal static bool CanExecuteCommandSource(ICommandSource commandSource)
 2 {
 3     ICommand command = commandSource.Command;
 4     if (command != null)
 5     {
 6         object commandParameter = commandSource.CommandParameter;
 7         IInputElement inputElement = commandSource.CommandTarget;
 8         RoutedCommand routedCommand = command as RoutedCommand;
 9         if (routedCommand != null)
10         {
11             if (inputElement == null)
12             {
13                 inputElement = (commandSource as IInputElement);
14             }
15 
16             return routedCommand.CanExecute(commandParameter, inputElement);
17         }
18 
19         return command.CanExecute(commandParameter);
20     }
21 
22     return false;
23 }

嗯,是不是有点内味了?

当Button的Command属性值发生改变时,HookCommand方法通过调用UpdateCanExecute方法调用了CommandHelpers的CanExecuteCommandSource方法,而后者会调用RoutedCommand中的CanExecute方法(如果Command非RoutedCommand类型,则直接调用ICommand接口的CanExecute方法),此CanExecute方法会引发CommandManager.CanExecuteEvent路由事件,最终事件被与此命令关联的CommandBinding中的CanExecute事件处理器捕获并处理,CanExecute的事件处理器中有刷新命令源状态的逻辑。

但是,请注意,这只是在Button的Command属性值发生变化时刷新Button的状态,那Command属性没有改变时,是如何刷新状态的呢?玄机还在HookCommand中,它会将UpdateCanExecute方法与CanExecuteChangedEventManager类进行关联,下面就此关联方法稍微展开说明一下:

我们看到在HookCommand方法中调用了

CanExecuteChangedEventManager.AddHandler(command, OnCanExecuteChanged);

而传入的第二个参数OnCanExecuteChanged实则可以等效于在调用ICommand.CanExecute(object)方法,关键还在此处的AddHandler方法,其实现如下:

 1 public static void AddHandler(ICommand source, EventHandler<EventArgs> handler)
 2 {
 3     ...
 4 
 5     PrivateAddHandler(source, handler);
 6 }
 7 
 8 private void PrivateAddHandler(ICommand source, EventHandler<EventArgs> handler)
 9 {
10     List<HandlerSink> list = (List<HandlerSink>)base[source];
11     if (list == null)
12     {
13         list = (List<HandlerSink>)(base[source] = new List<HandlerSink>());
14     }
15 
16     HandlerSink item = new HandlerSink(this, source, handler);
17     list.Add(item);
18     AddHandlerToCWT(handler, _cwt);
19 }
20 
21 public HandlerSink(CanExecuteChangedEventManager manager, ICommand source, EventHandler<EventArgs> originalHandler)
22 {
23     _manager = manager;
24     _source = new WeakReference(source);
25     _originalHandler = new WeakReference(originalHandler);
26     _onCanExecuteChangedHandler = OnCanExecuteChanged;
27     source.CanExecuteChanged += _onCanExecuteChangedHandler;
28 }
29 
30 private void OnCanExecuteChanged(object sender, EventArgs e)
31 {
32     ...
33     
34     EventHandler<EventArgs> eventHandler = (EventHandler<EventArgs>)_originalHandler.Target;
35     if (eventHandler != null)
36     {
37         eventHandler(sender, e);
38     }
39     else
40     {
41         _manager.ScheduleCleanup();
42     }
43 }

图方便,我把和AddHandler方法相关的代码都贴出来了。通过上述代码可以看到,AddHandler最终将传入的第二参数handler(等效于调用ICommand.CanExecute(object))方法传递给了一个HandlerSink实列的构造函数,而通过对此构造函数的进一步观察,可以发现在此构造函数内,HandlerSink订阅了ICommand.CanExecuteChanged事件,而事件处理器OnCanExecuteChanged做的事情其实就是调用构造函数传入的参数originalHandler,稍稍向上翻一下代码就可以看到AddHandler就是将其第二参数赋值给了这个originalHandler形式参数,简而言之,HandlerSink的构造函数将ICommand.CanExecute(object)方法绑定到了ICommand.CanExecuteChanged事件上,通过HandlerSink,RoutedCommand形成了一个闭环。因此当CommandManager.RequerySuggested事件被触发时,就会调用ICommand.CanExecute(object)方法。

在这里对命令源状态更新方法稍微做个总结:

首先是通过对CommandManager的RequerySuggested事件的封装将RoutedCommand的CanExecuteChanged与CommandManager的InvalidateRequerySuggested方法关联,这样当调用InvalidateRequerySuggested方法时会引发RoutedCommand的CanExecuteChanged事件;

其次,通过ICommandSource的Command属性将其CanExecuteChanged事件与关联的CommandBinding的CanExecute事件(或与自定义命令的CanExecute方法)关联;

这样一来,当调用CommandManager的InvalidateRequerySuggested方法时,最终会调用指定命令的关联CommandBinding中的CanExecute事件(或指定自定义命令的CanExecute方法)。

到这里,更新命令源的路径基本通了,但是,万事俱备只欠东风!是谁在什么时候调用了CommandManager的InvalidateRequerySuggested方法呢?

通过ILSpy来简单看一下:

 

 居然有这么多调用者。那就来看看第一处调用吧:

 1 // System.Windows.Input.CommandDevice
 2 private void PostProcessInput(object sender, ProcessInputEventArgs e)
 3 {
 4     if (e.StagingItem.Input.RoutedEvent == InputManager.InputReportEvent)
 5     {
 6         ...
 7     }
 8     else if (e.StagingItem.Input.RoutedEvent == Keyboard.KeyUpEvent || e.StagingItem.Input.RoutedEvent == Mouse.MouseUpEvent || e.StagingItem.Input.RoutedEvent == Keyboard.GotKeyboardFocusEvent || e.StagingItem.Input.RoutedEvent == Keyboard.LostKeyboardFocusEvent)
 9     {
10         CommandManager.InvalidateRequerySuggested();
11     }
12 }

可以看到,当键盘、鼠标事件发生时,都会引发命令源状态更新。

到此,关于CommandManager及如何更新命令源状态的讲述告一段落,接下来说说如何自定义命令。

5.自定义命令

看到此处,应该对WPF自带的RoutedCommand有了比较深入的理解,RoutedCommand本质是路由事件,它的执行不可避免的要经历发起路由事件、构建路由路径、沿路由路径执行命令处理程序等这一复杂的流程,所以当我们在使用命令时,可能就会面临两种选择:

1、我的命令需要进行路由;

2、我的命令不需要路由,我想让它轻装上阵;

针对上述两种情况,自然就有了两种自定义命令的方式:

方式一:命令需要路由,那么可以直接实例化RoutedCommand或RoutedUICommand,或扩展这两个实现。

下面主要就方式二进行展开。

ICommand接口包含一个事件CanExecuteChanged和两个方法CanExecute和Execute,

1 public interface ICommand
2 {
3     event EventHandler CanExecuteChanged;
4     bool CanExecute(object parameter);
5     void Execute(object parameter);
6 }

前文有提及,执行命令时,当命令不是RoutedCommand类型时,WPF会先调用ICommand接口的CanExecute方法,判断此时命令是否可以执行,然后再调用其Execute方法,命令的执行就完成了。所以在实现ICommand接口时,只要派生类的Execute方法能处理命令的操作即可。

直接上代码:

 1 class MyCommand : ICommand
 2 {
 3     private readonly Predicate<object> _canExecuteMethod;
 4     private readonly Action<object> _executeMethod;
 5 
 6     public event EventHandler CanExecuteChanged
 7     {
 8         add
 9         {
10             CommandManager.RequerySuggested += value;
11         }
12         remove
13         {
14             CommandManager.RequerySuggested -= value;
15         }
16     }
17 
18     public MyCommand(Action<object> executeMethod, Predicate<object> canExecuteMethod = null)
19     {
20         _executeMethod = executeMethod ?? throw new ArgumentNullException(nameof(executeMethod));
21         _canExecuteMethod = canExecuteMethod;
22     }
23 
24     public bool CanExecute(object parameter)
25     {
26         if (_canExecuteMethod != null)
27         {
28             return _canExecuteMethod(parameter);
29         }
30         return true;
31     }
32 
33     public void Execute(object parameter)
34     {
35         _executeMethod(parameter);
36     }
37 }

总结

  1. 命令系统包含ICommand,ICommandSource,命令目标及CommandBinding 四个基本要素,但是ICommandSource中的CommandTarget属性只在命令是RoutedCommand时才有用,否则在命令执行时会被直接忽略;
  2. RoutedCommand顾名思义,其本质还是路由事件,但它只负责发起路由事件,并不执行命令逻辑,命令逻辑是由与具体命令关联的CommandBinding来执行的;
  3. 由于RoutedCommand是基于路由事件的,因此其发起路由事件、构建路由路径、沿路由路径执行命令处理程序等这一复杂的流程势必会对执行效率产生不好的影响,所以如果不需要命令进行路由,可以构建简单的自定义命令。
  4. 自定义命令时,如果希望通过命令系统来改变命令源的可执行状态,需要在实现时通过CanExecuteChanged事件对CommandManager的RequerySuggested事件进行封装。

 

最后来一张命令系统的UML图:

参考

1.命令概述 - WPF .NET Framework | Microsoft Learn

posted @ 2023-01-30 20:02  叶落劲秋  阅读(1372)  评论(3编辑  收藏  举报