mcpField
D
desktop-mvvm

desktop-mvvm

github.com/codewithmukesh开发807154 次安装2 天前更新
代码仓库

关于 desktop-mvvm

面向 .NET 桌面应用开发者的 MVVM 实践指南,围绕 CommunityToolkit.Mvvm 展示可测试、跨 UI 框架的视图模型设计与依赖注入模式。

主要功能

  1. 01使用源生成器创建属性与命令
  2. 02以异步命令处理长任务
  3. 03通过 Generic Host 组合依赖
  4. 04视图模型优先导航与消息传递
  5. 05验证与无 UI 线程单元测试

适用场景

  1. 01为 WPF 应用组织依赖注入
  2. 02在 Avalonia 或 MAUI 复用视图模型
  3. 03测试不依赖窗口的业务逻辑

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/codewithmukesh/dotnet-claude-kit --skill desktop-mvvm

安装后在 Claude Code 中输入/desktop-mvvm确认已加载。

详细介绍

桌面 MVVM(.NET 10,CommunityToolkit.Mvvm)

核心原则

  1. 视图模型不依赖任何 UI 框架 — 视图模型中不应出现 Window、MessageBox、Dispatcher 或 Visibility。这样无需 UI 线程即可进行单元测试,也能在 WPF、Avalonia 和 MAUI 之间移植。
  2. 使用源生成器,不手写 INotifyPropertyChanged — CommunityToolkit.Mvvm(已验证 8.4.x)会在编译时生成属性、更改通知和命令样板代码。它不使用反射,因此兼容修剪和 AOT。
  3. 使用分部属性,而非字段 — 从 8.4 开始,[ObservableProperty] 应用于 partial 属性。从 8.4.1 开始,C# 14 无需设置 LangVersion=preview 即可使用此功能。这样得到的是可添加特性并可调试的真实属性,而不是名称带有特殊含义的字段。
  4. 使用异步命令,绝不使用 async void — 在返回 Task 的方法上使用 [RelayCommand],会生成 IAsyncRelayCommand。命令运行时会禁用自身、公开异常并支持取消。
  5. 使用 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 不支持裁剪

评论

支持 Markdown