关于 desktop-mvvm
面向 .NET 桌面应用开发者的 MVVM 实践指南,围绕 CommunityToolkit.Mvvm 展示可测试、跨 UI 框架的视图模型设计与依赖注入模式。
主要功能
- 01使用源生成器创建属性与命令
- 02以异步命令处理长任务
- 03通过 Generic Host 组合依赖
- 04视图模型优先导航与消息传递
- 05验证与无 UI 线程单元测试
适用场景
- 01为 WPF 应用组织依赖注入
- 02在 Avalonia 或 MAUI 复用视图模型
- 03测试不依赖窗口的业务逻辑
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill desktop-mvvm安装后在 Claude Code 中输入/desktop-mvvm确认已加载。
详细介绍
桌面 MVVM(.NET 10,CommunityToolkit.Mvvm)
核心原则
- 视图模型不依赖任何 UI 框架 — 视图模型中不应出现
Window、MessageBox、Dispatcher或Visibility。这样无需 UI 线程即可进行单元测试,也能在 WPF、Avalonia 和 MAUI 之间移植。 - 使用源生成器,不手写
INotifyPropertyChanged— CommunityToolkit.Mvvm(已验证 8.4.x)会在编译时生成属性、更改通知和命令样板代码。它不使用反射,因此兼容修剪和 AOT。 - 使用分部属性,而非字段 — 从 8.4 开始,
[ObservableProperty]应用于partial属性。从 8.4.1 开始,C# 14 无需设置LangVersion=preview即可使用此功能。这样得到的是可添加特性并可调试的真实属性,而不是名称带有特殊含义的字段。 - 使用异步命令,绝不使用
async void— 在返回Task的方法上使用[RelayCommand],会生成IAsyncRelayCommand。命令运行时会禁用自身、公开异常并支持取消。 - 使用 Generic Host 作为组合根 — DI、配置、日志记录和
IHttpClientFactory的工作方式与 ASP.NET Core 中相同。视图和视图模型都从容器中解析,绝不在代码隐藏中通过new创建。
模式
使用生成的属性和命令的视图模型
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public sealed partial class OrdersViewModel(IOrderService orders, TimeProvider clock) : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
public partial string? CustomerName { get; set; }
[ObservableProperty]
public partial OrderRow? SelectedOrder { get; set; }
public ObservableCollection<OrderRow> Orders { get; } = [];
// Generates LoadCommand (IAsyncRelayCommand) + LoadCancelCommand
[RelayCommand(IncludeCancelCommand = true)]
private async Task LoadAsync(CancellationToken ct)
{
Orders.Clear();
await foreach (var order in orders.StreamAsync(ct))
Orders.Add(order); // continuation resumes on the UI thread
}
[RelayCommand(CanExecute = nameof(CanSave))]
private Task SaveAsync(CancellationToken ct) =>
orders.CreateAsync(CustomerName!, clock.GetUtcNow(), ct);
private bool CanSave() => !string.IsNullOrWhiteSpace(CustomerName);
partial void OnSelectedOrderChanged(OrderRow? value) =>
CustomerName = value?.Customer; // generated hook, no setter boilerplate
}
原因: 类必须是 partial,以便生成器添加成员。命令名称会移除 Async(LoadAsync → LoadCommand)。命令运行时会被禁用(除非 AllowConcurrentExecutions = true),因此双击不会启动两次加载。请将忙碌指示器绑定到 LoadCommand.IsRunning,而不是手动维护 IsBusy 标志。
WPF 中的 Generic Host
从 App.xaml 中移除 StartupUri,然后在 App.xaml.cs 中管理启动过程:
public sealed partial class App : Application
{
private readonly IHost host = CreateHost();
private static IHost CreateHost()
{
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
ContentRootPath = AppContext.BaseDirectory // appsettings.json next to the exe, not the cwd
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
builder.Services.AddHttpClient<IOrderService, OrderService>();
builder.Services.AddTransient<OrdersViewModel>();
builder.Services.AddSingleton<MainWindow>();
return builder.Build();
}
protected override void OnStartup(StartupEventArgs e)
{
host.Start(); // starts IHostedService background work, if any
host.Services.GetRequiredService<MainWindow>().Show();
base.OnStartup(e);
}
protected override void OnExit(ExitEventArgs e)
{
// Process exit is the one place sync-over-async is acceptable (like Program.cs)
host.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
host.Dispose();
base.OnExit(e);
}
}
public sealed partial class MainWindow : Window
{
public MainWindow(OrdersViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
原因: 一个组合根即可与后端共享知识。通过构造函数注入窗口,可让代码隐藏仅包含 InitializeComponent + DataContext。将内容根目录设为 AppContext.BaseDirectory,可避免快捷方式的“起始位置”文件夹改变所加载的配置。Avalonia 的接线方式见 avalonia 技能。
UI 线程调度
从 UI 线程启动的 await 会在 UI 线程上恢复,因此大多数代码无需调度。仅当回调在后台线程上到达时才进行调度(计时器、FileSystemWatcher、套接字、消息总线)。
// 1. Progress<T> captures the UI SynchronizationContext when constructed on the UI thread
[RelayCommand]
private async Task ExportAsync(CancellationToken ct)
{
var progress = new Progress<int>(percent => ExportProgress = percent);
await Task.Run(() => exporter.Export(Orders.ToList(), progress, ct), ct); // CPU work off the UI thread
}
// 2. A tiny abstraction for background-origin events, implemented per framework
public interface IUiDispatcher
{
Task InvokeAsync(Action action);
}
public sealed class WpfUiDispatcher : IUiDispatcher
{
public async Task InvokeAsync(Action action) =>
await Application.Current.Dispatcher.InvokeAsync(action);
}
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{
public async Task InvokeAsync(Action action) =>
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(action);
}
原因: Progress<T> 与框架无关,无需任何额外设置。IUiDispatcher 接口可避免视图模型依赖 Dispatcher 类型,测试时可以提供内联实现(action(); return Task.CompletedTask;)。仅对 CPU 密集型工作使用 Task.Run。对于已经是异步的 I/O,绝不要用它包装。
视图模型优先导航
public sealed partial class Navigator(IServiceProvider services) : ObservableObject
{
[ObservableProperty]
public partial ObservableObject? Current { get; set; }
public void NavigateTo<TViewModel>() where TViewModel : ObservableObject =>
Current = services.GetRequiredService<TViewModel>();
}
<ContentControl Content="{Binding Navigator.Current}" />
<DataTemplate DataType="{x:Type vm:OrdersViewModel}">
<views:OrdersView />
</DataTemplate>
原因: 视图模型按类型导航,由视图层决定每个视图模型的呈现方式。这样导航便于测试(Assert.IsType<OrdersViewModel>(navigator.Current))。IServiceProvider 被限制在导航器中,不会扩散为服务定位器。将页面视图模型注册为 Transient,可在每次访问时创建新页面;注册为 Singleton,则可在多次访问间保留状态。
视图模型之间的解耦消息传递
public sealed record OrderSaved(Guid OrderId);
// Sender
messenger.Send(new OrderSaved(order.Id));
// Receiver
public sealed partial class DashboardViewModel : ObservableObject, IRecipient<OrderSaved>
{
public DashboardViewModel(IMessenger messenger) => messenger.RegisterAll(this);
public void Receive(OrderSaved message) => RefreshCommand.Execute(null);
[RelayCommand]
private Task RefreshAsync(CancellationToken ct) => Task.CompletedTask; // reload tiles
}
原因: 同级视图模型不应相互引用。WeakReferenceMessenger 以弱引用持有接收方,因此关闭页面后,其视图模型仍可被垃圾回收,无需显式注销。消息是不可变记录。
验证
public sealed partial class CustomerFormViewModel : ObservableValidator
{
[ObservableProperty]
[NotifyDataErrorInfo]
[Required, MaxLength(100)]
public partial string? Name { get; set; }
[RelayCommand]
private void Submit()
{
ValidateAllProperties();
if (HasErrors) return;
// save
}
}
原因: ObservableValidator 实现了 INotifyDataErrorInfo,WPF、Avalonia 和 MAUI 都能原生呈现验证结果。验证规则与数据放在一起,而非写在 XAML 中。
测试视图模型
[Fact]
public async Task SaveCommand_EmptyCustomer_CannotExecute()
{
// Arrange
var vm = new OrdersViewModel(new InMemoryOrderService(), new FakeTimeProvider());
// Act
vm.CustomerName = " ";
// Assert
Assert.False(vm.SaveCommand.CanExecute(null));
}
原因: 视图模型是普通类,因此测试无需 UI 线程或窗口即可运行。对自有服务使用手写伪对象(无需模拟框架),对时间使用 FakeTimeProvider。
MAUI 说明
MAUI 使用相同的视图模型,无需修改。通过 builder.Services 在 MauiProgram.CreateMauiApp() 中注册视图模型,使用 Shell 路由导航(Shell.Current.GoToAsync("orders")),并通过 MainThread.InvokeOnMainThreadAsync 调度后台回调。在页面上设置 x:DataType 以启用编译绑定,与 Avalonia 中的做法相同。
反模式
async void 命令和阻塞 UI 线程
// BAD — exceptions crash the app, button stays enabled, .Result can deadlock the UI thread
public ICommand LoadCommand => new RelayCommand(async () => await LoadAsync());
private void Refresh() => Orders = service.GetAllAsync().Result;
// GOOD — generated async command with cancellation and re-entrancy protection
[RelayCommand]
private async Task LoadAsync(CancellationToken ct) => await ReloadAsync(ct);
从后台线程修改绑定集合
// BAD — ObservableCollection raises CollectionChanged off the UI thread → crash
await Task.Run(() => { foreach (var o in Fetch()) Orders.Add(o); });
// GOOD — compute in the background, mutate on the UI thread
var rows = await Task.Run(() => Fetch().ToList(), ct);
foreach (var row in rows) Orders.Add(row);
在视图模型中使用 UI 类型
// BAD — untestable, ties the VM to WPF
if (MessageBox.Show("Delete?", "", MessageBoxButton.YesNo) == MessageBoxResult.Yes) { }
// GOOD — an interface the view layer implements
public interface IDialogService
{
Task<bool> ConfirmAsync(string title, string message);
}
if (await dialogs.ConfirmAsync("Delete order", $"Delete {SelectedOrder!.Number}?")) { }
手动编写属性样板代码
// BAD — 8 lines per property, typo-prone string names
private string? name;
public string? Name
{
get => name;
set { if (name != value) { name = value; OnPropertyChanged(nameof(Name)); } }
}
// GOOD
[ObservableProperty]
public partial string? Name { get; set; }
在代码隐藏中编写逻辑
// BAD — click handler talks to services; nothing is testable
private async void SaveButton_Click(object sender, RoutedEventArgs e) =>
await new OrderService().SaveAsync(NameBox.Text);
// GOOD — the view binds to a command; the view model owns behavior
// <Button Content="Save" Command="{Binding SaveCommand}" />
决策指南
| 场景 | 建议 |
|---|---|
| 新建跨平台桌面应用 | Avalonia + CommunityToolkit.Mvvm(参见 avalonia 技能) |
| 仅限 Windows,且已有 WPF 技能或控件 | WPF + CommunityToolkit.Mvvm + Generic Host |
| 使用同一代码库开发移动端和桌面应用 | MAUI(如果需要 Linux 桌面支持,则使用 Avalonia) |
| 旧版 WinForms | 保留 WinForms;将逻辑移入视图模型(.NET 7 起支持 Button.Command / DataContext 绑定) |
| 可观察属性 | [ObservableProperty] public partial T Name { get; set; } |
派生属性(例如 FullName) | 在其输入属性上使用 [NotifyPropertyChangedFor(nameof(FullName))] |
| 按钮是否启用取决于状态 | [RelayCommand(CanExecute = ...)] + [NotifyCanExecuteChangedFor] |
| 长时间运行的命令 | async Task + CancellationToken,并设置 IncludeCancelCommand = true |
| 忙碌指示器 | 绑定到 XCommand.IsRunning |
| CPU 密集型工作 | 使用 Task.Run + Progress<T>,然后在 UI 线程上更新状态 |
| 跨视图模型通知 | 使用 IMessenger(WeakReferenceMessenger)和记录消息 |
| 对话框 / 文件选取器 | 使用由视图层实现的 IDialogService 接口 |
| 需要裁剪 / Native AOT | 使用支持编译绑定的 Avalonia;WPF/WinForms 不支持裁剪 |