MVVM (Model-View-ViewModel)

我们之前实现了一个最简单的计数器,就需要在后台代码中多写大约四十行代码。当我们开发一个体量更大、业务逻辑更复杂的应用时,如果还是将所有逻辑都堆在一个小小的MainWindow.xaml.cs里,代码将会变得非常冗长且难以维护。因此我们需要一种设计模式来解耦界面与业务逻辑。

MVVM 是一种前端/客户端架构模式,全称 Model–View–ViewModel,常见于桌面应用和前端框架中,核心目标是降低界面与业务逻辑之间的耦合度,提升可维护性与可测试性。


一、MVVM 的三大组成部分

1. Model(模型)

  • 表示业务数据与业务规则

  • 负责数据结构、数据校验、持久化、接口调用等

  • 不关心界面如何展示

示例:

  • 用户对象、订单对象

  • 从接口获取的数据

  • 业务计算逻辑


2. View(视图)

  • 负责界面展示

  • 在 WinUI 中就是 XAML 代码

  • 只包含声明式 UI,不包含业务逻辑

特点:

  • 通过数据绑定展示状态

  • 通过事件或指令触发交互


3. ViewModel(视图模型)

  • 位于 View 与 Model 之间

  • 负责界面状态与行为逻辑

  • 将 Model 转换为 View 可直接使用的数据结构

职责:

  • 暴露可绑定的数据(属性)

  • 暴露可触发的行为(方法 / 命令)

  • 调用 Model 完成业务处理


二、MVVM 的核心机制:数据绑定

MVVM 的关键在于数据绑定(Data Binding)

1. 单向绑定

  • 数据从 ViewModel → View

  • 适合展示型数据

2. 双向绑定

  • View 改变会同步到 ViewModel

  • ViewModel 改变会同步到 View

  • 常用于表单输入

数据绑定使得 View 与 ViewModel 之间不需要直接调用彼此的方法。


三、MVVM 的工作流程

  1. View 通过绑定读取 ViewModel 的数据

  2. 用户在 View 上产生操作(点击、输入)

  3. 事件触发 ViewModel 中的方法

  4. ViewModel 调用 Model 更新数据

  5. 数据变化自动反映到 View

整个过程中,View 不直接操作 Model,也就实现了解耦。


听上去不错🤔

让我们分析一下刚才的计数器,试着把它改为 MVVM 架构。

Model

很显然我们只用到了一个Counter属性来计数。

1
2
3
4
public class CounterModel
{
    public int Value { get; set; }
}

ViewModel

这里的 ViewModel 应当实现按钮实现增减的逻辑,以及用于数据绑定的INotifyPropertyChanged接口。

 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
public class MainWindowViewModel : INotifyPropertyChanged
{
    private readonly CounterModel model;

    public MainWindowViewModel()
    {
        model = new CounterModel();
    }

    public int Counter
    {
        get => model.Value;
        set
        {
            if (model.Value == value)
            {
                return;
            }

            model.Value = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    public void Increment()
    {
        Counter++;
    }

    public void Decrement()
    {
        Counter--;
    }

    private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        // 使用 [CallerMemberName] 自动获取调用者的属性名(即 "Counter")
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

View

在解耦出 ViewModel 之后,视图的后台代码就简洁明了了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public sealed partial class MainWindow : Window
{
    private readonly MainWindowViewModel viewModel;

    public MainWindow()
    {
        InitializeComponent();
        viewModel = new MainWindowViewModel();
    }

    private void OnIncrementClick(object sender, RoutedEventArgs e)
    {
        viewModel.Increment();
    }

    private void OnDecrementClick(object sender, RoutedEventArgs e)
    {
        viewModel.Decrement();
    }
}
1
2
3
4
5
6
7
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
    <TextBlock x:Name="MyText" Text="{x:Bind viewModel.Counter, Mode=OneWay}"/>
    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
        <Button x:Name="DecrementButton" Content="-1" Click="OnDecrementClick" />
        <Button x:Name="IncrementButton" Content="+1" Click="OnIncrementClick" />
    </StackPanel>
</StackPanel>

看上去很完美,对吗?好,现在我们有一个新的需求:在计数小于等于 0 时禁用“-1”按钮(Button.IsEnabled = false)。我们无法在 ViewModel 中取得这个按钮的实例,所以我们只能在后台代码中实现这一逻辑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private void OnIncrementClick(object sender, RoutedEventArgs e)
{
    viewModel.Increment();
    if(viewModel.Counter > 0)
    {
        DecrementButton.IsEnabled = true;
    }
}

private void OnDecrementClick(object sender, RoutedEventArgs e)
{
    viewModel.Decrement();
    if (viewModel.Counter <= 0)
    {
        DecrementButton.IsEnabled = false;
        return;
    }
}

后台代码又变得臃肿了。这里存在的问题:

  1. 逻辑重复与散乱: 你必须为每个按钮都写一个 Click,虽然你可以把逻辑抽离到ViewModel中,但你依然要在多个地方调用它。

  2. 状态同步的“胶水代码”爆炸: 你必须手动指名道姓地告诉 DecrementButton 该变灰还是变亮。

    1.   如果你以后增加了一个重置按钮,必须记得处理“-1”按钮的可用性。漏掉一处,UI 状态就不统一了。
  3. 难以进行自动化测试: 如果你想写一个测试用例,确认“当计数小于等于 0 时,“-1”按钮不可用”。由于逻辑写在 Click 事件(甚至是 private 私有方法)里,你的测试程序无法在不启动界面的情况下调用这些方法。

  4. UI 强耦合: 这段逻辑里出现了 DecrementButton 具体控件的名字。如果你决定把按钮删了,换成一个可点击的图标,你的后台代码就会因为找不到 DecrementButton 这个变量名而编译报错。

有没有办法既能自动管理控件状态,又能打造一个统一的、UI 无关的逻辑出口呢?

Command

如果说 Data Binding(数据绑定) 是让 UI 自动显示 ViewModel 里的数据,那么 Command(命令) 就是让 UI 能够调用 ViewModel 里的功能。

Command 的优势:

  • 解耦:按钮只需要知道它绑定了哪个 Command,不需要知道逻辑是怎么实现的。

  • 自动状态更新:Command 自带“是否可执行”的判断逻辑,能自动让按钮变灰或启用。


Command 的核心协议:ICommand 接口

在 .NET 中,所有的命令都遵循 ICommand 接口。它包含三个核心成员:

  1. Execute (执行)

    1. 当用户点击按钮时,要运行的代码逻辑。
  2. CanExecute (能否执行)

    1. 一个返回布尔值(true/false)的函数。如果返回 false,绑定了这个命令的按钮会自动变灰(禁用)。
  3. CanExecuteChanged (执行状态改变)

    1. 当“能否执行”的条件发生变化时(比如计数小于 0),通知 UI,让 UI 重新检查 CanExecute 并刷新按钮状态。

如果你为每个按钮都写一个类,代码会爆炸。因此,通用的做法是写一个中转类(RelayCommand),把具体的逻辑通过委托传进去。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public sealed class RelayCommand : ICommand
{
    private readonly Action execute;
    private readonly Func<bool>? canExecute;

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

    public event EventHandler? CanExecuteChanged;

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

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

    public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

相应地修改 ViewModel 以使用 Command

 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
public class MainWindowViewModel : INotifyPropertyChanged
{
    private readonly CounterModel model;
    private readonly RelayCommand incrementCommand;
    private readonly RelayCommand decrementCommand;

    public MainWindowViewModel()
    {
        model = new CounterModel();
        incrementCommand = new RelayCommand(Increment);
        decrementCommand = new RelayCommand(Decrement, CanDecrement);
    }

    public int Counter
    {
        get => model.Value;
        set
        {
            if (model.Value == value)
            {
                return;
            }

            model.Value = value;
            OnPropertyChanged();
            decrementCommand.RaiseCanExecuteChanged();
        }
    }

    public ICommand IncrementCommand => incrementCommand;
    public ICommand DecrementCommand => decrementCommand;

    public event PropertyChangedEventHandler? PropertyChanged;

    private bool CanDecrement() => Counter > 0;

    private void Increment()
    {
        Counter++;
    }

    private void Decrement()
    {
        Counter--;
    }

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

在 XAML 中修改 ButtonCommand 绑定:

1
2
3
4
5
6
7
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
    <TextBlock x:Name="MyText" Text="{x:Bind viewModel.Counter, Mode=OneWay}"/>
    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
        <Button x:Name="DecrementButton" Content="-1" Command="{x:Bind viewModel.DecrementCommand}" />
        <Button x:Name="IncrementButton" Content="+1" Command="{x:Bind viewModel.IncrementCommand}" />
    </StackPanel>
</StackPanel>

这样我们就做到了 UI 元素与后台代码之间的解耦,Button 通过 Command 直接触发 ViewModel 中的逻辑。

CommunityToolkit.Mvvm

CommunityToolkit.Mvvm 是微软官方推荐的现代 MVVM 库。它利用 C# 的源生成器,在编译时自动生成冗长的样板代码(例如前面的 INotifyPropertyChangedICommand 实现),让开发者只需关注业务逻辑。

  1. 通过 NuGet 安装包:CommunityToolkit.Mvvm

  2. 引入命名空间:

    1. 需要 ObservableObject 时使用 using CommunityToolkit.Mvvm.ComponentModel

    2. 需要 RelayCommand 时使用 using CommunityToolkit.Mvvm.Input

  3. 使用 ObservableProperty 简化实现通知:

1
2
3
4
5
6
7
8
// 类必须是 partial(部分类),且继承 ObservableObject
public partial class UserViewModel : ObservableObject
{
    // 在私有字段上标注 [ObservableProperty]
    // 编译器会自动生成对应的公有属性 "UserName",并包含完整的通知逻辑
    [ObservableProperty]
    private string userName;
}
  1. 使用 RelayCommand 简化实现命令:只需在私有或保护方法上标注 [RelayCommand],工具包就会自动生成一个名称后置 Command 的公有属性。

利用这个库,我们丢掉了额外的 RelayCommand 类,并大大简化了 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
public partial class MainWindowViewModel : ObservableObject
{
    private readonly CounterModel model;

    [ObservableProperty]
    private int counter;

    public MainWindowViewModel()
    {
        model = new CounterModel();
        counter = model.Value;
    }

    partial void OnCounterChanged(int value)
    {
        model.Value = value;
        DecrementCommand.NotifyCanExecuteChanged();
        //[ObservableProperty] 会在属性变化时自动调用 partial void OnCounterChanged(...)
        //这里手动实现是为了在 counter 变化时同步 model 并触发 DecrementCommand 的 CanExecute 刷新
        //如果不实现,属性变更仍会通知 UI,但 model 同步和命令状态更新就不会发生
    }

    [RelayCommand]
    private void Increment()
    {
        Counter++;
    }

    private bool CanDecrement() => Counter > 0;

    [RelayCommand(CanExecute = nameof(CanDecrement))]
    private void Decrement()
    {
        Counter--;
    }
}

理解了以上内容,你就能打造一个简单的 WinUI 应用了!