• WPF 入门笔记 - 06 - 命令


    我们把世界看错,反说它欺骗了我们。 --飞鸟集

    前言

    相较而言,命令对我来说是一个新概念,因为在Winform中压根没有所谓的命令这个概念🥲。从文字角度理解,"命令"可以指代一种明确的指令或要求,用于向某个实体传达特定的操作或行为。它可以是一个动词性的词语,表示对某个对象或主体的要求或指示。命令通常具有明确的目标和执行内容,它告诉接收者要执行什么操作,并在某种程度上对行为进行约束。

    在软件开发中,"命令"是一种设计模式,它描述了将操作封装为对象的方法,以便在不同的上下文中使用和重用。这种命令模式通过将请求和操作封装到一个命令对象中,使得发送者和接收者之间解耦,从而实现了更灵活和可扩展的设计。在这种模式下,命令对象充当了发送者和接收者之间的中间媒介,接收者通过执行命令对象来完成特定的操作。

    在提到命令的时候,我们通常拿它来和事件作比较。我们都知道,WPF里已经有了路由事件,比如按钮在被点击以后做出的反应(直接事件),我们一般会通过ButtonClick新建一个事件,然后在这个事件里面写一些业务代码:

    image-20230623144220354

    private void Button_Click(object sender, RoutedEventArgs e)
    {
    MessageBox.Show("You click me.");
    }

    当我们运行程序点击按钮时,Button_Click事件会被驱动响应点击的动作弹出一个消息对话框,没有问题对吧。我们都知道,在早期的GUI框架中,应用程序的外观和行为方式之间没有真正的分离,回到这里也一样,将业务代码直接写在事件处理程序中会导致界面和业务逻辑紧密耦合在一起,使得两者难以分离和独立变更。实际开发中,我们会给按钮一个特定功能,当按钮的功能发生变化时,我们需要在UI界面中修改与这个功能所绑定的东西,同时也需要调整业务代码,这就导致了界面元素(外观)和业务逻辑(行为)混在一起,使得按钮的XAML代码既承担了外观的定义,又承担了业务逻辑的实现。此外,假设我们有很多个一样的按钮,都需要在用户点击按钮后为它提供相同的功能,通过上述面的方法,当这个功能发生改变时,我们就需要调正每一个涉及到的按钮以及相应的事件。为了解决这个问题,WPF提供了命令机制(Command),可以将按钮的行为与其外观进行分离。

    命令(Command)在WPF中是一种用于处理用户界面交互的机制,它们提供了一种在界面元素(UI)和后台逻辑之间进行解耦的方式,使得交互操作可以以一种统一的、可重用的方式进行处理。WPF命令的概念和实现是基于MVVM(Model-View-ViewModel)架构模式的,它使得界面元素的交互操作可以通过命令对象进行管理和处理,而不需要直接在界面代码中编写事件处理程序。

    通过使用命令,我们能够更好地组织和管理界面交互行为,使得代码结构清晰,易于维护和扩展。同时,命令还提供了一些额外的功能,如参数传递、命令的可用性控制等,使得我们能够更灵活地处理用户的操作。

    事件和命令:

    事件是与用户动作进行联动的,而命令是那些想要与界面分离的动作,比如常见的复制粘贴命令,当我们点击一个具有复制功能的按钮时,相当于我们通过点击的这个动作触发了一个复制的命令,这样做的好处就是 - 界面的交互操作变得简单、代码可重用性提高,在不破坏后台逻辑的情况下可以更加灵活的控制用户界面。

    命令模型

    WPF命令模型主要包含以下几个基本元素:

    命令(Command):指的是实现了ICommand接口的类,例如RoutedCommand类及其子类RoutedUICommand类,一般不包含具体逻辑。

    命令源(Command Source):即命令的发送者,指的是实现了ICommandSource接口的类。像ButtonMenuItem等界面元素都实现了这个接口,单击它们都会执行绑定的命令。

    命令目标(Command Target):即命令的接受者,指的是实现了IInputElement接口的类。

    命令关联(Command Binding):即将一些外围逻辑和命令关联起来。

    借用刘老师的图来看一下他们的关系:

    image-20230623155843299

    ICommand

    ICommand接口,包含一个事件两个方法:

    public interface ICommand
    {
    //
    // 摘要:
    // 当出现影响是否应执行该命令的更改时发生。
    event EventHandler CanExecuteChanged;
    // 摘要:
    // 定义用于确定此命令是否可以在其当前状态下执行的方法。
    //
    // 参数:
    // parameter:
    // 此命令使用的数据。如果此命令不需要传递数据,则该对象可以设置为 null。
    //
    // 返回结果:
    // 如果可以执行此命令,则为 true;否则为 false。
    bool CanExecute(object parameter);
    //
    // 摘要:
    // 定义在调用此命令时调用的方法。
    //
    // 参数:
    // parameter:
    // 此命令使用的数据。如果此命令不需要传递数据,则该对象可以设置为 null。
    void Execute(object parameter);
    }

    反过来看:

    • Execute - 执行某个动作

    • CanExecute - 能不能执行动作

    • CanExecuteChanged - 命令状态发生变化是响应的事件

    通过实现ICommand接口,我们可以创建自定义的命令对象,并将其与界面元素进行绑定。这样,界面元素就可以与命令相关联,通过调用命令的Execute方法来执行具体的操作,而无需直接编写事件处理程序。

    定义命令

    下面我们试着用命令的方式来实现上面的点击事件,并逐步理解模型中的内容。我们新建一个类MainViewModel来提供我们需要的功能方法:

    using System.Windows;
    namespace WPFDemo
    {
    public class MainViewModel
    {
    public void ShowInfo()
    {
    MessageBox.Show("You click me.");
    }
    }
    }

    ShowInfo()这个时候跟UI界面是分开的对吧,有了方法之后,我们可以说MainViewModel中的ShowInfo就命令了吗,根据模型来看显然还不行。继续走,写一个实现ICommand接口但不带具体逻辑的类,比如CustomCommand:

    image-20230623160635271

    using System;
    using System.Windows.Input;
    namespace WPFDemo
    {
    public class CustomCommand : ICommand
    {
    private readonly Action _execute;
    public CustomCommand(Action execute)
    {
    _execute = execute;
    }
    public event EventHandler CanExecuteChanged;
    public bool CanExecute(object parameter)
    {
    // 在这里实现命令的可执行逻辑
    return true; // 默认返回true,表示命令可执行
    }
    public void Execute(object parameter)
    {
    // 在这里实现命令的执行逻辑
    _execute?.Invoke();
    }
    }
    }

    之后在MainViewModel类中需要添加一个公共属性来暴露CustomCommand实例作为ShowInfoCommand,以便在XAML中进行绑定;

    using System.Windows;
    namespace WPFDemo
    {
    public class MainViewModel
    {
    public CustomCommand ShowInfoCommand { get; set; }
    public MainViewModel()
    {
    ShowInfoCommand = new CustomCommand(ShowInfo);
    }
    public void ShowInfo()
    {
    MessageBox.Show("You click me.");
    }
    }
    }

    最后,将CustomCommand实例与界面元素进行绑定:

    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:WPFDemo"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="800"
    Height="450"
    mc:Ignorable="d">

    设置数据上下文也可以在后台代码中完成

    show

    回到命令模型上来,梳理以下对应关系:

    1. CustomCommand 类本身是一个命令对象(ICommand),它实现了命令接口,包括 ExecuteCanExecute 方法。这个命令对象是命令模式中的 "具体命令"。
    2. MainViewModel 类作为命令的执行者(或者称为命令目标),其中包含了 ShowInfo 方法。在命令模式中,执行者负责实际执行命令所需的操作。在我们的示例中,ShowInfo 方法是具体执行的业务逻辑。
    3. XAML中,我们使用 CommandBinding 将按钮的 Click 事件与 ShowInfoCommand 关联起来。这个关联是通过在 WindowUserControlCommandBindings 集合中添加一个新的 CommandBinding 对象来完成的。这个 CommandBinding 指定了 ShowInfoCommand 作为命令和 MainViewModel 作为命令的执行者。

    注意:Command属性仅仅作为Click行为的绑定,其他行为,如鼠标移入、移出。。。等行为,要使用另外的MVVM方式进行绑定。

    最后,梳理下程序结构,可以看到,我们分别在MainViewModel.csMainWindow.xaml中书写业务代码和逻辑。

    通知更改

    WPF中,实现属性的通知更改是通过实现 INotifyPropertyChanged 接口来实现的。这个接口定义了一个 PropertyChanged 事件,当属性的值发生变化时,可以通过触发该事件来通知界面进行更新。

    在进行演示之前,先来看看我们上面所使用的例子能否像之前学习的绑定一样实现自动更新,按照业务分离的逻辑,我们在MainViewModel.cs中添加一个Name字段并在页面进行绑定。

    MainViewModel.cs

    using System.Windows;
    namespace WPFDemo
    {
    public class MainViewModel
    {
    public CustomCommand ShowInfoCommand { get; set; }
    public string Name { get; set; }
    public MainViewModel()
    {
    Name = "狗熊岭第一狙击手";
    ShowInfoCommand = new CustomCommand(ShowInfo);
    }
    public void ShowInfo()
    {
    Name = "光头强";
    MessageBox.Show("You click me.");
    }
    }
    }

    MainWindow.xaml

    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:WPFDemo"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="800"
    Height="450"
    WindowStartupLocation="CenterScreen"
    mc:Ignorable="d">

    之前xaml中的数据上下文不太好,需要在每个控件中都定义一次😎

    上面的代码中,我们将Name绑定到了TextBox的文本中,并在点击按钮是改变Name的值,如果它自己可以通知更改,自动更新的话那么在Name变化的时候文本也应该变化,对吧。我们运行试一下:

    show

    可以看到Name的变化时Text并没有随之变化,这说明Name发生改变以后并没有通知Text也进行变化。如果你喜欢捣鼓的话,可以看看Text如果被修改以后Name会不会变化。

    回到正题,上文提到过,实现属性的通知更改是通过实现 INotifyPropertyChanged 接口来实现的,我们来对自定义的MainViewModel.cs稍作修改实现属性的通知更改:

    using System.ComponentModel;
    using System.Windows;
    namespace WPFDemo
    {
    public class MainViewModel : INotifyPropertyChanged
    {
    public CustomCommand ShowInfoCommand { get; set; }
    public event PropertyChangedEventHandler PropertyChanged;
    private string _name;
    public string Name
    {
    get { return _name; }
    set
    {
    _name = value;
    OnPropertyChanged(nameof(Name));
    }
    }
    private void OnPropertyChanged(string name)
    {
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
    public MainViewModel()
    {
    Name = "狗熊岭第一狙击手";
    ShowInfoCommand = new CustomCommand(ShowInfo);
    }
    public void ShowInfo()
    {
    Name = "光头强";
    MessageBox.Show("You click me.");
    }
    }
    }

    💭nameof(Name) 是C# 6.0 引入的一个语法糖,它可以在编译时获取属性、方法、字段、类型等成员的名称作为一个字符串。

    WPF中,nameof(Name) 用于在属性更改通知中指定属性的名称。它的作用是避免硬编码属性名称,从而减少在重构过程中出现由于重命名属性而导致的错误。

    修改过后,MainViewModel 类实现了 INotifyPropertyChanged 接口,并在 Name 属性的 setter 中进行了属性更改通知。当 Name 属性的值发生变化时,会触发 PropertyChanged 事件,通知界面进行更新。

    show

    进阶玩法

    在实现通知更改的方式中,可以将通知更改的逻辑定义在一个基类中,例如 ViewModelBase 类。这个基类可以包含通用的属性更改通知实现,以便其他具体的视图模型类可以继承该基类并重用这些通知更改的功能。

    以下是一个简单的示例,展示了如何在 ViewModelBase 类中实现通知更改:

    using System.ComponentModel;
    using System.Runtime.CompilerServices;
    namespace WPFDemo
    {
    public class ViewModelBase : INotifyPropertyChanged
    {
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
    }
    }

    在上面的代码中,ViewModelBase 类实现了 INotifyPropertyChanged 接口,并提供了 OnPropertyChanged 方法用于触发属性更改通知事件,并将属性名称作为参数传递。

    默认情况下,CallerMemberName 特性用于自动获取调用该方法的成员的名称,并作为属性名称传递。

    通过继承 ViewModelBase 类,并使用 OnPropertyChanged 方法来设置属性,可以简化视图模型类中属性更改通知的实现:

    MainViewModel.cs

    using System.Windows;
    namespace WPFDemo
    {
    public class MainViewModel : ViewModelBase
    {
    public CustomCommand ShowInfoCommand { get; set; }
    private string _name;
    public string Name
    {
    get { return _name; }
    set
    {
    _name = value;
    OnPropertyChanged();
    }
    }
    private string _description;
    public string Description
    {
    get { return _description; }
    set { _description = value; OnPropertyChanged(); }
    }
    public MainViewModel()
    {
    Name = "狗熊岭第一狙击手";
    Description = "光头强";
    ShowInfoCommand = new CustomCommand(ShowInfo);
    }
    public void ShowInfo()
    {
    Name = "光头强";
    Description = "狗熊岭第一突破手,麦克阿瑟如是说。";
    MessageBox.Show("You click me.");
    }
    }
    }

    show

    在这个示例中,MainViewModel 类继承了 ViewModelBase 类,并使用 OnPropertyChanged 方法来设置 Name 属性。当 Name 属性的值发生更改时,SetProperty 方法会自动处理属性更改通知的逻辑,无需手动触发事件或编写重复的代码。

    通过将通知更改的实现定义在基类中,可以实现更简洁、可维护和可重用的代码,避免在每个具体的视图模型类中重复编写通知更改的逻辑。

    夹带私货 - 命令参数

    有时候我们需要在执行命令时传递参数。在WPF中,可以使用CommandParameter属性来传递参数给命令。

    CommandParameter是一个附加属性,可以将任意对象指定为命令的参数。当命令执行时,命令的Execute方法会接收到该参数,并可以在命令处理逻辑中使用它。

    以下是一个示例,展示如何在XAML中为命令指定CommandParameter