WPF 学习笔记(六):命令系统与 CommunityToolkit.Mvvm

写在前面

版本说明:基于 .NET 8 LTS + CommunityToolkit.Mvvm 8.2+。

承接上一篇数据绑定。绑定解决了"显示同步",但用户点击按钮、敲键盘这些"操作"怎么处理?传统做法是写 Button.Click += Handler——但这把 UI 和业务逻辑焊死了。WPF 给的答案是命令(Command)

本篇聚焦命令系统本身和 CommunityToolkit.Mvvm 的源生成器——下一章把 MVVM 落到完整工程实践。

本文要回答:

ICommand 接口在干嘛?为什么 [ObservableProperty] 一个特性就能替代 30 行 INPC 样板代码?源生成器到底生成了什么?


一、为什么不用事件而用命令

1.1 事件的问题

1
2
3
4
5
6
7
// 传统事件
button.Click += OnButtonClick;

private void OnButtonClick(object sender, RoutedEventArgs e)
{
    // 业务逻辑
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
问题:
  1. UI 和逻辑耦合
     → 这段代码必须在 Window 类里(事件源是 button)
     → 不能放到独立的 ViewModel

  2. 无法"自动禁用"
     → 业务规则"未输入用户名时不能提交"
     → 要手写 button.IsEnabled = !string.IsNullOrEmpty(...)
     → 规则一变要改多处

  3. 难以复用
     → 同一个"保存"操作,可能在按钮、菜单、快捷键触发
     → 事件要分别挂

  4. 难以测试
     → 单元测试要 mock Window,复杂

后端类比:
  事件 = WinForms 思路(UI 直接挂逻辑)
  命令 = MVC 的 Controller Action(UI 调用业务命令)

1.2 命令的解决思路

1
2
3
4
5
// ViewModel 暴露命令
public ICommand SaveCommand { get; }

// XAML 绑定
<Button Content="保存" Command="{Binding SaveCommand}"/>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
好处:
  1. UI 和逻辑解耦
     → ViewModel 提供 ICommand
     → View 绑定即可
     → 同一个命令绑定到按钮、菜单、快捷键都行

  2. 自动启用/禁用
     → ICommand.CanExecute 决定按钮是否可用
     → CanExecute 变化自动通知 UI

  3. 可测试
     → 单元测试直接调 SaveCommand.Execute()
     → 不需要 UI

二、ICommand 接口

2.1 接口定义

1
2
3
4
5
6
public interface ICommand
{
    void Execute(object parameter);               // 执行操作
    bool CanExecute(object parameter);            // 是否可执行
    event EventHandler CanExecuteChanged;         // 可执行状态变化
}

2.2 三个成员的语义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Execute(parameter):
  → 真正执行业务逻辑
  → UI 触发命令时调用
  → parameter 来自 CommandParameter(XAML 可设)

CanExecute(parameter):
  → 返回 true/false
  → true:UI 控件启用;false:UI 控件灰掉
  → 频繁调用(每次 CanExecuteChanged 都会查)

CanExecuteChanged:
  → 事件,告诉 UI"快重新查 CanExecute"
  → 触发后 UI 调 CanExecute
  → 通常转订阅 CommandManager.RequerySuggested(自动触发)

2.3 一个简单的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RelayCommand : ICommand
{
    private readonly Action _execute;
    private readonly Func<bool> _canExecute;

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

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

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

    // 关键:转订阅 CommandManager
    public event EventHandler CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }
}

2.4 使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class MainViewModel
{
    public string UserName { get; set; }

    public ICommand SaveCommand { get; }

    public MainViewModel()
    {
        SaveCommand = new RelayCommand(
            execute: () => MessageBox.Show($"Saving {UserName}"),
            canExecute: () => !string.IsNullOrEmpty(UserName));
    }
}
1
2
3
<TextBox Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}"/>
<Button Content="保存" Command="{Binding SaveCommand}"/>
<!-- UserName 为空时按钮自动灰掉 -->

三、CommandManager.RequerySuggested

这是 ICommand 的"自动重新评估"机制。

3.1 它在干什么

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CommandManager.RequerySuggested 是一个全局事件
WPF 在某些时机自动触发它:
  - 用户输入(按键、点击)
  - 焦点变化
  - 鼠标移动
  - 鼠标空闲(每隔一段时间)

触发后:
  - 所有订阅 CanExecuteChanged 的命令重新调 CanExecute
  - UI 根据返回值刷新控件状态

3.2 优缺点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
优点:
  - 写 ViewModel 不用关心"何时通知 CanExecute"
  - 系统自动重新评估

缺点:
  - 性能:全局事件,大量命令时频繁调用 CanExecute
  - 不精确:CanExecute 实际只依赖某几个属性,但每次都查
  - 延迟:依赖"空闲时刻",不是属性变化的瞬间

CommunityToolkit.Mvvm 的 RelayCommand 不用 CommandManager
  → 用显式 NotifyCanExecuteChangedFor
  → 更精确,但要求 ViewModel 主动通知

四、RoutedCommand vs RelayCommand

WPF 内建一种 RoutedCommand,但和 MVVM 不太合。

4.1 RoutedCommand

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 定义
public static readonly RoutedCommand SaveCommand =
    new RoutedCommand("Save", typeof(MyWindow));

// XAML 绑定
<Button Command="{x:Static local:MyWindow.SaveCommand}"/>

// 必须有 CommandBinding 处理
<Window.CommandBindings>
    <CommandBinding Command="{x:Static local:MyWindow.SaveCommand}"
                    Executed="Save_Executed"
                    CanExecute="Save_CanExecute"/>
</Window.CommandBindings>

private void Save_Executed(object sender, ExecutedRoutedEventArgs e) { ... }
private void Save_CanExecute(object sender, CanExecuteRoutedEventArgs e)
    => e.CanExecute = !string.IsNullOrEmpty(UserName);

4.2 RoutedCommand 的问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
特点:
  - 命令是路由事件(沿视觉树传播)
  - CommandBinding 必须挂在视觉树某节点上
  - 处理逻辑在 code-behind(不在 ViewModel)

为什么和 MVVM 不合:
  → 命令逻辑要写在 View(code-behind)
  → ViewModel 不能直接处理命令
  → 等于又把 UI 和逻辑耦合了

适用场景:
  - ApplicationCommands.Copy/Cut/Paste(系统命令)
  - NavigationCommands(导航)
  - 这些"控件级"命令,RoutedCommand 更合适

业务命令:
  - 用 RelayCommand(或 CommunityToolkit 的 [RelayCommand])
  - 命令在 ViewModel 里,可测试

4.3 对比

维度RoutedCommandRelayCommand
命令来源静态定义ViewModel 实例属性
处理位置CommandBinding(视觉树)ViewModel
是否路由是(沿树)
CanExecuteCommandBinding 提供闭包 / 方法
适合 MVVM
内建命令ApplicationCommands无(自己写或用 Toolkit)

五、CommunityToolkit.Mvvm 概览

微软出的现代 MVVM 库,零样板代码。

5.1 安装

1
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2"/>

5.2 三个核心组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ObservableObject:
  → 实现 INPC 的基类
  → 提供 SetProperty<T>(ref field, value) 方法

[ObservableProperty] 特性:
  → 给 private 字段加,自动生成 public 属性 + INPC
  → 字段 _name → 属性 Name

[RelayCommand] 特性:
  → 给方法加,自动生成 ICommand 属性
  → 方法 Save() → 属性 SaveCommand

5.3 用法对比

传统写法(35 行)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MainViewModel : INotifyPropertyChanged
{
    private string _userName;
    public string UserName
    {
        get => _userName;
        set
        {
            if (_userName != value)
            {
                _userName = value;
                OnPropertyChanged(nameof(UserName));
                OnPropertyChanged(nameof(CanSave));
            }
        }
    }

    public bool CanSave => !string.IsNullOrEmpty(UserName);

    public ICommand SaveCommand { get; }

    public MainViewModel()
    {
        SaveCommand = new RelayCommand(() => Save(), () => CanSave);
    }

    private void Save() { /* ... */ }

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

Toolkit 写法(10 行)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private string _userName;

    [RelayCommand(CanExecute = nameof(CanSave))]
    private void Save() { /* ... */ }

    public bool CanSave => !string.IsNullOrEmpty(UserName);
}
1
2
3
4
5
关键观察:
  - 字段 _userName 自动变成属性 UserName(带 INPC)
  - 方法 Save() 自动变成 ICommand 属性 SaveCommand
  - UserName 变化时 CanSave 也会重新评估(通过 NotifyCanExecuteChangedFor)
  - 必须标 partial(源生成器要补充代码)

六、[ObservableProperty] 源生成器原理

这是 CommunityToolkit.Mvvm 最神奇的地方。理解了原理,使用时心里就有底。

6.1 输入

1
2
3
4
5
public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private string _userName;
}

6.2 生成的代码(简化)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 自动生成的 MainViewModel.cs(实际在 obj/ 目录)
public partial class MainViewModel
{
    public string UserName
    {
        get => _userName;
        set
        {
            if (!EqualityComparer<string>.Default.Equals(_userName, value))
            {
                OnUserNameChanging(value);
                OnPropertyChanging(new PropertyChangingEventArgs("UserName"));  // 之前
                _userName = value;
                OnPropertyChanged(new PropertyChangedEventArgs("UserName"));   // 之后
                OnUserNameChanged(value);
            }
        }
    }

    // 可重写的 partial 方法(钩子)
    partial void OnUserNameChanging(string value);
    partial void OnUserNameChanged(string value);
}

6.3 字段命名约定

1
2
3
4
5
6
源生成器根据字段名生成属性名:
  _userName      → UserName
  _userNameField → UserNameField
  m_userName     → UserName

去掉前缀(_ / m_ / s_)+ 首字母大写

6.4 partial 方法钩子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private int _age;

    // 钩子:属性变化前
    partial void OnAgeChanging(int value)
    {
        if (value < 0 || value > 150)
            throw new ArgumentOutOfRangeException(nameof(value));
    }

    // 钩子:属性变化后
    partial void OnAgeChanged(int value)
    {
        // 触发别的属性
        OnPropertyChanged(nameof(IsAdult));
    }

    public bool IsAdult => Age >= 18;
}
1
2
3
4
5
6
7
钩子名约定:
  On<PropertyName>Changing(T newValue)
  On<PropertyName>Changed(T newValue)
  On<PropertyName>Changing(T oldValue, T newValue)(可选 old)

不用写完整的 OnPropertyChanged 调用,框架已经做了
你只在钩子里写"额外副作用"

七、[RelayCommand] 源生成器

7.1 输入

1
2
3
4
5
public partial class MainViewModel : ObservableObject
{
    [RelayCommand]
    private void Save() { /* ... */ }
}

7.2 生成的代码(简化)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public partial class MainViewModel
{
    private RelayCommand? _saveCommand;

    public IRelayCommand SaveCommand
    {
        get => _saveCommand ??= new RelayCommand(Save);
    }

    // 原方法的 partial 包装(如果需要异步等)
}
1
2
3
4
5
命名约定:
  方法 Save() → 属性 SaveCommand(自动加 Command 后缀)

  这就是为什么 XAML 写:
  <Button Command="{Binding SaveCommand}"/>

7.3 带 CanExecute

1
2
3
4
5
6
7
public partial class MainViewModel : ObservableObject
{
    [RelayCommand(CanExecute = nameof(CanSave))]
    private void Save() { /* ... */ }

    private bool CanSave() => !string.IsNullOrEmpty(UserName);
}

生成:

1
public IRelayCommand SaveCommand { get; }  // 内部用 CanSave 作判据

7.4 异步命令

1
2
3
4
5
6
7
8
9
public partial class MainViewModel : ObservableObject
{
    [RelayCommand]
    private async Task LoadDataAsync()
    {
        // await 异步操作
        await Task.Delay(1000);
    }
}

生成 IAsyncRelayCommand,UI 可以等执行完。

1
2
3
4
异步命令的特点:
   自动暴露 IsRunning(命令是否在执行)
   可绑到 UI 显示 loading
   自动防重入(执行中不能再次触发)

7.5 NotifyCanExecuteChangedFor(属性联动)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]   // ← UserName 变了,重新评估 SaveCommand
    private string _userName;

    [RelayCommand(CanExecute = nameof(CanSave))]
    private void Save() { /* ... */ }

    public bool CanSave => !string.IsNullOrEmpty(UserName);
}
1
2
3
4
5
6
7
8
9
效果:
  UserName 变化 → 触发 INPC
              → 同时通知 SaveCommand.CanExecuteChanged
              → UI 重新调 CanSave → 刷新按钮启用状态

对比 CommandManager.RequerySuggested:
  Toolkit 方案精确(只在 UserName 变时刷新 SaveCommand)
  CommandManager 方案全局(每次空闲刷新所有命令)
  → Toolkit 性能更好,但要求显式声明依赖

八、ObservableObject 基类

8.1 三个基类

1
2
3
4
5
6
7
ObservableObject(CommunityToolkit.Mvvm):
  → 实现 INotifyPropertyChanged + INotifyPropertyChanging
  → 提供 SetProperty<T>(ref field, value)

BindableBase(Prism):类似

ViewModelBase(自己写):手撸 INPC

8.2 SetProperty 方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public abstract class ObservableObject : INotifyPropertyChanged, INotifyPropertyChanging
{
    public event PropertyChangedEventHandler? PropertyChanged;
    public event PropertyChangingEventHandler? PropertyChanging;

    protected bool SetProperty<T>(ref T field, T newValue, [CallerMemberName] string? propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, newValue))
            return false;

        OnPropertyChanging(propertyName);
        field = newValue;
        OnPropertyChanged(propertyName);
        return true;
    }
}

8.3 不用 [ObservableProperty] 的写法

1
2
3
4
5
6
7
8
9
public partial class MainViewModel : ObservableObject
{
    private string _userName;
    public string UserName
    {
        get => _userName;
        set => SetProperty(ref _userName, value);
    }
}
1
2
3
4
5
6
7
8
何时不用 [ObservableProperty]:
  → 字段已有复杂逻辑(自定义 setter)
  → 命名冲突
  → 编译器版本不支持 source generator

何时用:
  → 绝大多数场景(90%+)
  → 样板代码消失,更易读

九、命令参数

9.1 CommandParameter

1
2
3
4
<!-- 传参给命令 -->
<Button Content="删除"
        Command="{Binding DeleteCommand}"
        CommandParameter="{Binding SelectedItem}"/>
1
2
3
4
5
6
[RelayCommand]
private void Delete(ItemModel item)
{
    // item 来自 CommandParameter
    if (item != null) Items.Remove(item);
}

9.2 生成的命令带泛型

1
2
// 生成的命令:IRelayCommand<ItemModel>
public IRelayCommand<ItemModel> DeleteCommand { get; }

十、实战:可命令化的简单计算器

把命令 + Toolkit + 绑定全部用上。

10.1 ViewModel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.ComponentModel;

namespace CalculatorDemo;

public partial class CalculatorViewModel : ObservableObject
{
    [ObservableProperty]
    private double _operand1;

    [ObservableProperty]
    private double _operand2;

    [ObservableProperty]
    private double _result;

    [ObservableProperty]
    private string _operator = "+";

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(CalculateCommand))]
    private bool _isCalculating;

    public CalculatorViewModel()
    {
        CalculateCommand = new RelayCommand(Calculate, CanCalculate);
    }

    // 用特性版本的命令
    [RelayCommand(CanExecute = nameof(CanCalculate))]
    private async Task CalculateAsync()
    {
        IsCalculating = true;
        try
        {
            await Task.Delay(500);  // 模拟异步计算

            Result = Operator switch
            {
                "+" => Operand1 + Operand2,
                "-" => Operand1 - Operand2,
                "*" => Operand1 * Operand2,
                "/" => Operand2 != 0 ? Operand1 / Operand2 : 0,
                _ => 0
            };
        }
        finally
        {
            IsCalculating = false;
        }
    }

    // 同步版本(简化演示)
    public IRelayCommand CalculateCommand { get; }

    private void Calculate()
    {
        Result = Operator switch
        {
            "+" => Operand1 + Operand2,
            "-" => Operand1 - Operand2,
            "*" => Operand1 * Operand2,
            "/" => Operand2 != 0 ? Operand1 / Operand2 : 0,
            _ => 0
        };
    }

    private bool CanCalculate()
        => !IsCalculating && Operator != "/" || Operand2 != 0;

    [RelayCommand]
    private void Clear()
    {
        Operand1 = 0;
        Operand2 = 0;
        Result = 0;
    }

    // 依赖属性联动
    partial void OnOperatorChanged(string value)
        => CalculateCommand.NotifyCanExecuteChanged();
}

10.2 XAML

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<Window x:Class="CalculatorDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Calculator" Width="400" Height="300">
    <Window.DataContext>
        <local:CalculatorViewModel/>
    </Window.DataContext>

    <Grid Margin="16">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
            <TextBox Width="100" Text="{Binding Operand1, UpdateSourceTrigger=PropertyChanged}"/>
            <ComboBox Width="50" Margin="8,0" SelectedItem="{Binding Operator}">
                <ComboBoxItem Content="+"/>
                <ComboBoxItem Content="-"/>
                <ComboBoxItem Content="*"/>
                <ComboBoxItem Content="/"/>
            </ComboBox>
            <TextBox Width="100" Text="{Binding Operand2, UpdateSourceTrigger=PropertyChanged}"/>
        </StackPanel>

        <TextBlock Grid.Row="1" Margin="0,8">
            <Run Text="= "/><Run Text="{Binding Result, Mode=OneWay}"/>
        </TextBlock>

        <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8">
            <Button Content="计算" Command="{Binding CalculateCommand}" Width="80" Margin="0,0,8,0"/>
            <Button Content="清空" Command="{Binding ClearCommand}" Width="80"/>
        </StackPanel>

        <TextBlock Grid.Row="3" Text="{Binding IsCalculating, StringFormat=正在计算:{0}}"/>
    </Grid>
</Window>

10.3 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
✅ [ObservableProperty] 字段 → 属性
✅ [RelayCommand] 方法 → ICommand
✅ [NotifyCanExecuteChangedFor] 属性联动命令
✅ partial OnXxxChanged 钩子
✅ CanExecute 业务规则(除数不为 0)
✅ CommandParameter 传参(隐式:通过 IsCalculating)
✅ 异步命令(CalculateAsync,async Task)
✅ 绑定 Mode=OneWay(结果只读)
✅ UpdateSourceTrigger=PropertyChanged(实时同步)

观察:
  - 切换运算符为 "/" 且 Operand2=0 → 计算按钮自动灰掉
  - 输入实时反映到 UI(INPC)
  - 计算中 IsCalculating=true → 按钮禁用

十一、常见陷阱

11.1 忘了 partial

1
2
3
4
5
6
7
8
9
// ❌ 不加 partial,源生成器无法补充
public class MainViewModel : ObservableObject
{
    [ObservableProperty] private string _name;
}
// 编译报错:找不到属性 Name

// ✅
public partial class MainViewModel : ObservableObject { ... }

11.2 字段命名不规范

1
2
3
4
5
6
7
// ❌ 字段没下划线前缀
[ObservableProperty]
private string name;   // 生成的属性可能也是 name(冲突)

// ✅
[ObservableProperty]
private string _name;

11.3 命令名字冲突

1
2
3
4
5
6
7
// ❌ 方法已叫 SaveCommand
[RelayCommand]
private void SaveCommand() { }   // 生成属性也叫 SaveCommand → 冲突

// ✅ 方法叫 Save
[RelayCommand]
private void Save() { }   // 生成属性 SaveCommand

11.4 CanExecute 不通知

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// ❌ UserName 变了但 SaveCommand 不知道
[ObservableProperty]
private string _userName;

[RelayCommand(CanExecute = nameof(CanSave))]
private void Save() { }
public bool CanSave => !string.IsNullOrEmpty(UserName);

// → UserName 改变,按钮启用状态不刷新

// ✅ 加 NotifyCanExecuteChangedFor
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string _userName;

11.5 异步命令没 await

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ❌ fire-and-forget
[RelayCommand]
private async Task LoadAsync()
{
    LoadData();   // 没 await,立即返回,IsRunning=false
}

// ✅
[RelayCommand]
private async Task LoadAsync()
{
    await LoadDataAsync();
}

十二、与 ASP.NET Core 的对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ASP.NET Core                  WPF Command
──────────────────────────────────────────────
Controller Action             ICommand.Execute
[HttpPost] 路由                Command 绑定到 Button
Authorization Filter          ICommand.CanExecute
[FromBody] 参数                CommandParameter
MediatR Command               ICommand(思路一致)
MediatR Handler               Command 内部逻辑

后端类比小结:
  ICommand ≈ MediatR 的 IRequest
  RelayCommand.Execute ≈ Handler.Handle
  CanExecute ≈ Authorization Policy
  CommandParameter ≈ Request DTO

十三、小结

本文聚焦命令系统本身:

  • 事件 vs 命令的本质差别(耦合度、可测试性)
  • ICommand 三成员(Execute / CanExecute / CanExecuteChanged)
  • CommandManager.RequerySuggested 的角色
  • RoutedCommand vs RelayCommand 的选型
  • CommunityToolkit.Mvvm 三件套(ObservableObject / [ObservableProperty] / [RelayCommand])
  • [ObservableProperty] 源生成器原理(生成属性 + INPC + partial 钩子)
  • [RelayCommand] 源生成器原理(生成 ICommand + 自动命名)
  • NotifyCanExecuteChangedFor 的精确通知
  • 异步命令与 IsRunning
  • 实战:可命令化的计算器
1
2
3
4
记住三句话:
  1. ICommand 是"业务操作的抽象"——Execute 是动作,CanExecute 是规则
  2. [ObservableProperty] / [RelayCommand] 是源生成器——零样板但要知道生成了什么
  3. CommunityToolkit 用 NotifyCanExecuteChangedFor 替代 CommandManager——更精确但要求显式声明

下一篇将进入 MVVM 工程化——把命令、绑定、DP 落到完整项目结构,引入 Microsoft.Extensions.DependencyInjection,搭建 TodoApp 完整工程。

Licensed under CC BY-NC-SA 4.0