MVVM (Model-View-ViewModel)
我们之前实现了一个最简单的计数器,就需要在后台代码中多写大约四十行代码。当我们开发一个体量更大、业务逻辑更复杂的应用时,如果还是将所有逻辑都堆在一个小小的MainWindow.xaml.cs里,代码将会变得非常冗长且难以维护。因此我们需要一种设计模式来解耦界面与业务逻辑。
MVVM 是一种前端/客户端架构模式,全称 Model–View–ViewModel,常见于桌面应用和前端框架中,核心目标是降低界面与业务逻辑之间的耦合度,提升可维护性与可测试性。
一、MVVM 的三大组成部分
1. Model(模型)
表示业务数据与业务规则
负责数据结构、数据校验、持久化、接口调用等
不关心界面如何展示
示例:
2. View(视图)
负责界面展示
在 WinUI 中就是 XAML 代码
只包含声明式 UI,不包含业务逻辑
特点:
3. ViewModel(视图模型)
职责:
暴露可绑定的数据(属性)
暴露可触发的行为(方法 / 命令)
调用 Model 完成业务处理
二、MVVM 的核心机制:数据绑定
MVVM 的关键在于数据绑定(Data Binding)。
1. 单向绑定
数据从 ViewModel → View
适合展示型数据
2. 双向绑定
View 改变会同步到 ViewModel
ViewModel 改变会同步到 View
常用于表单输入
数据绑定使得 View 与 ViewModel 之间不需要直接调用彼此的方法。
三、MVVM 的工作流程
View 通过绑定读取 ViewModel 的数据
用户在 View 上产生操作(点击、输入)
事件触发 ViewModel 中的方法
ViewModel 调用 Model 更新数据
数据变化自动反映到 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;
}
}
|
后台代码又变得臃肿了。这里存在的问题:
逻辑重复与散乱: 你必须为每个按钮都写一个 Click,虽然你可以把逻辑抽离到ViewModel中,但你依然要在多个地方调用它。
状态同步的“胶水代码”爆炸: 你必须手动指名道姓地告诉 DecrementButton 该变灰还是变亮。
- 如果你以后增加了一个重置按钮,必须记得处理“-1”按钮的可用性。漏掉一处,UI 状态就不统一了。
难以进行自动化测试: 如果你想写一个测试用例,确认“当计数小于等于 0 时,“-1”按钮不可用”。由于逻辑写在 Click 事件(甚至是 private 私有方法)里,你的测试程序无法在不启动界面的情况下调用这些方法。
UI 强耦合: 这段逻辑里出现了 DecrementButton 具体控件的名字。如果你决定把按钮删了,换成一个可点击的图标,你的后台代码就会因为找不到 DecrementButton 这个变量名而编译报错。
有没有办法既能自动管理控件状态,又能打造一个统一的、UI 无关的逻辑出口呢?
Command
如果说 Data Binding(数据绑定) 是让 UI 自动显示 ViewModel 里的数据,那么 Command(命令) 就是让 UI 能够调用 ViewModel 里的功能。
Command 的优势:
Command 的核心协议:ICommand 接口
在 .NET 中,所有的命令都遵循 ICommand 接口。它包含三个核心成员:
Execute (执行):
- 当用户点击按钮时,要运行的代码逻辑。
CanExecute (能否执行):
- 一个返回布尔值(true/false)的函数。如果返回
false,绑定了这个命令的按钮会自动变灰(禁用)。
CanExecuteChanged (执行状态改变):
- 当“能否执行”的条件发生变化时(比如计数小于 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 中修改 Button 的 Command 绑定:
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 是微软官方推荐的现代 MVVM 库。它利用 C# 的源生成器,在编译时自动生成冗长的样板代码(例如前面的 INotifyPropertyChanged 和 ICommand 实现),让开发者只需关注业务逻辑。
通过 NuGet 安装包:CommunityToolkit.Mvvm
引入命名空间:
需要 ObservableObject 时使用 using CommunityToolkit.Mvvm.ComponentModel
需要 RelayCommand 时使用 using CommunityToolkit.Mvvm.Input
使用 ObservableProperty 简化实现通知:
1
2
3
4
5
6
7
8
| // 类必须是 partial(部分类),且继承 ObservableObject
public partial class UserViewModel : ObservableObject
{
// 在私有字段上标注 [ObservableProperty]
// 编译器会自动生成对应的公有属性 "UserName",并包含完整的通知逻辑
[ObservableProperty]
private string userName;
}
|
- 使用
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 应用了!