desktop-mvvm

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Desktop MVVM (.NET 10, CommunityToolkit.Mvvm)

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

Core Principles

核心原则

  1. View models know nothing about the UI framework — No
    Window
    ,
    MessageBox
    ,
    Dispatcher
    , or
    Visibility
    in a view model. That keeps them unit-testable without a UI thread and portable between WPF, Avalonia, and MAUI.
  2. Source generators over hand-written
    INotifyPropertyChanged
    — 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.
  3. Use partial properties, not fields — Since 8.4,
    [ObservableProperty]
    goes on
    partial
    properties. From 8.4.1 that works on C# 14 with no
    LangVersion=preview
    . You get real properties that you can put attributes on and debug, instead of fields with magic names.
  4. Async commands, never
    async void
    —
    [RelayCommand]
    on a
    Task
    -returning method generates an
    IAsyncRelayCommand
    . It disables itself while running, surfaces exceptions, and supports cancellation.
  5. Generic Host is the composition root — DI, configuration, logging, and
    IHttpClientFactory
    work the same as in ASP.NET Core. Views and view models are resolved from the container, never
    new
    -ed in code-behind.
  1. ViewModel 对 UI 框架一无所知 — ViewModel 中不应出现
    Window
    、
    MessageBox
    、
    Dispatcher
    或
    Visibility
    。这样无需 UI 线程即可对 ViewModel 进行单元测试,并可在 WPF、Avalonia 和 MAUI 之间移植。
  2. 使用源生成器,而不是手写
    INotifyPropertyChanged
    — CommunityToolkit.Mvvm(8.4.x,已验证)会在编译时生成属性、变更通知和命令样板代码。由于不依赖反射,因此适合裁剪和 AOT。
  3. 使用 partial 属性,而不是字段 — 从 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 相同。View 和 ViewModel 都从容器解析,绝不在 code-behind 中通过
    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
partial
so the generator can add members.
Async
is stripped from command names (
LoadAsync
→
LoadCommand
). The command is disabled while it runs (unless
AllowConcurrentExecutions = true
), so double-clicks can't start two loads. Bind a busy indicator to
LoadCommand.IsRunning
instead of keeping a hand-rolled
IsBusy
flag.
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
}
原因: 类必须为
partial
,生成器才能添加成员。命令名称中的
Async
会被移除(
LoadAsync
→
LoadCommand
)。命令运行期间会禁用自身(除非设置
AllowConcurrentExecutions = true
),因此双击无法启动两次加载。将忙碌指示器绑定到
LoadCommand.IsRunning
,而不是维护手写的
IsBusy
标志。

Generic Host in WPF

WPF 中的 Generic Host

Remove
StartupUri
from
App.xaml
, then own startup in
App.xaml.cs
:
csharp
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
InitializeComponent
+
DataContext
. Setting the content root to
AppContext.BaseDirectory
stops a shortcut's "Start in" folder from changing which config loads. Avalonia wiring lives in the
avalonia
skill.
从
App.xaml
中移除
StartupUri
,然后在
App.xaml.cs
中自行管理启动流程:
csharp
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 只保留
InitializeComponent
和
DataContext
。将内容根设为
AppContext.BaseDirectory
,可防止快捷方式的“起始位置”文件夹改变所加载的配置文件。Avalonia 的接线位于
avalonia
skill 中。

UI-Thread Dispatching

UI 线程调度

await
started on the UI thread resumes on the UI thread, so most code needs no dispatching. Dispatch only for callbacks that arrive on background threads (timers,
FileSystemWatcher
, sockets, message buses).
csharp
// 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:
Progress<T>
is framework-neutral and needs zero plumbing. The
IUiDispatcher
interface keeps the
Dispatcher
type out of view models, and tests can supply an inline implementation (
action(); return Task.CompletedTask;
).
Task.Run
belongs around CPU-bound work only. Never wrap I/O that is already async.
在 UI 线程上启动的
await
在恢复执行时仍会回到 UI 线程,因此大多数代码都无需调度。仅应对来自后台线程的回调进行调度(定时器、
FileSystemWatcher
、套接字、消息总线)。
csharp
// 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
接口可避免在 ViewModel 中出现
Dispatcher
类型,测试则可提供内联实现(
action(); return Task.CompletedTask;
)。
Task.Run
只应用于 CPU 密集型工作。绝不要用
Task.Run
包裹本身已经异步的 I/O。

View-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 (
Assert.IsType<OrdersViewModel>(navigator.Current)
).
IServiceProvider
is confined to the navigator so it doesn't spread as a service locator. Register page view models as
Transient
for a fresh page each visit,
Singleton
to keep state across visits.
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>
原因: ViewModel 按类型导航,而视图层决定每个 ViewModel 的呈现方式。这样导航逻辑也可以进行测试(
Assert.IsType<OrdersViewModel>(navigator.Current)
)。
IServiceProvider
仅限于在 Navigator 中使用,避免扩散成 service locator。将页面 ViewModel 注册为
Transient
,可在每次访问时获得全新页面;注册为
Singleton
,则可在多次访问之间保留状态。

Decoupled 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.
WeakReferenceMessenger
holds recipients weakly, so a closed page's view model can still be garbage collected with no explicit unregister. Messages are immutable records.
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
}
原因: 同级 ViewModel 不应相互引用。
WeakReferenceMessenger
对接收者使用弱引用,因此关闭页面后,其 ViewModel 仍可被垃圾回收,无需显式注销。消息使用不可变 record。

Validation

验证

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:
ObservableValidator
implements
INotifyDataErrorInfo
, which WPF, Avalonia, and MAUI all render natively. Validation rules live with the data, not in XAML.
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
    }
}
原因:
ObservableValidator
实现了
INotifyDataErrorInfo
,WPF、Avalonia 和 MAUI 都能原生呈现验证错误。验证规则与数据放在一起,而不是写在 XAML 中。

Testing 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
FakeTimeProvider
for time.
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));
}
原因: ViewModel 是普通类,因此无需 UI 线程或窗口即可运行测试。对于自有服务,请使用手写的 fake(无需 mocking framework);对于时间,则使用
FakeTimeProvider
。

MAUI Note

MAUI 说明

MAUI uses the same view models unchanged. Register them in
MauiProgram.CreateMauiApp()
via
builder.Services
, navigate with Shell routes (
Shell.Current.GoToAsync("orders")
), and marshal background callbacks with
MainThread.InvokeOnMainThreadAsync
. Set
x:DataType
on pages for compiled bindings, as in Avalonia.
MAUI 可以原样使用相同的 ViewModel。通过
builder.Services
在
MauiProgram.CreateMauiApp()
中注册它们,使用 Shell 路由进行导航(
Shell.Current.GoToAsync("orders")
),并通过
MainThread.InvokeOnMainThreadAsync
调度后台回调。与 Avalonia 一样,应为页面设置
x:DataType
以启用编译绑定。

Anti-patterns

反模式

async void
Commands and Blocking the UI Thread

async void
命令与阻塞 UI 线程

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);
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

决策指南

ScenarioRecommendation
New cross-platform desktop appAvalonia + CommunityToolkit.Mvvm (see
avalonia
skill)
Windows-only, existing WPF skills or controlsWPF + CommunityToolkit.Mvvm + Generic Host
Mobile + desktop from one codebaseMAUI (or Avalonia if Linux desktop is required)
Legacy WinFormsKeep WinForms; move logic into view models (
Button.Command
/
DataContext
binding exists since .NET 7)
Observable property
[ObservableProperty] public partial T Name { get; set; }
Derived property (e.g.
FullName
)
[NotifyPropertyChangedFor(nameof(FullName))]
on its inputs
Button enablement depends on state
[RelayCommand(CanExecute = ...)]
+
[NotifyCanExecuteChangedFor]
Long-running command
async Task
+
CancellationToken
,
IncludeCancelCommand = true
Busy indicatorBind to
XCommand.IsRunning
CPU-heavy work
Task.Run
+
Progress<T>
, then update state on the UI thread
Cross-VM notifications
IMessenger
(
WeakReferenceMessenger
) with record messages
Dialogs / file pickers
IDialogService
interface implemented in the view layer
Need trimming / Native AOTAvalonia with compiled bindings; WPF/WinForms don't support trimming
场景建议
新的跨平台桌面应用Avalonia + CommunityToolkit.Mvvm(参见
avalonia
skill)
仅限 Windows,已有 WPF skill 或控件WPF + CommunityToolkit.Mvvm + Generic Host
使用同一代码库同时支持移动端和桌面端MAUI(如果必须支持 Linux 桌面端,则使用 Avalonia)
旧版 WinForms保留 WinForms;将逻辑移入 ViewModel(自 .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 线程上更新状态
跨 ViewModel 通知
IMessenger
(
WeakReferenceMessenger
),配合 record 消息
对话框 / 文件选择器由视图层实现
IDialogService
接口
需要裁剪 / Native AOTAvalonia 配合编译绑定;WPF/WinForms 不支持裁剪