命令系统

图形界面软件中经常有类似这样的情况:一个保存功能有多个入口,工具栏的保存按钮、菜单栏的文件 -> 保存菜单、快捷键Ctrl + S等,遇到这种情况,我们通常会写一个保存方法,然后让各个入口调用这个统一的保存方法,还要到处维护能否保存这一可用性状态,非常混乱。WPF的命令系统就是为了解决这一问题而设计的,WPF中命令(Command)抽象了一段逻辑“做什么”和“能不能做”,UI元素只负责绑定命令,执行的时机、可用性的判断全部由命令统一管理,由此虽然又增加了一层命令的概念,但将界面逻辑从控制事件的耦合中解放了出来。

内置路由命令

WPF内置了一批RoutedUICommand,它们按途分组放在几个静态类里。

静态类 常用命令
ApplicationCommands NewOpenSaveCloseUndoCopyPaste
EditingCommands ToggleBoldToggleItalicIncreaseFontSizeMoveToLineEnd等编辑命令
NavigationCommands BrowseBackNextPage等导航命令
MediaCommands PlayPause等媒体命令
ComponentCommands MoveDownScrollPageUp

内置命令本身不含任何执行逻辑,它只是一个标准化的“身份标识”,具体的执行逻辑写在响应命令的控件内部,例如TextBox内部实现了对Copy的响应逻辑,所以给菜单项挂上ApplicationCommands.Copy,点击该菜单项就能对当前焦点输入框执行复制。对于没有控件内部处理的命令,则需要我们用CommandBinding把命令和逻辑连接起来。

此外,从名字也可以看出,这些内置命令其实都是“路由命令”。RoutedUICommand本身是RoutedCommand的子类,前面章节我们曾介绍过路由事件的概念,事件和命令其实是底层和上层的关系,这些路由命令也会沿着视觉树从触发元素向上冒泡,直到找到匹配的CommandBinding

CommandBinding 命令绑定声明

CommandBinding是WPF中的一个类,它被称为命令绑定声明,简而言之就是用于声明绑定命令和具体处理逻辑的,它需要指定执行逻辑Executed和可用判断CanExecute。下面是一个保存功能的示例,当输入框有内容时,点击文件 -> 保存菜单或保存按钮时,可以弹出已保存提示消息,当输入框无内容时,保存菜单和保存按钮被禁用,二者的状态会随着输入框中的内容动态判断和变化。

<Window x:Class="Gacfox.Demo.MainWindow"
        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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.CommandBindings>
        <CommandBinding Command="ApplicationCommands.Save"
                        Executed="Save_Executed"
                        CanExecute="Save_CanExecute"/>
    </Window.CommandBindings>
    <DockPanel>
        <Menu DockPanel.Dock="Top">
            <MenuItem Header="文件(_F)">
                <MenuItem Header="保存" Command="ApplicationCommands.Save"/>
            </MenuItem>
        </Menu>
        <StackPanel Margin="20">
            <TextBox x:Name="contentBox" Height="120" TextWrapping="Wrap"
                     AcceptsReturn="True" TextChanged="ContentBox_TextChanged"/>
            <Button Content="保存" Command="ApplicationCommands.Save"
                    Width="80" Margin="0,10,0,0" HorizontalAlignment="Left"/>
        </StackPanel>
    </DockPanel>
</Window>
using System.Windows;
using System.Windows.Input;

namespace Gacfox.Demo;

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

    private void Save_CanExecute(object sender, CanExecuteRoutedEventArgs e)
    {
        e.CanExecute = !string.IsNullOrWhiteSpace(contentBox.Text);
    }

    private void Save_Executed(object sender, ExecutedRoutedEventArgs e)
    {
        MessageBox.Show($"已保存:{contentBox.Text}");
    }

    private void ContentBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
    {
        // 通知WPF重新查询所有命令的CanExecute状态
        CommandManager.InvalidateRequerySuggested();
    }
}

XAML中,我们使用<Window.CommandBindings>声明ApplicationCommands.Save这个RoutedUICommand绑定Save_ExecutedSave_CanExecute两个方法的逻辑,Save_Executed是命令的具体执行逻辑,Save_CanExecute是用于判断命令能否执行的逻辑。随后我们声明了保存菜单项和保存按钮,它们都被绑定到了ApplicationCommands.Save这个内置命令上,因此,这两个入口的可用性判断都被绑定到了Save_CanExecute方法,从这两个入口都能触发Save_Executed逻辑。至于TextBox,我们给它绑定了一个文本发生变化时的处理逻辑ContentBox_TextChanged,它用于实现当文本被改变时通知WPF重新查询所有命令的CanExecute状态。

注:我们会看到当CanExecute返回否时,菜单项和按钮被神奇的置灰了,这些其实是WPF菜单和按钮控件内置的逻辑,当绑定的命令不可用时就会自动置灰。

此外我们还注意到,由于CommandBinding是挂在Window上的,但我们使用的ApplicationCommands.Save是内置路由命令,窗口内任何地方的控件挂载这个命令都能找到这份逻辑,这也正是路由事件机制在命令上的应用。

至于最后的C#代码中,我们为什么要“通知WPF重新查询所有命令的CanExecute状态”?因为WPF命令系统不会自动侦测所有可能影响CanExecute结果的UI变化!这不是一个容易实现的逻辑,WPF出于性能、关系不确定性、避免循环依赖和职责边界等因素考虑,没有选择这样做,因此CommandManager.InvalidateRequerySuggested()需要我们手动触发。如果你没有手动在特定时机明确执行CommandManager.InvalidateRequerySuggested(),菜单和按钮的可用状态更新会变得滞后或不确定,它们在输入文字时可能不会立即变为可用,删除文字时也可能不会立即变为不可用。

ICommand接口与MVVM中的RelayCommand

WPF中,所有命令都是ICommand接口的实现。

public interface ICommand
{
    void Execute(object? parameter);
    bool CanExecute(object? parameter);
    event EventHandler? CanExecuteChanged;
}
  • Execute:命令执行逻辑,命令被触发时调用
  • CanExecute:判断命令能否执行,WPF中很多控件基于它来决定关联控件的IsEnabled
  • CanExecuteChanged:当CanExecute的结果可能变化时触发,通知WPF重新查询

前面代码中,我们使用CommandBinding的方式进行命令绑定声明,但其实我们仍把相关逻辑写在了窗体的代码隐藏类中,这仍不够解耦。在MVVM开发模式中,我们通常希望将命令的逻辑封装到ViewModel中,而非窗体的代码隐藏类里,RoutedCommand是做不到这一点的。在MVVM实践中,因此诞生了经典的RelayCommand,它是一个通用的ICommand实现,构造时传入两个委托,中继(Relay)了执行和判断逻辑。

下面我们直接看一个完整的例子,它的功能和之前完全相同,但我们使用了自定义的RelayCommand和MVVM模式,将文本框的变化通知、命令的实现逻辑代码隐藏类解耦。

classDiagram
    %% 定义接口
    class ICommand {
        <<interface>>
        +Execute(object? parameter)
        +CanExecute(object? parameter) bool
        +CanExecuteChanged event
    }

    class INotifyPropertyChanged {
        <<interface>>
        +PropertyChanged event
    }

    %% 定义具体实现类
    class RelayCommand {
        -Action~object?~ _execute
        -Predicate~object?~? _canExecute
        +RelayCommand(Action~object?~, Predicate~object?~?)
        +Execute(object? parameter)
        +CanExecute(object? parameter) bool
        +RaiseCanExecuteChanged()
    }

    class MainViewModel {
        -string? _content
        +string? Content
        +RelayCommand SaveCommand
        +MainViewModel()
        +OnPropertyChanged(string propName)
    }

    class MainWindow {
        +MainWindow()
    }

    %% 关系连线
    ICommand <|.. RelayCommand : 实现
    INotifyPropertyChanged <|.. MainViewModel : 实现
    MainWindow --> MainViewModel : DataContext (依赖)
    MainViewModel --> RelayCommand : 持有 (SaveCommand)
    MainWindow ..> MainViewModel : XAML 双向绑定 (Content/SaveCommand)
<Window x:Class="Gacfox.Demo.MainWindow"
        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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <DockPanel>
        <Menu DockPanel.Dock="Top">
            <MenuItem Header="文件(_F)">
                <MenuItem Header="保存" Command="{Binding SaveCommand}"/>
            </MenuItem>
        </Menu>
        <StackPanel Margin="20">
            <TextBox x:Name="contentBox" Height="120" TextWrapping="Wrap"
                     AcceptsReturn="True" Text="{Binding Content, UpdateSourceTrigger=PropertyChanged}"/>
            <Button Content="保存" Command="{Binding SaveCommand}"
                    Width="80" Margin="0,10,0,0" HorizontalAlignment="Left"/>
        </StackPanel>
    </DockPanel>
</Window>

XAML中,我们没有在此处用CommandBinding,我们将菜单和保存按钮都绑定到了自定义的SaveCommand上,它其实来自DataContext,具体来DataContext是我们的ViewModel,而SaveCommand是ViewModel实例的一个属性,具体类型是RelayCommand。此外,我们还把TextBox的数据绑定到了ViewModel,它的用途和之前一样,用来通知WPF重新查询CanExecute

using System.Windows;

namespace Gacfox.Demo;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        DataContext = new MainViewModel();
    }
}

代码隐藏类中的逻辑十分简单,我们实例化ViewModel作为DataContext。

using System.ComponentModel;
using System.Windows;

namespace Gacfox.Demo;

public class MainViewModel : INotifyPropertyChanged
{
    private string? _content;
    public string? Content
    {
        get => _content;
        set
        {
            if (_content != value)
            {
                _content = value;
                OnPropertyChanged(nameof(Content));
                SaveCommand.RaiseCanExecuteChanged();
            }
        }
    }

    public RelayCommand SaveCommand { get; }

    public MainViewModel()
    {
        SaveCommand = new RelayCommand(
            execute: _ => MessageBox.Show($"已保存:{Content}"),
            canExecute: _ => !string.IsNullOrWhiteSpace(Content));
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged(string propName)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
}

ViewModel中,我们主要做了两部分操作,一个是监听文本框的变更,另一个是实现SaveCommand命令的具体ExecuteCanExecute逻辑,具体逻辑和之前相同。

using System.Windows.Input;

namespace Gacfox.Demo;

public class RelayCommand : ICommand
{
    private readonly Action<object?> _execute;
    private readonly Predicate<object?>? _canExecute;

    public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
    {
        _execute = execute ?? throw new ArgumentNullException(nameof(execute));
        _canExecute = canExecute;
    }

    public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;

    public void Execute(object? parameter) => _execute(parameter);

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

    public void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested();
}

我们之前的SaveCommand其实就是RelayCommand,它是一个可复用的中继命令类,它能让View和ViewModel之间通过命令通信。

注意:实际开发中,我们完全不需要手动实现RelayCommand,这里仅作原理演示。RelayCommand虽然不是WPF框架的内置功能,但经过多年的发展,社区已经有了一套成熟的、官方推荐的最佳实践,例如微软官方维护的CommunityToolkit.Mvvm就实现了RelayCommand,有关MVVM的内容我们将在后续章节继续介绍。

CommandParameter 命令参数

同一个命令被多个控件复用时,我们可以靠CommandParameter区分来源,例如计算器界面上一排数字按钮共用一个命令就可以使用命令参数实现。

<Window x:Class="Gacfox.Demo.MainWindow"
        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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Gacfox.Demo"
        mc:Ignorable="d"
        Title="MainWindow" Height="100" Width="800">
    <StackPanel Orientation="Horizontal">
        <Button Content="1" Width="50" Command="{Binding InputCommand}" CommandParameter="1"/>
        <Button Content="2" Width="50" Command="{Binding InputCommand}" CommandParameter="2"/>
        <Button Content="3" Width="50" Command="{Binding InputCommand}" CommandParameter="3"/>
        <Label Content="{Binding Display}"/>
    </StackPanel>
</Window>
using System.Windows;

namespace Gacfox.Demo;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        DataContext = new MainViewModel();
    }
}
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Gacfox.Demo;

public class MainViewModel : INotifyPropertyChanged
{
    private string? _display = string.Empty;

    public string? Display
    {
        get => _display;
        set
        {
            if (_display != value)
            {
                _display = value;
                OnPropertyChanged();
            }
        }
    }

    public RelayCommand InputCommand { get; }

    public MainViewModel()
    {
        InputCommand = new RelayCommand(
            execute: parameter => Display += parameter?.ToString()
        );
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

XAML代码中,我们为按钮绑定了自定义的InputCommand,Label则绑定了ViewModel中的Display属性,ViewModel中我们一方面实现了命令的处理逻辑,另一方面实现了INotifyPropertyChanged,用于在Display属性变更时更新UI界面,让Display中的内容显示在Label中。

InputBindings 快捷键绑定

内置命令中很多自带快捷键,例如ApplicationCommands.Save自带Ctrl + S,自定义命令可以通过InputBindings挂载快捷键。InputBindings是元素级的输入绑定集合,它能将按键或鼠标手势映射到命令。

<Window.InputBindings>
    <KeyBinding Key="S" Modifiers="Ctrl" Command="{Binding SaveCommand}"/>
    <KeyBinding Key="F5" Command="{Binding RefreshCommand}"/>
</Window.InputBindings>
作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。