WPF之命令

命令系统的基本元素

WPF的命令系统由几个基本要素构成:

  • 命令(Command):WPF的命令实际上就是实现了ICommand接口的类,平时使用最多的是RoutedCommand类,还可以使用自定义命令。
  • 命令源(Command Source):即命令的发送者,是实现了ICommandSource接口的类,很多界面元素都实现了这个接口,如Button、Menultem、ListBoxltem等。
  • 命令目标(Command Target):即命令将发送给谁,或者说命令将作用在谁身上,命令目标必须是实现了IInputElement接口的类。
  • 命令关联(Command Binding):负责把一些外围逻辑与命令关联起来,比如执行之前对命令是否可以执行进行判断、命令执行之后还有哪些后续工作等。

基本元素之间的关系

基本元素之间的关系体现在使用命令的过程中,命令的使用大概分为如下几步:

  • 创建命令类:即获得一个实现ICommand接口的类,命令与具体业务逻辑无关则使用WPF类库中的RoutedCommand类即可,与业务逻辑相关则需创建RoutedCommand(或者ICommand接口)的派生类。
  • 声明命令实例:使用命令时需要创建命令类的实例,一般情况下程序中某种操作只需要一个命令实例与之对应即可,程序中的命令多使用单件模式(Singletone Pattern)
  • 指定命令的源:即指定由谁来发送这个命令,同一个命令可以有多个源,一旦把命令指派给命令源,那么命令源就会受命令的影响,各种控件发送命令的方法也不尽相同(如Buton是在单击时发送命令、ListBoxltme双击时发送命令)。
  • 指定命令目标:命令目标并不是命令的属性而是命令源的属性,指定命令目标是告诉命令源向哪个组件发送命令,无论这个组件是否拥有焦点它都会收到这个命令,没有为命令源指定命令目标则WPF系统认为当前拥有焦点的对象就是命令目标。
  • 设置命令关联:WPF命令需要CommandBinding在执行前来帮助判断是不是可以执行、在执行后做一些事件来“打扫战场”。

一旦某个UI组件被命令源“瞄上”,命令源就会不停地向命令目标“投石问路”,命令目标就会不停地发送出可路由的PreviewCanExecute和CanExecute附加事件,事件会沿着UI元素树向上传递并被命令关联所捕捉,命令关联捕捉到这些事件后会把命令能不能发送实时报告给命令。

如果命令被发送出来并到达命令目标,命令目标就会发送PreviewExecuted和Executed两个附加事件,这两个事件也会沿着UI元素树向上传递并被命令关联所捕捉,命令关联会完成一些后续的任务。对于那些与业务逻辑无关的通用命令,这些后续任务才是最重要的。

命令目标发出的PreviewCanExecute、CanExecute、PreviewExecuted和Executed这4个事件都是附加事件,是被CommandManager类“附加”给命令目标的,PreviewCanExecute和CanExecute的执行时机不由程序员控制,且执行频率比较高,会给降低系统性能、引入比较难调试的bug。

WPF命令系统基本元素的关系图如下:
WPF之命令

小试命令

定义一个命令,使用Button来发送这个命令,当命令送达TextBox时TextBox会被清空(如果TextBox中没有文字则命令不可被发送)。
XAML界面代码如下:

<Window x:Class="WpfApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"  
        Title="MainWindow" Height="179.464" Width="438.393">
    <StackPanel x:Name="stackPanel">
        <Button x:Name="button1" Content="Send Command" Margin="5"/>
        <TextBox x:Name="textBoxA" Margin="5,0" Height="100"/>
    </StackPanel>
</Window>

C#后台代码如下:

public MainWindow()
{
    InitializeComponent();
    InitializeCommand();
}

//声明并定义命令
private RoutedCommand clearCmd = new RoutedCommand("Clear",typeof(MainWindow));
private void InitializeCommand()
{
    //把命令赋值给命令源(发送者)并指定快捷键
    this.button1.Command=this.clearCmd;
    this.clearCmd.InputGestures.Add(new KeyGesture(Key.C, ModifierKeys.Alt));
    //指定命令目标
    this.button1.CommandTarget = this.textBoxA;
    //创建命令关联
    CommandBinding cb = new CommandBinding();
    cb.Command = this.clearCmd;
    //只关注与clearCmd相关的事件
    cb.CanExecute += new CanExecuteRoutedEventHandler(cb_CanExecute);
    cb.Executed += new ExecutedRoutedEventHandler(cb_Executed);
    //把命令关联安置在外围控件上
    this.stackPanel.CommandBindings.Add(cb);
}

//当探测命令是否可以执行时,此方法被调用
void cb_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
    if (string.IsNullOrEmpty(this.textBoxA.Text))
    {
        e.CanExecute = false;
    }
    else
    { 
        e.CanExecute = true; 
    }

    // 避免继续向上传而降低程序性能
    e.Handled = true; 
}

//当命令送达目标后,此方法被调用
void cb_Executed(object sender, ExecutedRoutedEventArgs e) 
{
    this.textBoxA.Clear();

    // 避免继续向上传而降低程序性能
    e.Handled = true;    
}

运行程序,在TextBox中输入文字后Button在命令可执行状态的影响下变为可用,此时单击Buton 或者按Alt+C键,TextBox都会被清空,效果如下:
WPF之命令

对于上面代码有几点需要注意的地方:

  • 使用命令可以避免自己写代码判断Button是否可用以及添加快捷键。
  • RoutedCommand是一个与业务逻辑无关的类,只负责在程序中“跑腿”而并不对命令目标做任何操作,TextBox是由CommandBinding清空的
  • 因为CanExecute事件的激发频率比较高,为了避免降低性能,在处理完后建议把e.Handled设为true
  • CommandBinding一定要设置在命令目标的外围控件上,不然无法捕捉到CanExecute和Executed等路由事件。

WPF的命令库

上面的例子中声明定义了一个命令:

private RoutedCommand clearCmd = new RoutedCommand("Clear",typeof(MainWindow));

命令具有“一处声明、处处使用”的特点,比如Save命令在程序的任何地方它都表示要求命令目标保存数据。微软在WPF类库里准备了一些便捷的命令库,这些命令库包括:

  • ApplicationCommands:提供一组标准的与应用程序相关的命令,参考ApplicationCommands
  • ComponentCommands:提供一组标准的与组件相关的命令,参考ComponentCommands
  • NavigationCommands:提供一组标准的与导航相关的命令,参考NavigationCommands
  • MediaCommands:提供一组标准的与媒体相关的命令,参考MediaCommands
  • EditingCommands:提供一组标准的与编辑相关的命令,参考EditingCommands

它们都是静态类,而命令就是用这些类的静态只读属性以单件模式暴露出来的。如ApplicationCommands类的源码如下:

public static class ApplicationCommands 
 {
     public static RoutedUICommand Cut { get; }
     public static RoutedUICommand Stop { get; }
     public static RoutedUICommand ContextMenu { get; }
     public static RoutedUICommand Properties { get; }
     public static RoutedUICommand PrintPreview { get; }
     public static RoutedUICommand CancelPrint { get; }
     public static RoutedUICommand Print { get; }
     public static RoutedUICommand SaveAs { get; }
     public static RoutedUICommand Save { get; }
     public static RoutedUICommand Close { get; }
     public static RoutedUICommand CorrectionList { get; }
     public static RoutedUICommand Open { get; }
     public static RoutedUICommand Help { get; }
     public static RoutedUICommand SelectAll { get; }
     public static RoutedUICommand Replace { get; }
     public static RoutedUICommand Find { get; }
     public static RoutedUICommand Redo { get; }
     public static RoutedUICommand Undo { get; }
     public static RoutedUICommand Delete { get; }
     public static RoutedUICommand Paste { get; }
     public static RoutedUICommand Copy { get; }
     public static RoutedUICommand New { get; }
     public static RoutedUICommand NotACommand { get; }
 }

其他几个命令库也与之类似,标准命令不用自己声明,直接使用命令库即可。

命令参数

命令源一定是实现了ICommandSource接口的对象,而ICommandSource有一个属性就是CommandPrameter,CommandPrameter就相当于命令里的“消息”。

实现一个需求,当TextBox中没有内容时两个按钮均不可用;当输入文字后按钮变为可用,单击按钮,ListBox会加入不同条目。
XAML代码如下:

<Grid Margin="6">
    <Grid.RowDefinitions>
        <RowDefinition Height="24"/>
        <RowDefinition Height="4"/>
        <RowDefinition Height="24"/>
        <RowDefinition Height="4"/>
        <RowDefinition Height="24"/>
        <RowDefinition Height="4"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <!--命令和命令参数-->
    <TextBlock Text="Name:" VerticalAlignment="Center" HorizontalAlignment="Left" Grid.Row="0"/>
    <TextBox x:Name="nameTextBox" Margin="60,0,0,0" Grid.Row="0"/>
    <Button Content="New Teacher" Command="New" CommandParameter="Teacher" Grid.Row="2"/>
    <Button Content="New Student" Command="New" CommandParameter="Student" Grid.Row="4"/>
    <ListBox x:Name="listBoxNewltems" Grid.Row="6"/>
</Grid>
<!--为窗体添加CommandBinding-->
<Window.CommandBindings>
    <CommandBinding Command="New" CanExecute="New_CanExecute" Executed="New_Executed"/>
</Window.CommandBindings>

CommandBinding的两个事件处理器代码如下:

private void New_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
    if (string.IsNullOrEmpty(this.nameTextBox.Text))
    {
        e.CanExecute = false;
    }
    else 
    { 
        e.CanExecute = true; 
    }
}
private void New_Executed(object sender, ExecutedRoutedEventArgs e) 
{
    string name = this.nameTextBox.Text;
    if (e.Parameter.ToString() == "Teacher") 
    {
        this.listBoxNewltems.Items.Add(string.Format("New Teacher:{0},学而不厌、海人不倦。",name));
    }
    if (e.Parameter.ToString() == "Student") 
    {
        this.listBoxNewltems.Items.Add(string.Format("New Student:{0},好好学习、天天向上。", name));
    }
        
}

效果如下:
WPF之命令

命令与Binding的结合

控件有很多事件可以进行各种各样不同的操作,可控件只有一个Command属性,而命令库中却有数十种命令,使用Binding可以使用Command属性来调用多种命令。

例如,如果一个Buton所关联命令有可能根据某些条件而改变,可以把代码写成这样:

<Buton x:Name="dynamicCmdBtn" Command="{Binding Path=ppp,Source=sss}" Content="Command"/>

大多数命令按钮都有相对应的图标来表示固定的含义,日常工作中一个控件的命令一经确定就很少改变。

近观命令

一般情况下,程序中使用与逻辑无关的RoutedCommand就足够了,但为了使程序的结构更加简洁(比如去掉外围的CommandBinding和与之相关的事件处理器),常需要定义自己的命令。
接下来,先由剖析RoutedCommand入手,再创建自己的命令。

ICommand接口与RoutedCommand

WPF的命令是实现了ICommand接口的类,ICommand接口只包含两个方法和一个事件:

  • Execute方法:命令执行,或者说命令作用于命令目标之上。
  • CanExecute方法:在执行之前用来探知命令是否可被执行。
  • CanExecuteChanged事件:当命令可执行状态发生改变时,可激发此事件来通知其他对象。

RoutedCommand在实现ICommand接口时,并未向Execute和CanExecute方法中添加任何逻辑,它是通用的、与具体业务逻辑无关的

从外部来看,当一个命令到达命令目标后,具体是执行Copy还是Cut(即业务逻辑)不是由命令决定的,而是外围的CommandBinding捕获到命令目标受命令激发而发送的路由事件后在其Executed事件处理器中完成。

从内部分析,RoutedCommand类与命令执行相关的代码简化如下:

public class RoutedCommand : ICommand
{
    //由lCommand继承而来,仅供内部使用
    private void ICommand.Execute(object parameter)
    {
        Execute(parameter, FilterInputElement(Keyboard.FocusedElement));
    }

    //新定义的方法,可由外部调用
    //第一个参数向命令传递一些数据,第二个参数是命令的目标
    public void Execute(object parameter, IInputElement target)
    {            
        //命令目标为空,选定当前具有焦点的控件作为目标
        if ((target != null) && !InputElement.IsValid(target))
        {
            throw new InvalidOperationException(SR.Get(SRID.Invalid_IInputElement, target.GetType()));
        }

        if (target == null)
        {
            target = FilterInputElement(Keyboard.FocusedElement);
        }

        //真正执行命令的逻辑
        ExecuteImpl(parameter, target, false);
    }

    //真正执行命令的逻辑,仅供内部使用
    private bool ExecuteImpl(object parameter, IInputElement target, bool userInitiated)
    {
        //..
        UIElement targetUIElement = target as UIElement;
        //..
        ExecutedRoutedEventArgs args = new ExecutedRoutedEventArgs(this, parameter);
        args.RoutedEvent = CommandManager.PreviewExecutedEvent;

        if (targetUIElement != null)
        {
            targetUIElement.RaiseEvent(args, userInitiated);
        }
        //..
        return false;
    }

    //另一个调用Executelmpl方法的函数,依序集级别可用
    internal bool ExecuteCore(object parameter, IInputElement target, bool userInitiated)
    {
        if (target == null)
        {
            target = FilterInputElement(Keyboard.FocusedElement);
        }

        return ExecuteImpl(parameter, target, userInitiated);
    }     
}

从ICommand接口继承来的Execute并没有被公开(可以说是废弃了),仅仅是调用新声明的带两个参数的Execute方法。新的Execute方法会调用命令执行逻辑的核心——Executelmpl方法(Executelmpl是Execute Implement的缩写),这个方法“借用”命令目标的RaiseEvent把RoutedEvent发送出去,事件会被外围的CommandBinding捕获到然后执行程序员预设的与业务相关的逻辑。

以ButtonBase为例,ButtonBase是在Click事件发生时发送命令的,而Click事件的激发是放在OnClick方法里。ButonBase的OnClick方法如下:

public class ButtonBase : ContentControl, ICommandSource 
{
    //激发Click路由事件,然后发送命令
    protected virtual void OnClick()
    {
        RoutedEventArgs newEvent = new RoutedEventArgs(BuonBase.ClickEvent, this);
        RaiseEvent(newEvent);

        //调用内部类CommandHelpers的ExecuteCommandSource方法
        MS.Internal.Commands.CommandHelpers.ExecuteCommandSource(this);
    }
}

ButonBase 调用了一个.NET Framework内部类(这个类没有向程序员暴露)CommandHelpers的ExecuteCommandSource方法,并把ButtonBase对象自己当作参数传了进去。

ExecuteCommandSource方法实际上是把传进来的参数当作命令源、调用命令源的ExecuteCore 方法(本质上是调用其Executelmpl方法)、获取命令源的CommandTarget属性值(命令目标)并使命令作用于命令目标之上。

CommandHelpers部分源码如下:

internal static class CommandHelpers
{
    //..
    internal static void ExecuteCommandSource(ICommandSource commandSource)
    {
        CriticalExecuteCommandSource(commandSource, false);
    }
    internal static void CriticalExecuteCommandSource(ICommandSource commandSource, bool userInitiated)
    {
        ICommand command = commandSource.Command;
        if (command != null)
        {
            object parameter = commandSource.CommandParameter;
            IInputElement target = commandSource.CommandTarget;

            RoutedCommand routed = command as RoutedCommand;
            if (routed != null)
            {
                if (target == null)
                {
                    target = commandSource as IInputElement;
                }
                if (routed.CanExecute(parameter, target))
                {
                    routed.ExecuteCore(parameter, target, userInitiated);
                }
            }
            else if (command.CanExecute(parameter))
            {
                command.Execute(parameter);
            }
        }
    }
}

自定义Command

“自定义命令”可以分两个层次来理解:

  • 第一个层次是指的是当WPF命令库中没有包含想要的命令时声明定义自己的RoutedCommand实例,如定义一个名为Laugh的RoutedCommand实例,实际是对RoutedCommand的使用。
  • 第二个层次是指实现ICommand接口、定义自己的命令并且把某些业务逻辑也包含在命令之中,真正意义上的自定义命令。

WPF自带的命令源和CommandBinding就是专门为RoutedCommand而编写的,如果想使用自己的ICommand派生类就必须连命令源一起实现(即实现ICommandSource接口),需要根据项目的实际情况进行权衡。

下面自定义一个名为Clear的命令,当命令到达命令目标的时候先通过命令目标的IsChanged属性判断命令目标的内容是否已经被改变,如果已经改变则命令可以执行,命令的执行会直接调用命令目标的Clear方法、驱动命令目标以自己的方式清除数据(同时改变IsChanged属性值)。

命令直接在命令目标上起作用,而不像RoutedCommand那样先在命令目标上激发出路由事件等外围控件捕捉到事件后再“翻过头来”对命令目标加以处理

定义命令目标接口(IView )

在程序中定义这样一个接口:

public interface IView 
{
    //属性
    bool IsChanged{get; set;}

    //方法
    void SetBinding();
    void Refresh();
    void Clear();
    void Save();

    //...
}

要求每个需要接受命令的组件都必须实现这个接口,确保命令可以成功地对它们执行操作。

定义命令(实现ICommand接口)

接下来实现ICommand接口,创建一个专门作用于IView派生类的命令:

//自定义命令
public class ClearCommand : ICommand
{
    //当命令可执行状态发送改变时,应当被激发
    public event EventHandler CanExecuteChanged;

    //用来判断命令是否可以执行
    public bool CanExecute(object parameter)
    {
        bool canExecute = false;
        IView view = parameter as IView;
        if (view != null)
        {
            canExecute = view.IsChanged;
        }
        return canExecute;
    }        

    //命令执行时,带有与业务相关的Clear逻辑
    public void Execute(object parameter)
    {
        IView view = parameter as IView;
        if (view != null)
        {
            view.Clear();
        }
    }
}

命令实现了ICommand接口并继承了CanExecuteChanged事件、CanExecute方法和Execute方法,在实现CanExecute方法和Execute方法时将唯一的参数作为命令的目标:

  • Execute方法中,如果目标是IView接口的派生类则调用其Clear方法(把业务逻辑引入了命令的Execute方法中)。
  • CanExecute方法中,如果目标是IView接口的派生类则返回其IsChanged属性值(根据项目需求定义)。

定义命令源(实现ICommandSource)

WPF命令系统的命令源是专门为RoutedCommand准备的并且不能重写,所以只能通过实现ICommandSource接口来创建自己的命令源。代码如下:

//自定义命令源
public partial class MyCommandSource : UserControl, ICommandSource
{

    // 继承自ICommand的3个属性
    public ICommand Command { get; set; }
    public object CommandParameter { get; set; }
    public IInputElement CommandTarget { get; set; }

    //构造函数
    public MyCommandSource() 
    {
        //命令刷新的时机
        CommandManager.RequerySuggested += RequeryCanExecute;
        //如果初次刷新不及时,可在此手动调用一次
        RequeryCanExecute(null, null);
    }


    //在组件被单击时连带执行命令
    protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
    {
        base.OnMouseLeftButtonDown(e);
        if (this.CommandTarget != null)
        {
            if (this.Command.CanExecute(CommandTarget)) 
            {
                this.Command.Execute(CommandTarget);
            }
        }
    }

    //查询并显示命令目标的可执行状态,显示方式根据实际需求定义
    private void RequeryCanExecute(object sender, EventArgs e)
    {
        if (this.CommandTarget != null)
        {
            if (this.Command.CanExecute(CommandTarget))
            {                    
                this.Background = System.Windows.Media.Brushes.Green;
            }
            else 
            {                    
                this.Background = System.Windows.Media.Brushes.Orange;
            }
                       
        }
    }
}

ICommandSource接口只包含Command、CommandParameter和CommandTarget三个属性,三个属性之间的关系取决于实现。
在本例中,CommandParameter完全没有被用到,而CommandTarget被当作参数传递给了Command的Execute、CanExecute方法,在控件被左单击时执行命令。

定义命令目标(实现IView接口)

ClearCommand专门作用于IView的派生类,合格的ClearCommand命令目标必须实现IView接口。

设计这种既有UI又需要实现接口的类可以先用XAML编辑器实现其UI部分再找到它的后台C#代码实现接口(WPF会自动为UI元素类添加partial关键字修饰),XAML代码会被翻译成类的一个部分,后台代码是类的另一个部分(甚至可以再多添加几个部分),可以在后台代码部分指定基类或实现接口,最终这些部分会被编译到一起。

组件的XAML部分如下:

<UserControl x:Class="WpfApp.MniView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Height="114" Width="200">
    <Border CornerRadius="5" BorderBrush="GreenYellow" BorderThickness="2">
        <StackPanel TextBoxBase.TextChanged="TextBoxBase_TextChanged">
            <TextBox x:Name="textBox1" Margin="5"/>
            <TextBox x:Name="textBox2" Margin="5,0"/>
            <TextBox x:Name="textBox3" Margin="5"/>
            <TextBox x:Name="textBox4" Margin="5,0"/>
        </StackPanel>
    </Border>
</UserControl>

组件的后台代码部分如下:

public partial class MniView : UserControl, IView
{
    public MniView()
    {
        InitializeComponent();
    }

    //继承自IView的成员们
    public bool IsChanged { get; set; } 
    public void SetBinding(){}
    public void Refresh(){}
    public void Save() {}

    /// <summary>
    /// 用于清除内容的业务逻辑
    /// </summary>
    public void Clear()
    {
        this.textBox1.Clear();
        this.textBox2.Clear();
        this.textBox3.Clear();
        this.textBox4.Clear();
        IsChanged = false;
    }

    private void TextBoxBase_TextChanged(object sender, TextChangedEventArgs e)
    {
        IsChanged = true;
    }
}

当Clear方法被调用的时候,它的几个TextBox会被清空。

使用自定义命令

把自定义命令、命令源、命令目标集成起来,窗体的XAML代码如下:

<Window x:Class="WpfApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"  
        xmlns:local="clr-namespace:WpfApp"      
        Title="MainWindow" Height="171.464" Width="297.06">
    <StackPanel>
        <local:MyCommandSource  x:Name="ctrlClear">
            <TextBlock Text="清除" Margin="10" Width="80" FontSize="16" TextAlignment="Center" Background="LightGreen"/>
        </local:MyCommandSource>
        <local:MniView x:Name="mniView1" />
    </StackPanel>
</Window>

本例中使用简单的文本作为命令源的显示内容,用OnMouseLeftButtonDown的方法来执行命令。需要根据显示内容的种类适当更改激发命令的方法,如使用按钮时应该捕获button的Click事件并在事件处理器中执行方法(Mouse事件会被Button吃掉)。

后台C#代码:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        //声明命令并使命令源和目标与之关联
        ClearCommand clearCommand = new ClearCommand();
        this.ctrlClear.Command = clearCommand;
        this.ctrlClear.CommandTarget = mniView1;
    }
}

首先创建了一个ClearCommand实例并把它赋值给自定义命令源的Command属性(正规的方法应该是把命令声明为静态全局的地方供所有对象调用),自定义命令源的CommandTarget属性目标是MiniView的实例。

运行程序,在TextBox里输入然后再单击清除控件,效果如下图:
WPF之命令

参考资料

WPF学习之深入浅出话命令
【WPF】Command 自定义命令
WPF 的命令的自动刷新时机

WPF之命令

上一篇:C# [API] Gets the Windows 10 Accent Colors


下一篇:欧盟与美国交换商业数据新协议生效