1. 为什么 CommunityToolkit.Mvvm 值得花时间系统学一遍
大概没有哪个 C# 桌面开发者能绕开 MVVM 这个话题。从 WPF 时代到 WinUI、MAUI,再到 Unity 里的 UI 逻辑组织,只要是带 XAML 的界面,MVVM 永远是绕不开的架构方案。而 CommunityToolkit.Mvvm 这个库,基本上就是目前 .NET 生态里 MVVM 框架的集大成者,没必要再自己造轮子,也没必要去用那些几年不更新、文档稀烂的老牌框架。
先说清楚 MVVM 到底做了什么。老式 WinForms 思路是界面控件直接绑事件,事件里直接改数据、改界面,代码全揉在 Code-Behind 里,项目一大人就疯了。MVVM 的核心是用数据绑定把界面和逻辑拆开:View 只负责展示和交互,ViewModel 暴露属性、命令、状态给 View 用,Model 管业务数据和规则,三者的关系靠绑定和通知机制维系。
CommunityToolkit.Mvvm 就是帮你把这套机制里最繁琐的部分全部自动化。它最核心的卖点是基于源生成器(Source Generator)的 [ObservableProperty] 和 [RelayCommand],直接把以前手写的那一大坨 INotifyPropertyChanged 实现、ICommand 实现全给省了。老实讲,早几年我用 MVVM 的时候,光是一个带校验的属性就得写七八十行代码,现在用这个库只需要一个字段加一个特性。
这套方案还有个特别实际的好处:它是微软官方维护的,跟着 .NET 生态走,不会像某些第三方框架那样突然停更。而且它不强制你用容器、不强制你继承某个特定基类,你想用在 WPF、WinForms、MAUI、Uno Platform 都行,灵活性非常高。
这篇文章我打算把 CommunityToolkit.Mvvm 从安装到实战、从原理到坑位全部拆开讲,适合刚接触 MVVM 的新手,也适合已经手写了很多模板代码、想重构的老手。后面每个点我都会给出实际可跑的代码和我在项目里踩过的真实教训。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心功能拆解:源生成器到底省了多少事
2.1 ObservableProperty:告别二十行 GetSet
以前写一个可绑定的属性,标准姿势是这样的:
csharp复制private string _name;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value;
OnPropertyChanged();
}
}
}
每个属性都要这么来一遍。实体字段一多,整个 ViewModel 全是这种样板代码,真正有业务逻辑的部分反而被淹没了。更烦的是,每写一个属性都得小心翼翼处理 SetProperty 的调用,漏一个通知界面就傻了。
用 [ObservableProperty] 之后,同一个属性只需要这样:
csharp复制[ObservableProperty]
private string _name;
源生成器会在编译时帮你把 Name 属性、通知逻辑、SetProperty 调用全部生成出来。写起来不累,跑起来性能和手写一模一样,因为生成的就是你手写的代码,不存在任何反射开销。
这里有几个细节值得注意。第一,命名规则是字段名是什么,生成的属性名就自动去掉下划线并转成帕斯卡命名法。_userName 会生成 UserName,_orderTotal 会生成 OrderTotal。如果字段没有下划线前缀,就是直接首字母大写。第二,字段必须是 partial 类里——这个类本身得是 partial,因为源生成器不能修改你原来的类,只能通过生成一个兄弟 partial 类来补充代码。
第三,[ObservableProperty] 支持在属性生成时加自定义逻辑。比如我常常需要属性变化时联动更新另一个属性,直接在字段上用 partial void OnNameChanged(string value) 和 partial void OnNameChanging(string value) 这两个钩子方法就行:
csharp复制[ObservableProperty]
private string _name;
partial void OnNameChanged(string value)
{
// 名字变了,顺带更新欢迎语
Greeting = $"你好,{value}";
}
你不需要手动调用 OnPropertyChanged(nameof(Greeting)) 去通知界面,因为 Greeting 本身可能也是个 [ObservableProperty] 属性,自己会发通知。如果 Greeting 是计算属性,那就得用下面的 [NotifyPropertyChangedFor]。
csharp复制[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsEmpty))]
private string _keyword;
public bool IsEmpty => string.IsNullOrEmpty(_keyword);
[NotifyPropertyChangedFor] 表示当 _keyword 变化时自动触发 IsEmpty 的变更通知,这个在实际业务里太常用了,比如搜索框关键字变化时同时刷新“清空按钮是否可见”。
还有 [NotifyCanExecuteChangedFor],用在命令上。比如一个属性会影响某个按钮是否可点,属性变化时命令的执行状态也得刷新,这个特性就是干这个的。
2.2 RelayCommand 与 AsyncRelayCommand:命令的完整解法
ICommand 是 WPF 时代就有的接口,MVVM 里按钮点击、菜单点击、右键菜单这类交互全走它。早期自己实现 ICommand 需要写一个 RelayCommand 类,然后每个命令都要 new 一遍,代码冗余得厉害。
CommunityToolkit.Mvvm 里的 [RelayCommand] 和 [AsyncRelayCommand] 同样用源生成器自动生成命令属性。你只需要写方法,命令属性自动带出来:
csharp复制[RelayCommand]
private void Save()
{
// 保存逻辑
}
// 用法:在 XAML 里绑定 SaveCommand
生成的命令属性名就是方法名加 Command 后缀。Save 对应 SaveCommand,DeleteUser 对应 DeleteUserCommand。
异步版本更常用,因为现代业务里到处是网络请求、数据库读写、文件 IO:
csharp复制[RelayCommand]
private async Task LoadDataAsync()
{
IsBusy = true;
try
{
await _dataService.FetchAsync();
}
finally
{
IsBusy = false;
}
}
AsyncRelayCommand 有几个非常实用的内置行为。第一个是 并发控制,它支持 AllowConcurrentExecutions 参数,默认情况下命令执行期间再次点击会被忽略,避免重复提交,而且 IsRunning 属性会自动变化,你直接绑定它就能控制界面 loading 状态。第二个是 异常处理,命令内部抛异常不会让程序崩掉,你可以订阅 TaskScheduler.UnobservedTaskException 或者给命令的 ExecutionTask 加 continuation 来处理错误。
注意一个常见的误区:CanExecute 的更新时机。命令的可执行状态不会自动刷新,你需要手动通知。最省事的方式是在依赖的字段上用 [NotifyCanExecuteChangedFor(nameof(SaveCommand))],这样字段一变,命令状态自动刷新。
csharp复制[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private bool _hasUnsavedChanges;
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save() { }
private bool CanSave() => HasUnsavedChanges;
这个组合拳我几乎在每个项目里都会用。表单修改过才让保存按钮亮起来,没改过就是灰色的,这需求本质就是 CanExecute 加依赖属性通知,用 [NotifyCanExecuteChangedFor] 就是个干净的解法。
2.3 Messenger:弱引用消息通信
MVVM 项目里 ViewModel 之间经常需要通信,比如 A 页面的操作要让 B 页面的数据刷新、登录状态变化要通知所有页面更新头像。直接互相持有引用,页面一多就成蜘蛛网了,而且容易造成内存泄漏(ViewModel 被界面引用,界面又被 ViewModel 引用,谁也释放不了)。
CommunityToolkit.Mvvm 内置了一个基于弱引用的 WeakReferenceMessenger,专门解决跨 ViewModel 通信:
csharp复制// 发送消息
WeakReferenceMessenger.Default.Send(new UserLoggedInMessage(userId));
// 接收消息
WeakReferenceMessenger.Default.Register<MainPageViewModel, UserLoggedInMessage>(
this, (receiver, message) =>
{
receiver.OnUserLoggedIn(message.UserId);
});
消息接收端必须是弱引用,也就是说接收方被回收之后,消息不会再发给它,不会造成内存泄漏。这是它跟事件 event 最大的差别——事件是强引用,订阅了就一定要手动退订,忘了退订就完蛋。
[ObservableRecipient] 配合 Messenger 有一层不错的封装。继承这个基类之后,IsActive 属性控制消息接收的开关,注册和反注册自动完成:
csharp复制public partial class MainViewModel : ObservableRecipient
{
public MainViewModel()
{
IsActive = true; // 激活后自动注册
}
protected override void OnActivated()
{
// 在这里注册消息接收
Messenger.Register<MainViewModel, DataUpdatedMessage>(this, (r, m) => r.OnDataUpdated(m));
}
}
写消息类的建议:结构够用就行,别堆字段。消息本质是你给别的 ViewModel 的“快递包裹”,只需要带必要的数据。常用的做法是 record 类型:
csharp复制public sealed record DataUpdatedMessage(DateTime UpdateTime, int ItemCount);
2.4 其他高频特性:SetProperty、ObservableRecipient、IObservableObject
SetProperty 是 ObservableObject 的保护方法,在手写属性的兼容场景下还是很常用。比如有一个属性来自第三方库的模型,不方便直接用 [ObservableProperty],那就手写属性但调用 SetProperty:
csharp复制private string _thirdPartyName;
public string ThirdPartyName
{
get => _thirdPartyName;
set => SetProperty(ref _thirdPartyName, value);
}
ObservableRecipient 前面提过,继承它方便用 Messenger。IObservableObject 接口适合做依赖注入场景的抽象,服务层拿到的是接口,不关心具体实现是不是 ObservableObject。
还有一个值得说的点是 [ObservableProperty] 配合 partial 方法做校验。业务上经常需要拦截属性变化做格式校验、长度限制,或者把值规范化。partial void OnNameChanging / OnNameChanged 这对钩子就是干这个的:
csharp复制[ObservableProperty]
private double _price;
partial void OnPriceChanging(double value)
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(Price));
}
partial void OnPriceChanged(double value)
{
Total = Quantity * value;
}
OnPriceChanging 里可以拦截非法值,OnPriceChanged 里做派生计算。这套机制比手写 OnPropertyChanged 再到处判断更直接、更好维护。
3. 实操:从零开始用 CommunityToolkit.Mvvm 实现一个订单管理页面
光讲特性不够直观,我带大家从头写一个典型的订单管理页面,把上面的知识点整个串起来。这个例子我尽可能贴近真实项目场景:有列表加载、有搜索、有明细弹窗、有状态变更操作。
3.1 环境要求与安装
CommunityToolkit.Mvvm 要求 .NET Standard 2.0 以上的目标框架,所以 .NET Core 3.1、.NET 5/6/7/8、.NET Framework 4.6.1+ 都能用。老项目也能迁移,我去年把一个 .NET Framework 4.7.2 的 WPF 项目从 MvvmLight 迁过来,半小时搞定,编译过了就基本通了。
安装方式有两种。一种是在 Visual Studio 的“管理 NuGet 包”界面搜索 CommunityToolkit.Mvvm,当前稳定版在 8.x,直接安装最新版。另一种是 CLI 命令:
bash复制dotnet add package CommunityToolkit.Mvvm
装完之后有个关键点:源生成器需要 LangVersion 至少是 C# 8,最好是 C# 9 或更高。如果你用的是 .NET 6+ 项目,默认就是 C# 10/11/12,不需要额外配置。老一些的项目如果没有全局 LangVersion,在 csproj 里加上 <LangVersion>9.0</LangVersion> 即可,否则会看到 CS0518 之类的“找不到属性类型”报错。
3.2 模型与 ViewModel 的骨架搭建
先定义清晰的数据模型。订单模型这样设计:
csharp复制public class Order
{
public string OrderNo { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public DateTime CreateTime { get; set; }
public OrderStatus Status { get; set; }
}
public enum OrderStatus
{
Pending,
Paid,
Shipped,
Completed,
Cancelled
}
实际项目里 Order 这个类通常来自数据库实体映射、API 返回的 DTO,或者服务层的领域模型。注意,Demo 里我让它 set 可写,实际按规范应该尽量让状态字段只读、用方法驱动状态变化,但作为绑定源的模型在 MVVM 中并不强制要求实现通知接口——ViewModel 才负责通知。
ViewModel 部分:
csharp复制public partial class OrderListViewModel : ObservableObject
{
private readonly IOrderService _orderService;
[ObservableProperty]
private ObservableCollection<Order> _orders = new();
[ObservableProperty]
private string _searchKeyword = string.Empty;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadOrdersCommand))]
private bool _hasPendingOrders;
public OrderListViewModel(IOrderService orderService)
{
_orderService = orderService;
}
}
注意我这里类上加了 partial,这是源生成器的硬性要求。ObservableCollection<Order> 用来绑定 DataGrid、ListBox、CollectionView 这些列表控件,它的 INotifyCollectionChanged 会让 UI 自动增删。
构造函数注入 IOrderService,这也是现代 .NET 项目里非常标准的做法。保证 ViewModel 不依赖具体服务实现,测试、替换都方便。
3.3 命令加载数据与搜索过滤
加载数据的命令用异步版本,因为真实场景里一定是从数据库或 HTTP API 拉数据:
csharp复制[RelayCommand]
private async Task LoadOrdersAsync()
{
if (IsLoading)
return;
IsLoading = true;
try
{
var result = await _orderService.GetOrdersAsync(SearchKeyword);
Orders.Clear();
foreach (var order in result)
{
Orders.Add(order);
}
}
finally
{
IsLoading = false;
}
}
IsLoading 绑定到界面上,可以做加载指示器。用 try/finally 而不是 try/catch,因为 UI 层的错误处理一般放到全局异常处理器,或者做统一提示,这里不吞异常。
搜索框的变化触发重新查询,就在 SearchKeyword 上做:
csharp复制partial void OnSearchKeywordChanged(string value)
{
// 防抖:延迟 300ms 触发查询,避免每个字符都发请求
_debounceTimer?.Stop();
_debounceTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_debounceTimer.Start();
}
// 构造函数里给 Timer 挂 Tick
_debounceTimer = new DispatcherTimer();
_debounceTimer.Tick += async (_, _) => await LoadOrdersAsync();
防抖是个小技巧,认真做搜索功能的人都会懂。你不防抖的话,用户输“202402”这个过程会触发六次查询,服务端和数据库压力都不小。DispatcherTimer 在 UI 线程跑,Tick 之后直接调 LoadOrdersAsync,线程安全没有额外的切换问题。
如果不想用 Timer,还有一个思路是 [RelayCommand] 里对参数做过滤,然后在 SearchKeyword 变化时手动触发 SearchCommand.Execute(null)。我两种方式都试过,Timer 方案更顺滑,而且对接口请求的中断、乱序也有天然的最后一次生效保护。
3.4 状态变更操作与消息通知
业务上订单要操作状态变更,比如“标记为已付款”。这个操作通常影响当前列表项,可能还要让其他页面知道订单状态变了。
csharp复制[RelayCommand]
private async Task MarkAsPaidAsync(Order? order)
{
if (order == null || order.Status != OrderStatus.Pending)
return;
var result = await _orderService.UpdateStatusAsync(order.OrderNo, OrderStatus.Paid);
if (!result)
{
// 失败提示交给全局 UI 服务
return;
}
order.Status = OrderStatus.Paid;
HasPendingOrders = Orders.Any(x => x.Status == OrderStatus.Pending);
// 通知其他模块:订单状态变了
WeakReferenceMessenger.Default.Send(new OrderStatusChangedMessage(order.OrderNo, OrderStatus.Paid));
}
这里有个重点:命令方法带了参数,生成的命令会自动支持 CommandParameter 绑定。XAML 里这样绑:
xml复制<Button Content="标记已付款"
Command="{Binding MarkAsPaidCommand}"
CommandParameter="{Binding}" />
CommandParameter="{Binding}" 的意思是把当前 DataContext(列表项这一行)传过去。列表容器里每个数据项的 DataContext 是一个 Order,所以传过去的就是这个 Order。
如果不想传整个对象,还有一种做法是用 x:Reference 或 ElementName 引用其他控件属性当参数。但列表场景直接绑定当前 DataContext 是最直接最不容易错的。
3.5 绑定到 XAML 的完整用法
WPF 里完整绑定是这样:
xml复制<Window x:Class="Demo.MainWindow"
xmlns:vm="clr-namespace:Demo.ViewModels"
Title="订单管理">
<Window.DataContext>
<vm:OrderListViewModel />
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="10">
<TextBox Width="200"
Text="{Binding SearchKeyword, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="搜索"
Command="{Binding LoadOrdersCommand}"
Margin="10,0,0,0" />
</StackPanel>
<DataGrid Grid.Row="1"
ItemsSource="{Binding Orders}"
AutoGenerateColumns="False"
IsReadOnly="True">
<DataGrid.Columns>
<DataGridTextColumn Header="单号" Binding="{Binding OrderNo}" />
<DataGridTextColumn Header="客户" Binding="{Binding CustomerName}" />
<DataGridTextColumn Header="金额" Binding="{Binding Amount, StringFormat=C2}" />
<DataGridTextColumn Header="时间" Binding="{Binding CreateTime, StringFormat=yyyy-MM-dd HH:mm}" />
<DataGridTextColumn Header="状态" Binding="{Binding Status}" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</Window>
如果你在项目里用 DI(依赖注入),DataContext 最好是构造函数注入而不是直接在 XAML 里 new。用 App.xaml.cs 或者 MainWindow 的构造函数里给 DataContext = _viewModel,这样 OrderListViewModel 里的 IOrderService 才能被正确注入。
4. 不同平台的适配与 XAML 细节经验
4.1 WPF 平台最常用的场景与注意点
WPF 是 CommunityToolkit.Mvvm 的主战场。ObservableCollection 的 UI 线程限制是 WPF 里的经典坑:后台线程往集合里 Add 可能会抛 NotSupportedException。解决办法很简单,把集合的操作调度回 UI 线程:
csharp复制await Dispatcher.InvokeAsync(() =>
{
Orders.Add(order);
});
或者用 ConcurrentObservableCollection,但我不推荐,它在遍历期间做修改还是有竞态。更稳的做法是保持集合操作全在 UI 线程,后台线程只算数据,算完丢过来。
WPF 还有一个特性值得配合:CollectionViewSource 做分组、排序、过滤。很多人会问“我用 ObservableCollection 为什么不能排序?”因为排序不是集合的职责,集合只负责顺序存储,排序/过滤/分组是视图层的事。
csharp复制ICollectionView view = CollectionViewSource.GetDefaultView(Orders);
view.Filter = o => ((Order)o).CustomerName.Contains(SearchKeyword, StringComparison.OrdinalIgnoreCase);
view.SortDescriptions.Add(new SortDescription("Amount", ListSortDirection.Descending));
注意,如果你用 CollectionViewSource 过滤,就不用每次重新加载列表了,性能更好。但需要记住每次 SearchKeyword 变化后调用 view.Refresh() 才会重新过滤。
4.2 WinForms 里也能用 MVVM,但别过度设计
WinForms 虽然官方没有绑定机制,但 CommunityToolkit.Mvvm 的核心类并不依赖 WPF 或 XAML,它只依赖 INotifyPropertyChanged 和 ICommand 这两个接口,在 WinForms 里照样能用。
典型用法是给 DataGridView 绑 BindingSource,DataSource 指向 ViewModel 的集合,然后手工订阅 PropertyChanged 刷新控件状态。命令方面通过按钮 Click 事件调用 ViewModel 里的命令,或者自己做一层简单的绑定:
csharp复制public partial class OrderForm : Form
{
private readonly OrderListViewModel _viewModel;
public OrderForm()
{
InitializeComponent();
_viewModel = new OrderListViewModel(new OrderService());
dataGridView1.DataSource = _viewModel.Orders;
_viewModel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OrderListViewModel.IsLoading))
btnLoad.Enabled = !_viewModel.IsLoading;
};
}
private void btnLoad_Click(object sender, EventArgs e)
=> _viewModel.LoadOrdersCommand.Execute(null);
}
注意 WinForms 里没有 ICommand 的自动触发机制,按钮点击要手动执行命令。也不存在 CommandParameter 这种魔法,直接调用方法参数更直接。我的建议是 WinForms 项目别全套硬上 MVVM,把有复杂状态的部分拿 ViewModel 接住,界面代码该写的还是写,求稳不求纯。
4.3 MAUI / Uno 平台需要注意的差异
MAUI 的命令绑定、属性绑定和 WPF 基本一致,但绑定的默认模式可能不同。比如 MAUI 里 Entry.Text 默认绑定模式下,输入框失焦才更新绑定源,如果你做实时搜索,需要显式设置:
xml复制<Entry Text="{Binding SearchKeyword, Mode=TwoWay, UpdateSourceEventName=TextChanged}" />
Uno Platform 也兼容,它是跑在 WinUI 上的,整体体验接近 WPF。但要注意 UpdateSourceTrigger 的枚举值在不同平台里表现不同,绝对不能照抄 WPF 代码不验证就上线。
多平台的共性问题:Dispatcher 的使用差异。WPF 用 Dispatcher,MAUI 用 MainThread.BeginInvokeOnMainThread,Uno 用 DispatcherQueue。CommunityToolkit.Mvvm 不管这一层,它只管通知,线程切换必须你自己处理。
5. 迁移指南:从 MvvmLight 老项目换过来
这个标题很具体,我就把 MvvmLight 到 CommunityToolkit.Mvvm 的迁移踩坑之路完整讲清楚,正好也是很多项目在做的实际工作。
5.1 对应关系速查表
MvvmLight 当年风靡一时,现在基本停更了。它的模式跟 CommunityToolkit.Mvvm 大同小异,迁移最关键的是把老写法映射到新写法:
| 场景 | MvvmLight 写法 | CommunityToolkit.Mvvm 写法 |
|---|---|---|
| ViewModel 基类 | ViewModelBase |
ObservableObject 或 ObservableRecipient |
| 属性通知 | Set(() => MyProp, ref _myProp) |
[ObservableProperty] + partial void OnMyPropChanged |
| RelayCommand | new RelayCommand(Execute, CanExecute) |
[RelayCommand(CanExecute = nameof(CanExec))] |
| Messenger | Messenger.Default.Send(msg) |
WeakReferenceMessenger.Default.Send(msg) |
| 注册信息 | Messenger.Default.Register(this, msg, echo) |
Register<TRecipient, TMessage>(this, handler) 或 ObservableRecipient |
注意 MvvmLight 的 Messenger 用户注册时需要传 token 机制,CommunityToolkit.Mvvm 的注册方式改了,整体更简洁,但消息类也可以复用老消息类,不需要重写。
5.2 具体迁移步骤
第一步,删 MvvmLight 的 NuGet 包,装 CommunityToolkit.Mvvm。
第二步,把 ViewModelBase 替换为 ObservableObject。如果旧代码里有 RaisePropertyChanged("Name") 这种写法,改成 OnPropertyChanged() 或者在新框架下用 [ObservableProperty] 重写字段。注意新框架的 OnPropertyChanged 需要传 nameof(Property),自动化程度更高。
第三步,处理消息。SimpleMessage 这种旧消息类型在新框架里可以用 ValueChangedMessage<T> 代替,也可以自己定义 record 类型。
第三步半,处理命令。旧写法 new RelayCommand(执行方法, 判断方法) 的属性要删掉,改成类里写方法加 [RelayCommand]。
迁移后我跑过一次全项目,大约 4 个 ViewModel、30 多个属性、20 来个命令,MVVM 核心部分一个多小时就全部改完,余下的时间全耗在 XAML 里那几处绑定更新上。整体体验比想象中顺。
5.3 迁移后的最优实践
迁移完了别急着庆祝,照着下面的清单自查一遍:
- 所有 ViewModel 类都加了
partial关键字 - 源生成器没有报错(IDE 里看得到生成文件,一般在“分析器生成的文件”节点,或者 bin 目录下)
- 所有命令的
CanExecute更新时机都配了NotifyCanExecuteChangedFor - 异步命令都处理了
IsRunning,查询下有没有重复点击的隐患 - Messenger 接收方都确认用弱引用,不会造成内存泄漏
- 所有
OnXxxChanged钩子里做的是派生逻辑而不是 UI 逻辑
按这个清单过一遍,基本能覆盖 90% 的坑。
6. 常见问题与排查技巧实录
6.1 源生成器没有生成任何代码
我见过最多的场景之一。代码里写了 [ObservableProperty],但编译报 CS0518 或者属性根本找不到,这时候先排查几个方向:
- 项目是否安装了 CommunityToolkit.Mvvm 包?确认 NuGet 还原成功。
- 类是否加了
partial?没加的话源生成器根本就不会从这个类生成任何东西。 - LangVersion 是否至少为 C# 8?
- IDE 里是不是没刷新?关掉重开,或者 Build > Rebuild,确认不是 IDE 缓存问题。
如果还不行,打开“工具 > 选项 > 文本编辑器 > C# > 高级”,把“生成期间生成源”勾上,这样能看到更多报错细节。
6.2 界面没有刷新,属性也变了就是不动
这是 MVVM 新手最崩溃的问题。属性是 [ObservableProperty] 的,值确实改了,界面纹丝不动。这时候按下面的顺序查:
先看绑定是否正确,XAML 里绑定路径有没有写错。细小的拼写错误在 XAML 里不会编译报错,运行时只是静默失败,这是 WPF 的老毛病。输出窗口一般有以下类似的提示:
code复制System.Windows.Data Error: 40 : BindingExpression path error: 'NoOrders' property not found on 'object' ''OrderListViewModel''
先看输出窗口有没有这个提示,有的话就是绑定写错了。
再看属性改变了没有。在 OnChanged 钩子打个断点,或者在属性 setter 里临时加一句 Debug.WriteLine,确认通知触发。
然后是绑定模式问题。默认情况下绑定 TextBox 的 Text 是失去焦点才更新源,如果你期望的是点击按钮时值已更新,但输入框还处于焦点状态,那么命令执行时拿到的还是旧值。这不算 bug 但很容易踩到,用 UpdateSourceTrigger=PropertyChanged 可解。
最后是线程问题。后台线程改了属性,通知事件是在后台线程发的,WPF 不会跨线程自动切回 UI,界面可能就甩锅给你了。确认不是这个坑的办法是在 OnXxxChanged 里看 Thread.CurrentThread.ManagedThreadId。
6.3 AsyncRelayCommand 的并发与重复执行
默认情况 AsyncRelayCommand 会忽略执行期间的重复点击,这是它比手工 async void 事件好的地方。但如果你的 Execute 方法是在外部手动调用的,例如从消息里触发,那并发控制只作用于命令内部。你可以设置:
csharp复制[RelayCommand(AllowConcurrentExecutions = false)]
private async Task DoHeavyWorkAsync() { }
注意版本差异:AllowConcurrentExecutions 在 8.x 阿里用的是 [RelayCommand(AllowConcurrentExecutions = false)],很直观。
如果命令依赖 UI 线程的控件状态,比如 Canvas 里点的坐标、选中框范围,那并发控制必须配合 IsRunning 做搬运。当一个长耗时任务在跑时,用户想再次用新参数触发,需要判断一下是丢弃新请求还是排队等待,这会用到 ExecutionTask:
csharp复制if (MyCommand.ExecutionTask != null)
{
await MyCommand.ExecutionTask; // 等上一次完成
}
await MyCommand.ExecuteAsync(null);
这种方式适合做互斥。排队执行这种玩法在导出报表、批量发送消息的场景很有用。
6.4 消息注册后不触发或泄漏失控
消息不触发,最常见的低级错误是:发送消息类型和接收注册类型不是同一个类型。我用 record 定义消息的时候偶尔粗心,注册处用 DataUpdatedMessage、发送处传 OrderStatusChangedMessage,半分钟后才反应过来。
另一个低级错误是:ObservableRecipient 激活了就一定会注册,但 IsActive 的赋值时机如果是别的地方手动设的,而你又没重写 OnActivated,那就不会注册成功。我有时在构造函数里写 IsActive = true,然后发现收到的消息永远是零,检查后才发现重写的是 OnDeactivated 而不是 OnActivated。
泄漏场景几乎不会出现在 WeakReferenceMessenger 里,它会自己找不会泄漏的接收方。但如果你的消息中带了强引用的大对象(比如把整个 ViewModel 装进去了),那 GC 就无法立即回收,本质上和管理不当一样。消息里放数据就好,别放整个实例。这条我踩过,有一次往消息里塞了整个 OrderListViewModel,UI 内存直接涨了 20MB 下不来。
6.5 设计上的坏味道:命令里塞业务逻辑
很多刚用上这个库的人容易把 ViewModel 变成“命令工厂”——整个类里全是命令方法,每个方法里写一大坨业务逻辑。这个模式的坏处是测试困难、职责过重、改业务必动 ViewModel。
更健康的分层是:ViewModel 负责状态组织和交互编排,业务规则放到独立的 Service 或者领域类里。比如“订单状态机”的流转判断,不应该写在 MarkAsPaidAsync 里面,应该由 IOrderService.UpdateStatusAsync 去校验:“这个状态能不能流转到那个状态”。
命令行云项目里还有一个常见坏味道:一个方法干了 5 件事,加载、解析、计算、保存、通知全塞一块。建议能拆就拆,拆不成也至少把可测试的部分下沉到服务层。
7. 实测其他 MVVM 框架对比后,我为什么锁定这个
用了这么多年,我简单对比过几个主流 MVVM 方案,不是空口推荐,是真刀真枪跑过 Demo 和中小型项目。
| 框架 | 源生成器支持 | 消息机制 | 维护状态 | 学习曲线 | 依赖注入集成 |
|---|---|---|---|---|---|
| CommunityToolkit.Mvvm | 成熟,核心卖点 | 弱引用 Messenger | 官方长期维护 | 平缓 | 无绑定,但配合 DI 容器很自然 |
| Prism | 有但晚 | EventAggregator(强引用) | 维护活跃,定位偏重型 | 陡,自带导航/容器绑定 | 强绑定,引导按它那一套来 |
| MvvmLight | 无 | Messenger(强引用) | 停更多年 | 平缓 | 一般 |
| Caliburn.Micro | 无,靠约定 | EventAggregator | 维护一般 | 中等 | 有引导 |
| ReactiveUI | 无,靠 Rx 组合 | 无内置 | 维护活跃 | 非常陡,得先学 Rx | 可选 |
Prism 和 CommunityToolkit.Mvvm 不冲突,实际上常被组合使用。Prism 管导航、模块化、容器集成,CommunityToolkit.Mvvm 管属性通知、命令、消息。.NET 生态里大量项目是“Prism + 这个库”的组合拳,二者在应用层配合得不错。
CommunityToolkit.Mvvm 最大的温和在这里:它不强加范式,不要求你用它的容器,不是全家桶式硬绑定。需要什么拿什么,不需要的完全可以不碰。这种克制在 .NET 官方库里很少见,也是我敢在生成代码、序列化、事件处理上押宝它的原因。
8. 写在后面:这套 MVVM 方案帮我省掉的时间
项目重构最实际的收益是代码量的变化。一个中等规模的 WPF 订单模块,老写法里 ViewModel 有一千多行助攻全是样板代码;切到 CommunityToolkit.Mvvm 之后,同样的功能大概三百行左右,减了近七成。更明显的变化是代码审查的时候,以前 review 一堆 SetProperty 的差异也无从下手,现在只用看真正的业务逻辑和 partial void OnXxxChanged 钩子里的关联逻辑,精力和时间都省了。
这库不是银弹,它不能把架构设计上的问题消除掉。但它把那些重复、机械、容易遗漏的绑定和通知代码收掉了,你出力的是架构和业务本身,这比“什么都能写”更重要。
最后分享一个我在实际中发现好用的点:[ObservableProperty] 的字段可以非常自由地控制可见性,设为 private 或 protected 都不会影响生成属性。这意味着你写 ViewModel 基类、抽象类、泛型基类时也能用它。有套抽象列表页的基类,字段就是 protected ObservableCollection<T> _items,子类通过生成的 Items 属性完成绑定,干净利落,完全不需要手写任何通知逻辑。这个小玩法在很多需要做基类复用的项目里很实用。
