by 雪隐_上班了 · 掘金主页
欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权。
一句话省流版
CommunityToolkit.Mvvm 8.4.2 用源生成器 把"手写 INotifyPropertyChanged + ICommand"这套脏活累活全包了。你只需要写字段 + 贴 attribute,编译器加班帮你生成一切------你摸鱼,代码自己卷自己。
装包
bash
dotnet add package CommunityToolkit.Mvvm --version 8.4.2
就这一个包,搞定。它依赖:
Microsoft.Bcl.AsyncInterfaces(源生成器要用的接口,不用你管)- 不依赖 WPF,纯类库项目也能用(但咱们是 WPF 系列,就不跑题了)
装完就能用,不用配任何东西,比装个微信还简单。
一、[ObservableProperty]:告别 INPC 腱鞘炎
传统写法(手写 INPC,写到手抽筋)
csharp
public class MainViewModel : INotifyPropertyChanged
{
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
就一个字符串字段,写了 17 行模板代码。你想想,一个 ViewModel 少说十几个属性,那不得写到手抽筋、眼发花、腰肌劳损?
现代写法(2 行,不能再多了)
csharp
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _name = string.Empty;
}
就两行。真的就两行。
[ObservableProperty] 是源生成器的 attribute,编译时自动给你生成:
csharp
public string Name
{
get => _name;
set
{
if (!EqualityComparer<string>.Default.Equals(_name, value))
{
_name = value;
OnPropertyChanged(nameof(Name)); // 基类帮你干了活
}
}
}
三个关键点,记牢了:
- 类必须
partial(源生成器要往里头塞代码,你不partial它进不来) - 继承
ObservableObject(基类给你OnPropertyChanged和SetProperty工具方法) - 字段命名约定:
_camelCase→ 属性PascalCase。写了_firstName就生成FirstName,写了_lastName就生成LastName。别自己瞎起名,按规矩来。
联动通知:一个变,全家都变
csharp
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DisplayName))]
private string _firstName = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DisplayName))]
private string _lastName = string.Empty;
public string DisplayName => $"{FirstName} {LastName}".Trim();
}
XAML 里三个控件各绑各的:
xaml
<TextBox Text="{Binding FirstName, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Text="{Binding LastName, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding DisplayName}" />
你改 FirstName,DisplayName 自动刷新;你改 LastName,DisplayName 也跟着刷新。一个人干活,三个人响应,比当代打工人还能卷。
二、[RelayCommand]:命令系统终于能看了
传统 ICommand 写法(还是手抽筋)
csharp
public class MainViewModel : INotifyPropertyChanged
{
private ICommand? _saveCommand;
public ICommand SaveCommand => _saveCommand ??= new RelayCommand(
execute: () => { /* 业务逻辑 */ },
canExecute: () => !string.IsNullOrEmpty(Name));
}
每来一个命令,就得写一个字段 + 一个属性 + 一个 lambda,代码量堪比写小作文。
现代写法(1 行,真的就 1 行)
csharp
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _name = string.Empty;
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
// 业务逻辑
}
private bool CanSave() => !string.IsNullOrEmpty(Name);
}
源生成器自动给你生成:
public IRelayCommand SaveCommand { get; }CanSave返回值变化时,自动调用NotifyCanExecuteChanged()
XAML 绑定:
xaml
<Button Content="保存" Command="{Binding SaveCommand}" />
按钮自动根据 Name 是否为空来启用/禁用,完全不用你手动刷新命令状态。
异步命令:线程不阻塞 UI 是基本素养
csharp
[RelayCommand]
private async Task LoadAsync()
{
IsLoading = true;
try
{
await Task.Delay(2000); // 模拟网络请求
}
finally
{
IsLoading = false;
}
}
生成的是 IAsyncRelayCommand,XAML 绑定方式完全一样:
xaml
<Button Content="加载" Command="{Binding LoadCommand}" />
点一下,按钮自动禁用(防止重复点击),加载完自动启用。连 Loading 状态都不用你手动管,它自己知道。
参数化命令:传参是个小坑
csharp
[RelayCommand]
private void SelectItem(int id)
{
SelectedId = id;
}
XAML:
xaml
<Button Content="选 1" Command="{Binding SelectItemCommand}" CommandParameter="1" />
⚠️ 注意坑: CommandParameter 传过来的是字符串 "1",WPF 不会自动给你转成 int。方法参数是 int 的话,它会直接报错。
解决方案:参数用 string,自己解析:
csharp
[RelayCommand]
private void SelectTool(string kindName)
{
if (Enum.TryParse<AnnotationKind>(kindName, out var kind))
CurrentTool = kind;
}
记住一条铁律:CommandParameter 传过来全是 object,实际值是字符串,别指望 WPF 帮你做类型转换。 你指望它,它就会让你失望。
三、WeakReferenceMessenger:跨 VM 通信,谁也别认识谁
有些场景里,两个 ViewModel 需要沟通,但谁都不该直接 new 对方------否则两个 VM 紧耦合,测试起来想死。
经典场景:左栏选了个相册 → 中栏加载对应图片列表。
不用 Messenger(紧耦合,想死)
csharp
public class AlbumListViewModel
{
private ImageViewModel _imageVm = new ImageViewModel(); // 硬耦合!
public void OnAlbumSelected(int albumId)
{
_imageVm.LoadAlbumImagesAsync(albumId);
}
}
你测 AlbumListViewModel 的时候,还得顺便带个 ImageViewModel 的 mock,一个单元测试写着写着变成了集成测试。
用 Messenger(解耦,各过各的)
第一步:定义消息(就是个 record,一行)
csharp
public record AlbumSelectedMessage(int AlbumId);
第二步:发送方只管发,不管谁收
csharp
public class AlbumListViewModel : ObservableObject
{
[RelayCommand]
private void SelectAlbum(Album album)
{
WeakReferenceMessenger.Default.Send(new AlbumSelectedMessage(album.Id));
}
}
第三步:接收方自己注册,自己处理
csharp
public class ImageViewModel : ObservableObject
{
public ImageViewModel()
{
WeakReferenceMessenger.Default.Register<AlbumSelectedMessage>(this, (r, m) =>
{
if (r is ImageViewModel vm)
_ = vm.LoadAlbumImagesAsync(m.AlbumId);
});
}
}
两个 VM 互相不知道对方存在。发消息的不知道谁在听,听消息的不知道谁发的。 像极了广播电台和收音机的关系------你听你的,我播我的。
关键:WeakReference 防内存泄漏
普通 C# event 订阅会让发布者持有订阅者引用,订阅者不小心就会死不了(内存泄漏)。
WeakReferenceMessenger 内部用弱引用,订阅者只要没有被其他地方强引用,GC 来了就能收走,不拖泥带水。
不过有个小习惯建议养成 :ViewModel 关闭时调用 WeakReferenceMessenger.Default.UnregisterAll(this),虽然弱引用已经不太会泄漏了,但及时清理是成年人的体面。
四、实战小例:Counter(30 行从 0 到跑)
ViewModel(20 行)
csharp
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace CounterApp;
public partial class CounterViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusText))]
private int _count;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(IncrementCommand))]
[NotifyCanExecuteChangedFor(nameof(DecrementCommand))]
private int _maxCount = 10;
public string StatusText => Count >= MaxCount
? "已达上限"
: $"还能加 {MaxCount - Count} 次";
[RelayCommand(CanExecute = nameof(CanIncrement))]
private void Increment() => Count++;
[RelayCommand(CanExecute = nameof(CanDecrement))]
private void Decrement() => Count--;
private bool CanIncrement() => Count < MaxCount;
private bool CanDecrement() => Count > 0;
}
View(10 行 XAML)
xaml
<Window x:Class="CounterApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Counter" Height="200" Width="300">
<StackPanel VerticalAlignment="Center" Margin="20">
<TextBlock Text="{Binding Count}" FontSize="48" HorizontalAlignment="Center" />
<TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center" Margin="0,0,0,10" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="-" Width="60" Margin="5"
Command="{Binding DecrementCommand}" />
<Button Content="+" Width="60" Margin="5"
Command="{Binding IncrementCommand}" />
</StackPanel>
</StackPanel>
</Window>
App.xaml.cs(5 行)
csharp
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var window = new MainWindow { DataContext = new CounterViewModel() };
window.Show();
}
}
跑起来效果:
- Count=0 时,
-按钮灰的(不能减到负数) - Count=10 时,
+按钮灰的(达到上限) - 中间状态正常加减,状态文字实时更新
30 行 实现了"双向 binding + 命令启用/禁用 + 计算属性联动"。手写 INPC + ICommand 至少 80 行起步。这叫降维打击。
五、什么时候不要用三件套(别硬套)
三件套虽好,但不是万能膏药,贴哪都行。这几个场景别硬塞到 ViewModel 里:
- 纯 UI 状态(焦点、鼠标位置、动画进度)→ 放 View 自己管,别污染 ViewModel。你把鼠标坐标塞 ViewModel 里,测试怎么测?毫无意义。
- 高频事件(鼠标 Move、Drag 等)→ 别用 Command,直接事件处理 + View 内部搞定。Command 是为"用户动作触发业务逻辑"设计的,不是为"鼠标每动一次都刷新 UI"设计的。
- 业务逻辑可单测的部分→ 放 ViewModel,不要塞 code-behind。这是 MVVM 的底线。
具体怎么判断?下一篇我专门讲 4 问判别法,帮你一招分清"这个逻辑该放哪"。
最后说句心里话 :CommunityToolkit.Mvvm 这套工具我用了三天就觉得"以前手写 INPC 的我到底在干嘛"------技术选型选对了,相当于上班带了个实习生帮你干杂活。希望这篇能让你少走 3 天弯路。我们下篇见,记得装好 NuGet 包。📦☕️