前言 相信大家看過不少講C# async await的文章,博客園就能搜到很多,但還是有很多C#程式員不明白。 如果搞不明白,其實也不影響使用。但有人就會疑惑,為什麼要用非同步?我感覺它更慢了,跟同步有啥區別? 有的人研究深入,比如去研究狀態機,可能會明白其中的原理。但深入研究的畢竟少數。有的人寫一些 ...
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類進行關聯(這裡就不展開講述CanExecuteChangedEventManager類了,感興趣的話可以自行研究),具體關聯方法就是將上述的OnCanExecuteChanged方法作為事件處理器綁定到Command的CanExecuteChanged事件,還記得嗎,這個CanExecuteChanged事件就是RoutedCommand中對CommandManager.RequerySuggested事件進行封裝的那個,想起來了吧?
在這裡對命令源狀態更新方法稍微做個總結:
首先是通過對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 }
總結
- 命令系統包含ICommand,ICommandSource,命令目標及CommandBinding 四個基本要素,但是ICommandSource中的CommandTarget屬性只在命令是RoutedCommand時才有用,否則在命令執行時會被直接忽略;
- RoutedCommand顧名思義,其本質還是路由事件,但它只負責發起路由事件,並不執行命令邏輯,命令邏輯是由與具體命令關聯的CommandBinding來執行的;
- 由於RoutedCommand是基於路由事件的,因此其發起路由事件、構建路由路徑、沿路由路徑執行命令處理程式等這一複雜的流程勢必會對執行效率產生不好的影響,所以如果不需要命令進行路由,可以構建簡單的自定義命令。
- 自定義命令時,如果希望通過命令系統來改變命令源的可執行狀態,需要在實現時通過CanExecuteChanged事件對CommandManager的RequerySuggested事件進行封裝。
最後來一張命令系統的UML圖:
參考
1.命令概述 - WPF .NET Framework | Microsoft Learn
本文來自博客園,作者:葉落勁秋,轉載請註明原文鏈接:https://www.cnblogs.com/tianlang358/p/17077102.html