WPF中改进自定义Command一些想法
Command来源于Command命令模式,Command模式它封装的是命令,把命令发出者的责任和命令执行者的责任分开,直白的说为了调用与具体实现解耦。关于理论俺向来是不擅长的,而且各位达人的文章也已经到了数不胜数的地步,所以小弟也就不在此画蛇添足了。
在WPF中定义的接口为ICommand,叫这个名字显而易见,为什么不叫IXXXCommand,比如ICurryCommand,不好意思微软的WPF控件不是我做的,否则我会考虑这一命名方案。当然如果你感觉微软定义的有缺陷准备自己着手打造一套全新控件包括新的ICommand接口,那么您可以就此跳过了。
public interface ICommand { event EventHandler CanExecuteChanged; bool CanExecute(object parameter); void Execute(object parameter); }对于Execute方法不难理解,对于命令模式来说有个统一的处理函数是必须的,自然包括可能的传参;而对于CanExecute从字面意义上就可以了解到——方法能不能执行,实际意义在于吃不到就不要让人看到,执行了函数然后告诉你由于啥啥状况实际上不能运行,还不如一开始就告诉别人这个函数执行不了,至于为什么执行不了,那就要自己想办法通知咯;那为什么要有个事件呢?打个比方店里货卖完了我不能买了,但进货后可以买,可什么时候能进到货我并不知道,需要店家通知。让店家通知这个动作在程序来说就是注册事件,告诉命令的发出者什么时候才能执行,这个也就是CanExecuteChanged的由来;在WPF中对于控件不能CanExecute的做法通常都是把控件的IsEnable设成False,当注册的CanExecuteChanged得到回应时才设置成True。
// Summary: // Defines an object that knows how to invoke a command. public interface ICommandSource { // Summary: // Gets the command that will be executed when the command source is invoked. ICommand Command { get; } // // Summary: // Represents a user defined data value that can be passed to the command when // it is executed. // // Returns: // The command specific data. object CommandParameter { get; } // // Summary: // The object that the command is being executed on. IInputElement CommandTarget { get; } }
作为命令的发出者,也就是调用者,微软也给出了一个接口,Command 意义不必说了,它通常都是在控件的Click中执行,最常见的Button,CheckBox,RadioButton(注意这些控件实际都继承于ButtonBase,所以你需要制作有Click动作的控件不是有特别需求建议从他继承) MenuItem,CommandParameter就是Command中Execute方法的参数。最后一个属性CommandTarget 是为了解决类似这种情况:右键菜单上有个粘贴命令,执行命令后是把剪贴板的内容复制到相对应的文字框中,而不是把剪贴板的内容拷贝到右键菜单上,这里的CommandTarget便是那个文字框,CommandTarget默认为当Command是RoutedCommand才能使用;当CommandTarget为空时,MSDN的说法是找到当前焦点所对应的控件(Keyboard Foucs),如点击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(object sender, ExecutedRoutedEventArgs e)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
/// A command whose sole purpose is to /// relay its functionality to other /// objects by invoking delegates. The /// default return value for the CanExecute /// method is 'true'. /// </summary> public class DelegateCommand : ICommand { #region Fields readonly Action<object> _execute; readonly Predicate<object> _canExecute; #endregion // Fields #region Constructors /// <summary> /// Creates a new command that can always execute. /// </summary> /// <param name="execute">The execution logic.</param> public DelegateCommand(Action<object> execute) : this(execute, null) { } /// <summary> /// Creates a new command. /// </summary> /// <param name="execute">The execution logic.</param> /// <param name="canExecute">The execution status logic.</param> public DelegateCommand(Action<object> execute, Predicate<object> canExecute) { if (execute == null) throw new ArgumentNullException("execute"); _execute = execute; _canExecute = canExecute; } #endregion // Constructors #region ICommand Members [DebuggerStepThrough] public bool CanExecute(object parameter) { return _canExecute == null ? true : _canExecute(parameter); } public event EventHandler CanExecuteChanged { add { CommandManager.RequerySuggested += value; } remove { CommandManager.RequerySuggested -= value; } } public void Execute(object parameter) { _execute(parameter); } #endregion // ICommand Members }
对于其中的
public event EventHandler CanExecuteChanged { 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多次切换会有内存泄漏,建议使用微软在MVVM DEMO中的DelegateCommand,它在构造函数中还有参数来开关是否要加入CommandManager.RequerySuggested,此Command已在在附录中。
到这里大家似乎已经很满意了,差不多自己也就是这么做的,可有没有想过,这样的话CommandBinding是用不了的,毕竟有时候需要用它做些UI层的拦截,如命令执行完之后可以把当前对话框关闭这也属于UI层面的,那CommandBinding为什么用不了?我们没有引发CommandManager上的事件像CommandManager.ExecutedEvent。没有引发也就没有路由事件,没有粮食怎么吃肉?通过CommandManager.AddExecutedHandler加入的委托也是用不的了,都是用的CommandManager.ExecutedEvent事件。
不能引发路由,就让能引发的来做。已经有人迫不及待了:不就new个RoutedCommand,然后把我们自定义的Command中的方法剥离出来赋给CommandBinding。这里需要用到附加属性,前端需要这样定义,而不能直接为Command赋值:
<Button local:CommandAttachBehavior.Command="{Binding Save}">Save</Button>CommandAttachBehavior类如下:
public static class VisualExtension { public static T FindAncestor<T>(this Visual visual, Predicate<T> predicate) where T : Visual { while (visual != null && !predicate(visual as T)) { visual = (Visual)VisualTreeHelper.GetParent(visual); } return (T)visual; } }
/// <summary> /// Attached property that can be used to create a binding for a CommandModel. Set the /// CommandAttachBehavior.Command property to a CommandModel. /// </summary> public static class CommandAttachBehavior { public static readonly DependencyProperty CommandProperty = DependencyProperty.RegisterAttached("Command", typeof(ICommand), typeof(CommandAttachBehavior), new PropertyMetadata(new PropertyChangedCallback(OnCommandInvalidated))); public static ICommand GetCommand(DependencyObject sender) { return (ICommand)sender.GetValue(CommandProperty); } public static void SetCommand(DependencyObject sender, ICommand command) { sender.SetValue(CommandProperty, command); } /// <summary> /// Callback when the Command property is set or changed. /// </summary> private static void OnCommandInvalidated(DependencyObject sender, DependencyPropertyChangedEventArgs e) { var command = e.NewValue as ICommand; if (command == null) return; var el = sender as UIElement; if (el == null) throw new ArgumentNullException(); if (el is ICommandSource) { var routedCommand = new RoutedCommand(); var type = el.GetType(); var propInfo = type.GetProperty("Command"); propInfo.SetValue(el, command, null); el.Dispatcher.BeginInvoke((Action)delegate { var elParent = el.FindAncestor<UIElement>(u => !(u is ICommandSource)); if (elParent == null) return; elParent.CommandBindings.Add(new CommandBinding(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我只好用了反射的方法:
var argsConstructo = typeof(ExecutedRoutedEventArgs).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); ExecutedRoutedEventArgs args = (ExecutedRoutedEventArgs)argsConstructo[0].Invoke(new object[] { this, parameter }); args.RoutedEvent = CommandManager.PreviewExecutedEvent;
由于引发这个事件需要实际UIElement、UIElement3D或ContentElement对象,只有这些类才拥有RaiseEvent方法,所以我为DelegateCommand又定义了一个IElement接口来承接对象,为了让CommandTarget也能使用,Render之后我才对IElement赋值,因为我不知道CommandTarget属性是否会定义在Command之后。CommandAttachBehavior类上的OnCommandInvalidated改写为如下:(我改进的DelegateCommand也在附件)
private static void OnCommandInvalidated(DependencyObject sender, DependencyPropertyChangedEventArgs e) { var command = e.NewValue as ICommand; if (command == null) return; sender.Dispatcher.BeginInvoke((Action)delegate { ICommandSource commandSource = sender as ICommandSource; if (commandSource != null) { var delegateCommand = command as IElement; if (delegateCommand != null) delegateCommand.Target = commandSource.CommandTarget ?? (IInputElement)sender; var type = sender.GetType(); var propInfo = type.GetProperty("Command"); propInfo.SetValue(sender, command, null); } }, DispatcherPriority.Render); }
Command的其他一些改进做法
通常来说对自定义的Command改进的还有增加泛型,泛型有什么用呢?这个其实是给Execute里的参数用的,他的参数按照ICommand规定默认是object,可有时候我们的参数是个Employee类,那么在执行的时候我们需要做Employee employee = arg as Employee的操作,假如穿进来的参数直接是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),希望本文是抛砖引玉,当然被拍砖引来的玉,我也同样欢迎。