desktop-mvvm
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDesktop MVVM (.NET 10, CommunityToolkit.Mvvm)
桌面 MVVM(.NET 10,CommunityToolkit.Mvvm)
Core Principles
核心原则
- View models know nothing about the UI framework — No ,
Window,MessageBox, orDispatcherin a view model. That keeps them unit-testable without a UI thread and portable between WPF, Avalonia, and MAUI.Visibility - Source generators over hand-written — CommunityToolkit.Mvvm (8.4.x, verified) generates the property, change notification, and command boilerplate at compile time. There's no reflection, so it stays trimming- and AOT-friendly.
INotifyPropertyChanged - Use partial properties, not fields — Since 8.4, goes on
[ObservableProperty]properties. From 8.4.1 that works on C# 14 with nopartial. You get real properties that you can put attributes on and debug, instead of fields with magic names.LangVersion=preview - Async commands, never —
async voidon a[RelayCommand]-returning method generates anTask. It disables itself while running, surfaces exceptions, and supports cancellation.IAsyncRelayCommand - Generic Host is the composition root — DI, configuration, logging, and work the same as in ASP.NET Core. Views and view models are resolved from the container, never
IHttpClientFactory-ed in code-behind.new
- ViewModel 对 UI 框架一无所知 — ViewModel 中不应出现 、
Window、MessageBox或Dispatcher。这样无需 UI 线程即可对 ViewModel 进行单元测试,并可在 WPF、Avalonia 和 MAUI 之间移植。Visibility - 使用源生成器,而不是手写 — CommunityToolkit.Mvvm(8.4.x,已验证)会在编译时生成属性、变更通知和命令样板代码。由于不依赖反射,因此适合裁剪和 AOT。
INotifyPropertyChanged - 使用 partial 属性,而不是字段 — 从 8.4 开始,应放在
[ObservableProperty]属性上。从 8.4.1 起,它可在 C# 14 下使用,无需partial。这样可以获得真正的属性:可以添加特性并进行调试,而不必使用依赖魔法名称的字段。LangVersion=preview - 异步命令,绝不使用 — 在返回
async void的方法上使用Task,会生成[RelayCommand]。命令运行期间会自动禁用自身、暴露异常并支持取消。IAsyncRelayCommand - Generic Host 是组合根 — DI、配置、日志和 的使用方式与 ASP.NET Core 相同。View 和 ViewModel 都从容器解析,绝不在 code-behind 中通过
IHttpClientFactory创建。new
Patterns
模式
View Model with Generated Properties and Commands
使用生成的属性和命令的 ViewModel
csharp
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
}Why: the class must be so the generator can add members. is stripped from command names ( → ). The command is disabled while it runs (unless ), so double-clicks can't start two loads. Bind a busy indicator to instead of keeping a hand-rolled flag.
partialAsyncLoadAsyncLoadCommandAllowConcurrentExecutions = trueLoadCommand.IsRunningIsBusycsharp
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
}原因: 类必须为 ,生成器才能添加成员。命令名称中的 会被移除( → )。命令运行期间会禁用自身(除非设置 ),因此双击无法启动两次加载。将忙碌指示器绑定到 ,而不是维护手写的 标志。
partialAsyncLoadAsyncLoadCommandAllowConcurrentExecutions = trueLoadCommand.IsRunningIsBusyGeneric Host in WPF
WPF 中的 Generic Host
Remove from , then own startup in :
StartupUriApp.xamlApp.xaml.cscsharp
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;
}
}Why: one composition root shared with your backend knowledge. Constructor injection into windows keeps code-behind to + . Setting the content root to stops a shortcut's "Start in" folder from changing which config loads. Avalonia wiring lives in the skill.
InitializeComponentDataContextAppContext.BaseDirectoryavalonia从 中移除 ,然后在 中自行管理启动流程:
App.xamlStartupUriApp.xaml.cscsharp
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;
}
}原因: 这样可以形成一个统一的组合根,并复用后端开发中已有的经验。向窗口构造函数注入依赖,可让 code-behind 只保留 和 。将内容根设为 ,可防止快捷方式的“起始位置”文件夹改变所加载的配置文件。Avalonia 的接线位于 skill 中。
InitializeComponentDataContextAppContext.BaseDirectoryavaloniaUI-Thread Dispatching
UI 线程调度
awaitFileSystemWatchercsharp
// 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);
}Why: is framework-neutral and needs zero plumbing. The interface keeps the type out of view models, and tests can supply an inline implementation (). belongs around CPU-bound work only. Never wrap I/O that is already async.
Progress<T>IUiDispatcherDispatcheraction(); return Task.CompletedTask;Task.Run在 UI 线程上启动的 在恢复执行时仍会回到 UI 线程,因此大多数代码都无需调度。仅应对来自后台线程的回调进行调度(定时器、、套接字、消息总线)。
awaitFileSystemWatchercsharp
// 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);
}原因: 与框架无关,无需任何管道代码。 接口可避免在 ViewModel 中出现 类型,测试则可提供内联实现()。 只应用于 CPU 密集型工作。绝不要用 包裹本身已经异步的 I/O。
Progress<T>IUiDispatcherDispatcheraction(); return Task.CompletedTask;Task.RunTask.RunView-Model-First Navigation
ViewModel 优先的导航
csharp
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>();
}xml
<!-- Shell window: the content area follows Navigator.Current -->
<ContentControl Content="{Binding Navigator.Current}" />
<!-- App resources: one DataTemplate per view model maps VM → View -->
<DataTemplate DataType="{x:Type vm:OrdersViewModel}">
<views:OrdersView />
</DataTemplate>Why: view models navigate by type, and the view layer decides how each one renders. That makes navigation testable (). is confined to the navigator so it doesn't spread as a service locator. Register page view models as for a fresh page each visit, to keep state across visits.
Assert.IsType<OrdersViewModel>(navigator.Current)IServiceProviderTransientSingletoncsharp
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>();
}xml
<!-- Shell window: the content area follows Navigator.Current -->
<ContentControl Content="{Binding Navigator.Current}" />
<!-- App resources: one DataTemplate per view model maps VM → View -->
<DataTemplate DataType="{x:Type vm:OrdersViewModel}">
<views:OrdersView />
</DataTemplate>原因: ViewModel 按类型导航,而视图层决定每个 ViewModel 的呈现方式。这样导航逻辑也可以进行测试()。 仅限于在 Navigator 中使用,避免扩散成 service locator。将页面 ViewModel 注册为 ,可在每次访问时获得全新页面;注册为 ,则可在多次访问之间保留状态。
Assert.IsType<OrdersViewModel>(navigator.Current)IServiceProviderTransientSingletonDecoupled Messaging Between View Models
ViewModel 之间的解耦消息传递
csharp
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
}Why: sibling view models shouldn't reference each other. holds recipients weakly, so a closed page's view model can still be garbage collected with no explicit unregister. Messages are immutable records.
WeakReferenceMessengercsharp
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
}原因: 同级 ViewModel 不应相互引用。 对接收者使用弱引用,因此关闭页面后,其 ViewModel 仍可被垃圾回收,无需显式注销。消息使用不可变 record。
WeakReferenceMessengerValidation
验证
csharp
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
}
}Why: implements , which WPF, Avalonia, and MAUI all render natively. Validation rules live with the data, not in XAML.
ObservableValidatorINotifyDataErrorInfocsharp
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
}
}原因: 实现了 ,WPF、Avalonia 和 MAUI 都能原生呈现验证错误。验证规则与数据放在一起,而不是写在 XAML 中。
ObservableValidatorINotifyDataErrorInfoTesting View Models
测试 ViewModel
csharp
[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));
}Why: view models are plain classes, so tests run without a UI thread or window. Use a hand-written fake for your own services (no mocking framework) and for time.
FakeTimeProvidercsharp
[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));
}原因: ViewModel 是普通类,因此无需 UI 线程或窗口即可运行测试。对于自有服务,请使用手写的 fake(无需 mocking framework);对于时间,则使用 。
FakeTimeProviderMAUI Note
MAUI 说明
MAUI uses the same view models unchanged. Register them in via , navigate with Shell routes (), and marshal background callbacks with . Set on pages for compiled bindings, as in Avalonia.
MauiProgram.CreateMauiApp()builder.ServicesShell.Current.GoToAsync("orders")MainThread.InvokeOnMainThreadAsyncx:DataTypeMAUI 可以原样使用相同的 ViewModel。通过 在 中注册它们,使用 Shell 路由进行导航(),并通过 调度后台回调。与 Avalonia 一样,应为页面设置 以启用编译绑定。
builder.ServicesMauiProgram.CreateMauiApp()Shell.Current.GoToAsync("orders")MainThread.InvokeOnMainThreadAsyncx:DataTypeAnti-patterns
反模式
async void
Commands and Blocking the UI Thread
async voidasync void
命令与阻塞 UI 线程
async voidcsharp
// 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);csharp
// 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);Mutating Bound Collections from a Background Thread
在后台线程修改绑定集合
csharp
// 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);csharp
// 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 Types in View Models
ViewModel 中使用 UI 类型
csharp
// 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}?")) { }csharp
// 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}?")) { }Hand-Written Property Boilerplate
手写属性样板代码
csharp
// 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; }csharp
// 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; }Logic in Code-Behind
在 code-behind 中编写逻辑
csharp
// 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}" />csharp
// 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}" />Decision Guide
决策指南
| Scenario | Recommendation |
|---|---|
| New cross-platform desktop app | Avalonia + CommunityToolkit.Mvvm (see |
| Windows-only, existing WPF skills or controls | WPF + CommunityToolkit.Mvvm + Generic Host |
| Mobile + desktop from one codebase | MAUI (or Avalonia if Linux desktop is required) |
| Legacy WinForms | Keep WinForms; move logic into view models ( |
| Observable property | |
Derived property (e.g. | |
| Button enablement depends on state | |
| Long-running command | |
| Busy indicator | Bind to |
| CPU-heavy work | |
| Cross-VM notifications | |
| Dialogs / file pickers | |
| Need trimming / Native AOT | Avalonia with compiled bindings; WPF/WinForms don't support trimming |
| 场景 | 建议 |
|---|---|
| 新的跨平台桌面应用 | Avalonia + CommunityToolkit.Mvvm(参见 |
| 仅限 Windows,已有 WPF skill 或控件 | WPF + CommunityToolkit.Mvvm + Generic Host |
| 使用同一代码库同时支持移动端和桌面端 | MAUI(如果必须支持 Linux 桌面端,则使用 Avalonia) |
| 旧版 WinForms | 保留 WinForms;将逻辑移入 ViewModel(自 .NET 7 起支持 |
| 可观察属性 | |
派生属性(例如 | 在其输入属性上使用 |
| 按钮可用状态取决于状态 | |
| 长时间运行的命令 | |
| 忙碌指示器 | 绑定到 |
| CPU 密集型工作 | |
| 跨 ViewModel 通知 | |
| 对话框 / 文件选择器 | 由视图层实现 |
| 需要裁剪 / Native AOT | Avalonia 配合编译绑定;WPF/WinForms 不支持裁剪 |