WPF中改進自定義Command一些想法
在WPF中定義的接口為ICommand,叫這個名字顯而易見,為什么不叫IXXXCommand,比如ICurryCommand,不好意思微軟的WPF控件不是我做的,否則我會考慮這一命名方案。當然如果你感覺微軟定義的有缺陷準備自己著手打造一套全新控件包括新的ICommand接口,那么您可以就此跳過了。
- publicinterfaceICommand
- {
- eventEventHandlerCanExecuteChanged;
- boolCanExecute(objectparameter);
- voidExecute(objectparameter);
- }
對于Execute方法不難理解,對于命令模式來說有個統一的處理函數是必須的,自然包括可能的傳參;而對于CanExecute從字面意義上就可以了解到——方法能不能執行,實際意義在于吃不到就不要讓人看到,執行了函數然后告訴你由于啥啥狀況實際上不能運行,還不如一開始就告訴別人這個函數執行不了,至于為什么執行不了,那就要自己想辦法通知咯;那為什么要有個事件呢?打個比方店里貨賣完了我不能買了,但進貨后可以買,可什么時候能進到貨我并不知道,需要店家通知。讓店家通知這個動作在程序來說就是注冊事件,告訴命令的發出者什么時候才能執行,這個也就是CanExecuteChanged的由來;在WPF中對于控件不能CanExecute的做法通常都是把控件的IsEnable設成False,當注冊的CanExecuteChanged得到回應時才設置成True。
- //Summary:
- //Definesanobjectthatknowshowtoinvokeacommand.
- publicinterfaceICommandSource
- {
- //Summary:
- //Getsthecommandthatwillbeexecutedwhenthecommandsourceisinvoked.
- ICommandCommand{get;}
- //
- //Summary:
- //Representsauserdefineddatavaluethatcanbepassedtothecommandwhen
- //itisexecuted.
- //
- //Returns:
- //Thecommandspecificdata.
- objectCommandParameter{get;}
- //
- //Summary:
- //Theobjectthatthecommandisbeingexecutedon.
- IInputElementCommandTarget{get;}
- }
作為命令的發出者,也就是調用者,微軟也給出了一個接口,自定義Command意義不必說了,它通常都是在控件的Click中執行,最常見的Button,CheckBox,RadioButton(注意這些控件實際都繼承于ButtonBase,所以你需要制作有Click動作的控件不是有特別需求建議從他繼承)MenuItem,CommandParameter就是Command中Execute方法的參數。最后一個屬性CommandTarget是為了解決類似這種情況:右鍵菜單上有個粘貼命令,執行命令后是把剪貼板的內容復制到相對應的文字框中,而不是把剪貼板的內容拷貝到右鍵菜單上,這里的CommandTarget便是那個文字框,CommandTarget默認為當Command是RoutedCommand才能使用;當CommandTarget為空時,MSDN的說法是找到當前焦點所對應的控件(KeyboardFoucs),如點擊Button,命令執行后得到焦點的應該是你點擊的那個Button,可我Reflector的結果貌似CommandTarget為空時,直接用了Command發出的者,雖然都是同一個Button,但總感覺有點怪。
說了這些你是不是覺得這三個屬性的值應該都是外部給的,可微軟居然定義為get只讀,我也百思不得其解,這里還值得一提的是ICommandSource只是一種規范,和命令必須繼承ICommand不同(要不然至少微軟的控件不認),不是必須的,可為了規范期間建議繼承該接口,方便他人閱讀理解也好為一些操作統一做法。
內置Command
前面說了ICommand只是一個接口,好處是你可以隨意實現,壞處便是每次使用都需要建立一個實現它的具體類,那么微軟有沒有給個默認的實現類,答案是肯定的,它叫做RoutedCommand,不用不知道,一用嚇一跳,默認的這個RoutedCommand類居然不能傳委托,為什么說不能穿委托很詫異,上面說了Command的主要功能是有個函數讓人執行,可函數不傳給他,你讓別人執行啥?(派生于他的類幾乎啥也做不了——他沒有任何虛方法),微軟這里又用了一招——CommandBinding,他彌補了RoutedCommand在功能上的缺陷,可以為ICommand指定CanExecute委托和Execute委托,RoutedCommand是ICommand的具體實現,自然可以舒舒服服的享用,不過CommandBinding的出現真的只為了RoutedCommand的亡羊補牢?
試想有這樣一種要求,在xaml中有個Grid,Grid中有個Button,點擊Button需要Grid背景變色。看到這個要求很多人可能笑了,很簡單嘛,注冊Button的Click事件,為Grid取個名字,在Click的事件委托中為Grid的Background賦值,沒錯。
假使把這個Button封裝到一個UserControl中,Grid中包含的只是UserControl,這個時候依舊需要點擊Button來修改Grid的顏色,有些人已經破口而出了,在UserControl中定義一個事件,在Button的Click事件委托中調用這個事件,一切看起來都很輕松;
那么現在假設Button被裝到一個Style中,我繼承的不是UserControl而是Control,你可能會聳聳肩,說道那只好注冊事件路由就可以了比如this.AddHandle(Button.ClickEvent,XXDelegate);可如果我現在里面放的按鈕不是一個而是一百個呢?我只需要其中的一個有改變Grid的功能。為Button取個名字然后判斷也是個辦法,用Button上的文字顯然會受到多語言的困擾。
最后這個為Grid改變背景的功能還被放到另外50個按鈕上以及一些MenuItem上,甚至需要Ctrl+K這樣的快捷鍵來實現,您是否還有熱情為他們一一取名判斷?
那用CommandBinding怎么解決呢?綜觀這些按鈕,菜單,快捷鍵的作用只有一個,就是為Grid改變背景,那么換句話說他們執行的是同一個命令,只要讓Grid知道有人執行了這個命令,然后得到這個消息后自己改變背景就可以了,也可以理解為命令沿可視樹向上通知直到有人接收。
命令的向上傳遞,容易讓我們想到事件路由,事實也是如此,我們知道事件路由首先得定義一個RoutedEvent,事件發出者通過方法RaiseEvent傳遞RoutedEventArgs參數通知,當RoutedEventArgs中的Handled屬性為True時,會阻止之后的事件執行,除非事件在開始的時候是通過AddHandle方法注冊,且把第三個參數handledEventsToo設為了True,那么這個RoutedEvent在哪里?這個我們又要說到CommandManager這個類,他在其中定義了PreviewExecutedEvent,ExecutedEvent,PreviewCanExecuteEvent等事件,通過Reflector可以看到UIElement的RegisterEvents方法中有這樣的定義(其中的type指的是typeof(UIElement)):
也就是說凡是派生于UIElement的子類都可以受到這個路由傳遞。同理沒有繼承與UIElement的類只要注冊以上事件便可接受Command的響應。大家具體實做后會發現,CommandManager.ExecutedEvent的參數ExecutedRoutedEventArgs類它的構造函數是internal,意思就是說我們不能通過普通的new來創建,通常在我們習慣性的問候了一些女性后,便開始接受這樣無奈的事實——使用RoutedCommand是官方唯一指定的具備引發CommandManager.ExecutedEvent條件的途徑(可以實例化ExecutedRoutedEventArgs,內部關系到處存在,唉…)。
說來這些或許有人開始點頭,之后又開始疑惑這和CommandBinding有啥關系,完全是CommandManager和RoutedCommand的那點事,他怎么進行第三者插足來運行那些委托方法?以UIElement.OnExecutedThunk來做說明,它其實調用的是CommandManager.OnExecuted(objectsender,ExecutedRoutedEventArgse)sender就是當前的UIElement,這個方法會瞧瞧UIElement上的CommandBindingCollection看其中的CommandBinding包含的Command有沒有和e中的Command相同的,因為是事件路由,他可根據可視樹往上找,一個不成再看下一個,如果有則執行CommandBinding的OnExecuted,也就是運行委托傳入的方法,之后把e.Handled設為True,這使得我們同一個Command的委托方法只能用CommandBinding一次,連續定義幾個相同委托的CommandBinding沒有任何意義,同理CommandManager.AddExecutedHandler加入的委托也不能引發,除非顯示的用AddHanlde把第三個參數設為True——
uiElementControl.AddHanlde(CommandManager.ExecutedEvent,xxxDelegate,true),設成True的后果是這個委托每次必執行。
擴展自定義Command
對于程序來說,我們希望把業務邏輯和呈現盡量分離,以期實現不同UI的相同調用,一個程序B/S架構能用,C/S架構也能用,或許有人說了:這不就是要把業務封裝成個DLL或是WebService嘛,我們在用WCF完全沒問題。是的,這樣可以更方便的測試并增加代碼的重用性降低出錯幾率。隨著人口的增長,剩余勞動力的增加,各種分工愈趨細化…等等,先不要仍雞蛋,開個玩笑也不行?拿Web前端打比方,需要的技術可能有javascript、vbScript、css、html、圖片處理(如PS),在有些狀況下這事我們全扛了,但在內心深處或許有一個聲音:我需要美工;潛臺詞是沒有美術細胞。
我們希望美工干什么?界面美化?廢話?界面美化包括頁面布局、色調搭配、圖片修改等,那么之上的這些技術中留下的可能只剩javascript和vbscript了,那javascript能干什么?在ajax沒有誕生的歲月,有段時間他已經淪落到做些簡單的動畫效果和動態增加表單元素之類的地步,頁面回調刷新,太復雜的也沒有必要,甚至于在那段時間我都有聽到一些少用javascript的言論,現在反觀自然是毛骨悚然,如同回望50年前的生活,也是不可想象的,時代在進步,思想也在變化。
Ajax中數據一般是傳遞json,由于http的局限我們通過字符串來模擬對象,一個對象通常對應固定的UI,當對象數據發生變化時UI也能夠發現變化,我們希望有份模板可以留給美工修改,假設對象為Employee上面有個屬性為Name,那么UI上會有個div它的innerHTML為其對應呈現,Name為王五,innerHTML也為王五,Name為張三時,innerHTML自動的也更改為張三,這種在Web上近乎的天方夜譚,但在WPF中卻成為了可能,甚至于Employee上有個行為Walk(),在UI上操作按鈕執行的可以是Employee這個行為。不過調用這個行為的方式我們成為Command。
既然是數據對象那么它可以完全不理會UI的呈現方式,在WPF你要把Name放到一個TextBlock上還是一個Label上,這個Label的顏色是紅是白可以由界面設計者說了算,這稱為MVVM模式。可對于行為WPF還不能完全綁定到對象上的方法,要把方法轉換到Command中去,也就是說要把方法轉換成ICommand的Execute的形式——void,且只能傳一個參數。而且這樣的話RoutedCommand也就失去了功效,他不能傳委托,對象又不知道具體的前端控件不能使用CommandBinding,這時我們需要自定一個Command
- ///Acommandwhosesolepurposeisto
- ///relayitsfunctionalitytoother
- ///objectsbyinvokingdelegates.The
- ///defaultreturnvaluefortheCanExecute
- ///methodis'true'.
- ///</summary>
- publicclassDelegateCommand:ICommand
- {
- #regionFields
- readonlyAction<object>_execute;
- readonlyPredicate<object>_canExecute;
- #endregion//Fields
- #regionConstructors
- ///<summary>
- ///Createsanewcommandthatcanalwaysexecute.
- ///</summary>
- ///<paramname="execute">Theexecutionlogic.</param>
- publicDelegateCommand(Action<object>execute)
- :this(execute,null)
- {
- }
- ///<summary>
- ///Createsanewcommand.
- ///</summary>
- ///<paramname="execute">Theexecutionlogic.</param>
- ///<paramname="canExecute">Theexecutionstatuslogic.</param>
- publicDelegateCommand(Action<object>execute,Predicate<object>canExecute)
- {
- if(execute==null)
- thrownewArgumentNullException("execute");
- _execute=execute;
- _canExecute=canExecute;
- }
- #endregion//Constructors
- #regionICommandMembers
- [DebuggerStepThrough]
- publicboolCanExecute(objectparameter)
- {
- return_canExecute==null?true:_canExecute(parameter);
- }
- publiceventEventHandlerCanExecuteChanged
- {
- add{CommandManager.RequerySuggested+=value;}
- remove{CommandManager.RequerySuggested-=value;}
- }
- publicvoidExecute(objectparameter)
- {
- _execute(parameter);
- }
- #endregion//ICommandMembers對于其中的
- publiceventEventHandlerCanExecuteChanged
- {
- add{CommandManager.RequerySuggested+=value;}
- remove{CommandManager.RequerySuggested-=value;}
- }
您可能有點疑惑,我們知道CanExecuteChanged是給命令執行體通知是否可執行命令用的(譬如控件的IsEnable屬性是否更改),也就是上面比方中店里貨到了,店家通知我可以買貨了,可通知必須要對應的Command去發出,且一個個發出這便有些麻煩,這個時候我們需要把事件注冊到全局統一發出,CommandManager.RequerySuggested就給我們提供了這樣方便,注冊后可用CommandManager.InvalidateRequerySuggested()來統一引發,當在主線程外使用該方法注意需要這樣來調用
- Application.Current.Dispatcher.BeginInvoke((Action)delegate()
- {
- CommandManager.InvalidateRequerySuggested();
- }
System.Windows.Threading.DispatcherPriority.Normal);繼承于UIElement的類,當鼠標點擊、鍵盤按下或鼠標滾輪也會觸發該方法。
你可能有個疑問,事件可是強引用,一旦加入這個全局的事件,是否會發生內存泄露,這點你可以放心,全局事件只是看上去,實際上它是用WeakReference來存放加入的委托,執行委托的時候判斷WeakReference的Target是否為空,為空則清除,你可以用工具看下CommandManager的源碼就完全清楚了,RoutedCommand也是用這個全局方式來處理。同理如果你認為統一引發效能太差或沒有必要也可以自己手動引發,如Prism中的DelegateCommand就需要自己調用他的RaiseCanExecuteChanged函數來引發,值得注意的是Prism中的事件沒有采用弱引用機制,你的Command和UI多次切換會有內存泄漏,建議使用微軟在MVVMDEMO中的DelegateCommand,它在構造函數中還有參數來開關是否要加入CommandManager.RequerySuggested,此Command已在在附錄中。
到這里大家似乎已經很滿意了,差不多自己也就是這么做的,可有沒有想過,這樣的話CommandBinding是用不了的,畢竟有時候需要用它做些UI層的攔截,如命令執行完之后可以把當前對話框關閉這也屬于UI層面的,那CommandBinding為什么用不了?我們沒有引發CommandManager上的事件像CommandManager.ExecutedEvent。沒有引發也就沒有路由事件,沒有糧食怎么吃肉?通過CommandManager.AddExecutedHandler加入的委托也是用不的了,都是用的CommandManager.ExecutedEvent事件。
不能引發路由,就讓能引發的來做。已經有人迫不及待了:不就new個RoutedCommand,然后把我們自定義的Command中的方法剝離出來賦給CommandBinding。這里需要用到附加屬性,前端需要這樣定義,而不能直接為Command賦值:
- <Buttonlocal:CommandAttachBehavior.Command="{BindingSave}">Save</Button>CommandAttachBehavior類如下:
- publicstaticclassVisualExtension
- {
- publicstaticTFindAncestor<T>(thisVisualvisual,Predicate<T>predicate)whereT:Visual
- {
- while(visual!=null&&!predicate(visualasT))
- {
- visual=(Visual)VisualTreeHelper.GetParent(visual);
- }
- return(T)visual;
- }
- }
- ///<summary>
- ///AttachedpropertythatcanbeusedtocreateabindingforaCommandModel.Setthe
- ///CommandAttachBehavior.CommandpropertytoaCommandModel.
- ///</summary>
- publicstaticclassCommandAttachBehavior
- {
- publicstaticreadonlyDependencyPropertyCommandProperty
- =DependencyProperty.RegisterAttached("Command",typeof(ICommand),typeof(CommandAttachBehavior),
- newPropertyMetadata(newPropertyChangedCallback(OnCommandInvalidated)));
- publicstaticICommandGetCommand(DependencyObjectsender)
- {
- return(ICommand)sender.GetValue(CommandProperty);
- }
- publicstaticvoidSetCommand(DependencyObjectsender,ICommandcommand)
- {
- sender.SetValue(CommandProperty,command);
- }
- ///<summary>
- ///CallbackwhentheCommandpropertyissetorchanged.
- ///</summary>
- privatestaticvoidOnCommandInvalidated(DependencyObjectsender,DependencyPropertyChangedEventArgse)
- {
- varcommand=e.NewValueasICommand;
- if(command==null)
- return;
- varel=senderasUIElement;
- if(el==null)
- thrownewArgumentNullException();
- if(elisICommandSource)
- {
- varroutedCommand=newRoutedCommand();
- vartype=el.GetType();
- varpropInfo=type.GetProperty("Command");
- propInfo.SetValue(el,command,null);
- el.Dispatcher.BeginInvoke((Action)delegate
- {
- varelParent=el.FindAncestor<UIElement>(u=>!(uisICommandSource));
- if(elParent==null)
- return;
- elParent.CommandBindings.Add(newCommandBinding(routedCommand,
- (target,arg)=>
- {
- command.Execute(arg.Parameter);
- },
- (target,arg)=>
- {
- arg.CanExecute=command.CanExecute(arg.Parameter);
- }));
- },DispatcherPriority.Render);
- }
- }
- }
大家可能問了用CommandBinding用就用了,那為什么還需要把他綁定到非命令父類,問題是綁定到他自己本身話CommandManager.AddExecutedHandler還是不能用,會被CommandBinding給攔截掉,這里要注意下CommandManager.AddExecutedHandler的用法,由于它注冊的是CommandManager.ExecutedEvent事件,如果你把它注冊給容器,而這個容器包含很多Button,各個Button命令不同,路由事件的特性會使得任一命令發出時都會響應注冊的委托,原因是這些命令都引發了CommandManager.ExecutedEvent事件,所以僅對當前控件的命令攔截的話最好只注冊到命令發出者本身(Button)。
這種方法雖然可以攔截了,但CommandBinding已經被用了,外部無法再使用,況且循環找父類效率也差,為什么要在Render之后才找呢?如果你用了類似Prism框架中Region的延遲加載一開始會找不到父類。我們自定義的Command淪為了中間的代理對象,想手動控制CanExecuteChanged也變的望塵莫及。
思來想去無奈為了實例化ExecutedRoutedEventArgs我只好用了反射的方法:
varargsConstructo=typeof(ExecutedRoutedEventArgs).GetConstructors(BindingFlags.NonPublic|BindingFlags.Instance);
ExecutedRoutedEventArgsargs=(ExecutedRoutedEventArgs)argsConstructo[0].Invoke(newobject[]{this,parameter});
args.RoutedEvent=CommandManager.PreviewExecutedEvent;由于引發這個事件需要實際UIElement、UIElement3D或ContentElement對象,只有這些類才擁有RaiseEvent方法,所以我為DelegateCommand又定義了一個IElement接口來承接對象,為了讓CommandTarget也能使用,Render之后我才對IElement賦值,因為我不知道CommandTarget屬性是否會定義在Command之后。CommandAttachBehavior類上的OnCommandInvalidated改寫為如下:(我改進的DelegateCommand也在附件)
- privatestaticvoidOnCommandInvalidated(DependencyObjectsender,DependencyPropertyChangedEventArgse)
- {
- varcommand=e.NewValueasICommand;
- if(command==null)
- return;
- sender.Dispatcher.BeginInvoke((Action)delegate
- {
- ICommandSourcecommandSource=senderasICommandSource;
- if(commandSource!=null)
- {
- vardelegateCommand=commandasIElement;
- if(delegateCommand!=null)
- delegateCommand.Target=commandSource.CommandTarget??(IInputElement)sender;
- vartype=sender.GetType();
- varpropInfo=type.GetProperty("Command");
- propInfo.SetValue(sender,command,null);
- }
- },DispatcherPriority.Render);
- }
自定義Command的其他一些改進做法
通常來說對自定義Command改進的還有增加泛型,泛型有什么用呢?這個其實是給Execute里的參數用的,他的參數按照ICommand規定默認是object,可有時候我們的參數是個Employee類,那么在執行的時候我們需要做Employeeemployee=argasEmployee的操作,假如穿進來的參數直接是Employee自然不需要這么做了,而轉成Employee對象的操作在Command中已經被做掉——CanExecute((T)parameter)。
自定義Command雖好,可一個控件限定一個Command有時候就會顯的不夠用,或者那個控件壓根沒有Command那不完了,MVVM沒法混了?沒有命令事件總該有吧,什么,沒有事件?單純顯示用的?那他憑什么有行為?有事件的話,我們可以注冊事件在委托中執行Command,具體做法請參考Prism中的ButtonBaseClickCommandBehavior、CommandBehaviorBase、Click這三個類。
Prism中還有個關于Command的類叫做CompositeCommand,他主要為了解決幾個自定義Command一起能執行的問題:一次增加了多了訂單,只要每個訂單都被允許保存,則不需要一個個點訂單的Save按鈕,來個SaveAll一起保存,要是里面有個訂單不能保存,那么SaveAll是不能用的。實現原理也比較直觀,就是把幾個自定義Command放到一個列表并注冊他們的CanExecuteChanged,看是不是都能被執行,如果不能執行則CompositeCommand的CanExecute為false,能執行則用CanExecuteChanged通知前端控件,執行時只要循環執行列表中Command的Execute方法即可。
一般定義的Command不能控制ExecutedRoutedEventArgs中的Handled屬性,我把他提了出來用ref來控制,這種做法似乎有點讓ViewModel知曉UI的味道,可有時候還是必要的,如我的SaveCommand結束后本該會有個關閉窗口的CommandBinding相隨,可執行SaveCommand時發生了錯誤,這時就要把Handled設為True不能讓之后的CommandBinding進行。
PS:我自己改進的這個DelegateCommand也有些缺點比如需要用附加屬性,這樣用起來就比較不統一,還有就是反射用的較多效率不說,也破壞了原有的對象封裝,并需要在Command中放入了UI元素(IElement),希望本文是拋磚引玉,當然被拍磚引來的玉,我也同樣歡迎。
【編輯推薦】