desktop-mvvm について
.NET デスクトップアプリ開発者向けの MVVM 実践ガイドです。CommunityToolkit.Mvvm を軸に、テスト可能なビューモデルや、UI フレームワークをまたいで使える構成パターンを解説します。
主な機能
- 01ソースジェネレーターによるプロパティとコマンド生成
- 02長時間処理向けの非同期コマンド
- 03Generic Host による依存関係の構成
- 04ビューモデル先行のナビゲーションとメッセージング
- 05UI スレッド不要の検証と単体テスト
活用シーン
- 01WPF アプリの依存性注入の構成
- 02Avalonia や 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 からはLangVersion=previewを指定せずに C# 14 で動作します。マジックネームを持つフィールドではなく、属性を付けてデバッグできる実際のプロパティを利用できます。 - 非同期コマンドを使い、
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 の場合を除く)、ダブルクリックしても読み込みが二重に開始されません。手作業で IsBusy フラグを管理する代わりに、ビジーインジケーターを LoadCommand.IsRunning にバインドしてください。
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;
}
}
理由: バックエンドのナレッジと共有するコンポジションルートを 1 つにできます。ウィンドウへのコンストラクターインジェクションにより、コードビハインドを 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;)を用意できます。Task.Run で囲むのは CPU 負荷の高い処理だけにしてください。すでに非同期の 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 では、同じビューモデルを変更せずに使用します。MauiProgram.CreateMauiApp() で builder.Services を使って登録し、Shell のルートで遷移し(Shell.Current.GoToAsync("orders"))、バックグラウンドのコールバックは MainThread.InvokeOnMainThreadAsync を使ってマーシャリングします。Avalonia と同様に、コンパイル済みバインディングを使うページでは x:DataType を設定します。
アンチパターン
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 |
| 1 つのコードベースでモバイルとデスクトップに対応 | MAUI(Linux デスクトップが必要な場合は Avalonia) |
| レガシーな WinForms | WinForms を維持し、ロジックをビューモデルに移行します(Button.Command / DataContext バインディングは .NET 7 以降で利用できます) |
| オブザーバブルプロパティ | [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 スレッドで状態を更新 |
| VM 間の通知 | レコードメッセージを使った IMessenger(WeakReferenceMessenger) |
| ダイアログ/ファイルピッカー | ビュー層で実装する IDialogService インターフェイス |
| トリミング/Native AOT が必要 | コンパイル済みバインディングを使う Avalonia。WPF/WinForms はトリミングをサポートしていません |