はじめに
かずきさん(@okazuki)のブログを読んで感化され、当時WindowsFormsでMVVMをやろうとしたことがあります。
結果は完全に自己満足で終わりました。
INotifyPropertyChangedを自前実装して、ICommandも自前で書いて、バインディングも手動でゴリゴリ書いて…コード量が増えただけで「これ、何のメリットあるんだっけ?」ってなったやつです。
あのとき感じた「なんか違う」の正体が、今なら言語化できます。そして**.NET 10のWindowsFormsなら、あの苦労がうそのように楽になっています**。
環境構築は前の記事を参照してください。(.NET 10 devcontainer環境)
devcontainer(Linux環境)でWindowsFormsを使う場合の注意
devcontainerはLinux上で動作するため、dotnet new winformsでプロジェクトを作成するとそのままではエラーになります。
NETSDK1100: To build a project targeting Windows on this operating system, set the EnableWindowsTargeting property to true.
.csprojに以下を追加することでビルドは通るようになります。
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<!-- これを追加 -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
ただし実行(dotnet run)はLinux上ではできません。コードの動作確認はWindows環境で行ってください。
MVVMとは何か(概念の整理)
MVVMはModel-View-ViewModelの略で、2005年頃にMicrosoftのJohn GossmanがWPF向けに提唱したアーキテクチャパターンです。
View ←── バインディング ──→ ViewModel ←── ViewModel操作 ──→ Model
(画面) (画面用ロジック) (ビジネスロジック)
ポイントはViewとViewModelがバインディングで繋がっていることです。ViewModelのプロパティが変わると自動でUIが更新され、UIの操作が自動でViewModelに伝わる。これがMVVMの核心です。
MVVMに必要な3つの部品
| 部品 | 役割 | .NETのインターフェース |
|---|---|---|
| プロパティ変更通知 | VMの変更をViewに伝える | INotifyPropertyChanged |
| コマンド | ボタン等の操作をVMで受け取る | ICommand |
| バインディング基盤 | ViewとVMを繋ぐ仕組み | フレームワーク依存 |
ここが重要で、MVVMはバインディング基盤が整って初めて機能するパターンです。WPFがXAMLとデータバインディングをセットで提供したからこそMVVMが成立しました。
ボイラープレートとは?
「毎回同じように書かなければならない、本質的でない定型コード」のことです。蒸気機関のボイラーが規格通りに作られることに由来します。INotifyPropertyChangedの実装がその典型で、プロパティを1つ追加するたびに同じようなコードを繰り返し書く必要がありました。
昔の苦労(自前実装時代)
.NET Framework 4.8時代のWindowsFormsでMVVMをやろうとすると、こうなります。
INotifyPropertyChangedを自前実装する
using System.ComponentModel;
namespace MyWinFormsApp;
// ボイラープレートの塊
public class NotificationObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void RaisePropertyChanged(string propertyName)
{
var handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}
ICommandも自前実装する
using System.Windows.Input;
namespace MyWinFormsApp;
// DelegateCommandも自前で書く
public class DelegateCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public DelegateCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
ViewModelを書く
namespace MyWinFormsApp;
public class GreetingViewModel : NotificationObject
{
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
_name = value;
RaisePropertyChanged(nameof(Name)); // 毎回これを書く
}
}
private string _greeting = string.Empty;
public string Greeting
{
get => _greeting;
set
{
_greeting = value;
RaisePropertyChanged(nameof(Greeting)); // 毎回これを書く
}
}
public DelegateCommand GreetCommand { get; }
public GreetingViewModel()
{
GreetCommand = new DelegateCommand(ExecuteGreet);
}
private void ExecuteGreet()
{
Greeting = $"こんにちは、{Name}さん!";
}
}
フォーム側でバインディングを手動で書く
devcontainerではデザイナーが使えない
Visual StudioのGUIデザイナーはWindowsでしか動かないため、devcontainer上ではコントロールをコードで追加します。Form1.Designer.csは自動生成されたままにしておき、Form1.csのコンストラクタでコントロールを生成・配置します。
namespace MyWinFormsApp;
public partial class Form1 : Form
{
private readonly GreetingViewModel _vm;
// コントロールをフィールドとして宣言
private TextBox textBoxName = new();
private Label labelGreeting = new();
private Button buttonGreet = new();
public Form1()
{
InitializeComponent();
// コントロールの配置
textBoxName.Location = new Point(20, 20);
textBoxName.Width = 200;
labelGreeting.Location = new Point(20, 60);
labelGreeting.Width = 300;
labelGreeting.Text = "ここに挨拶が表示されます";
buttonGreet.Location = new Point(20, 100);
buttonGreet.Text = "挨拶する";
Controls.AddRange([textBoxName, labelGreeting, buttonGreet]);
// ViewModelの生成とバインディング
_vm = new GreetingViewModel();
// バインディングを手動でゴリゴリ書く
textBoxName.DataBindings.Add(
nameof(textBoxName.Text), _vm, nameof(_vm.Name),
false, DataSourceUpdateMode.OnPropertyChanged);
labelGreeting.DataBindings.Add(
nameof(labelGreeting.Text), _vm, nameof(_vm.Greeting));
// ボタンはCommandに繋がらないのでイベントハンドラで妥協
buttonGreet.Click += (s, e) => _vm.GreetCommand.Execute(null);
}
}
…なんか違う
- プロパティを追加するたびに
RaisePropertyChangedを書く -
DelegateCommandも自前実装 - ボタンはCommandにバインドできないのでイベントハンドラで妥協
- コード量が増えただけでXAMLのような恩恵がない
これが「自己満足で終わった」正体です。道具が揃っていないのにパターンだけ真似した状態でした。
今風のコード(.NET 10 + CommunityToolkit.Mvvm)
.NET 8以降、WindowsFormsのバインディングエンジンがWPFに倣って強化され、ButtonにCommandプロパティが追加されました。さらにCommunityToolkit.Mvvmを使えば、ボイラープレートがほぼゼロになります。
NuGetパッケージを追加
dotnet add package CommunityToolkit.Mvvm
ViewModelを書く(今風)
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace MyWinFormsApp;
// partialが必須(ソースジェネレーターが使うため)
public partial class GreetingViewModel : ObservableObject
{
// [ObservableProperty]を付けるだけでプロパティ+変更通知が自動生成される
[ObservableProperty]
private string _name = string.Empty;
[ObservableProperty]
private string _greeting = string.Empty;
// [RelayCommand]を付けるだけでICommandが自動生成される
[RelayCommand]
private void Greet()
{
Greeting = $"こんにちは、{Name}さん!";
}
}
昔と比べてみると:
| 昔(自前実装) | 今(CommunityToolkit.Mvvm) |
|---|---|
NotificationObjectを自前実装 |
ObservableObjectを継承するだけ |
プロパティごとにRaisePropertyChanged
|
[ObservableProperty]属性1行 |
DelegateCommandを自前実装 |
[RelayCommand]属性1行 |
フォーム側を書く(今風)
namespace MyWinFormsApp;
public partial class Form1 : Form
{
private readonly GreetingViewModel _vm;
// コントロールをフィールドとして宣言
private TextBox textBoxName = new();
private Label labelGreeting = new();
private Button buttonGreet = new();
public Form1()
{
InitializeComponent();
// コントロールの配置
textBoxName.Location = new Point(20, 20);
textBoxName.Width = 200;
labelGreeting.Location = new Point(20, 60);
labelGreeting.Width = 300;
labelGreeting.Text = "ここに挨拶が表示されます";
buttonGreet.Location = new Point(20, 100);
buttonGreet.Text = "挨拶する";
Controls.AddRange([textBoxName, labelGreeting, buttonGreet]);
// ViewModelの生成とバインディング
_vm = new GreetingViewModel();
// DataContextを設定(.NET 8以降)
this.DataContext = _vm;
// テキストボックスのバインディング
textBoxName.DataBindings.Add(
nameof(textBoxName.Text), _vm, nameof(_vm.Name),
false, DataSourceUpdateMode.OnPropertyChanged);
labelGreeting.DataBindings.Add(
nameof(labelGreeting.Text), _vm, nameof(_vm.Greeting));
// .NET 8以降はButtonにCommandプロパティが追加された!
buttonGreet.Command = _vm.GreetCommand;
}
}
buttonGreet.Command = _vm.GreetCommand; の1行。これだけです。昔はClickイベントで妥協していたところが、WPFと同じ感覚でCommandにバインドできるようになりました。
devcontainerでユニットテストを書く
MVVMの最大のメリットのひとつがテスタビリティです。ViewModelはUIに依存しない純粋なC#クラスなので、devcontainer(Linux)上でもユニットテストが普通に動きます。
devcontainerでできること
├── ビルド(エラーチェック) ✅
├── ViewModelのユニットテスト ✅ ← MVVMの恩恵!
└── 実際の画面を動かして確認 ❌(Windowsが必要)
「ロジックの開発とテストはdevcontainerで、画面確認だけWindowsで」という分業ができます。
プロジェクト構成を分離する
テストプロジェクトからWinFormsプロジェクトを直接参照しようとすると、Linux上でWindowsDesktop SDKを引っ張ろうとしてエラーになります。
NETSDK1073: The FrameworkReference 'Microsoft.WindowsDesktop.App.WindowsForms' was not recognized
解決策はViewModelを別のクラスライブラリに分離することです。これはMVVMのアーキテクチャ的にも正しい形です。
MyWinFormsApp.Core(net10.0) ← ViewModelはここ。Windowsに依存しない
MyWinFormsApp(net10.0-windows) ← Formだけ。Coreを参照
MyWinFormsApp.Tests(net10.0) ← Coreを参照。Windowsに依存しない
Coreプロジェクトを作成する
# Coreプロジェクト作成
dotnet new classlib -n MyWinFormsApp.Core -o src/MyWinFormsApp.Core
# CommunityToolkit.MvvmをCoreに追加
dotnet add src/MyWinFormsApp.Core/MyWinFormsApp.Core.csproj package CommunityToolkit.Mvvm
# WinFormsプロジェクトからCoreを参照
dotnet add src/MyWinFormsApp/MyWinFormsApp.csproj reference src/MyWinFormsApp.Core/MyWinFormsApp.Core.csproj
GreetingViewModel.csをsrc/MyWinFormsApp.Core/に移動します。
テストプロジェクトを作成する
dotnet new xunit -n MyWinFormsApp.Tests -o src/MyWinFormsApp.Tests
dotnet add src/MyWinFormsApp.Tests/MyWinFormsApp.Tests.csproj reference src/MyWinFormsApp.Core/MyWinFormsApp.Core.csproj
テストプロジェクトはnet10.0(クロスプラットフォーム)のままでOKです。WinFormsに依存しないCoreだけを参照するためです。
テストを書く
namespace MyWinFormsApp.Tests;
public class GreetingViewModelTests
{
[Fact]
public void Greet_名前を入力して挨拶コマンドを実行すると_挨拶文が設定される()
{
// Arrange
var vm = new GreetingViewModel();
vm.Name = "太郎";
// Act
vm.GreetCommand.Execute(null);
// Assert
Assert.Equal("こんにちは、太郎さん!", vm.Greeting);
}
[Fact]
public void Greet_名前が空の場合_挨拶文が空名前で設定される()
{
// Arrange
var vm = new GreetingViewModel();
vm.Name = string.Empty;
// Act
vm.GreetCommand.Execute(null);
// Assert
Assert.Equal("こんにちは、さん!", vm.Greeting);
}
[Fact]
public void Name_変更時_PropertyChangedが発火する()
{
// Arrange
var vm = new GreetingViewModel();
var changedProperties = new List<string?>();
vm.PropertyChanged += (s, e) => changedProperties.Add(e.PropertyName);
// Act
vm.Name = "花子";
// Assert
Assert.Contains(nameof(vm.Name), changedProperties);
}
}
テストを実行する
dotnet test src/MyWinFormsApp.Tests
Test summary: total: 3, failed: 0, succeeded: 3, skipped: 0, duration: 1.0s
Build succeeded in 6.0s
devcontainer上で3テスト全部通ります。ViewModelにロジックが集約されているからこそ、UIを起動しなくてもテストが書けます。これが昔の「Button_Clickにロジック直書き」との決定的な違いです。
実行結果
動作確認はWindows環境が必要なため、本記事ではビルドとテストの成功までを確認範囲としています。画面の動作確認については読者の環境でお試しください。
ビルドとテストについては以下を確認しています。
-
dotnet build→ devcontainer上でビルド成功 -
dotnet test→ 3テスト全て成功
Test summary: total: 3, failed: 0, succeeded: 3, skipped: 0, duration: 1.0s
Build succeeded in 6.0s
まとめ
| .NET Framework 4.8時代 | .NET 10 | |
|---|---|---|
INotifyPropertyChanged |
自前実装必須 |
ObservableObject継承のみ |
| プロパティ変更通知 |
RaisePropertyChangedを毎回書く |
[ObservableProperty]属性1行 |
ICommand |
自前でDelegateCommandを書く |
[RelayCommand]属性1行 |
| Buttonとコマンドの紐付け |
Clickイベントで妥協 |
Commandプロパティに直接代入 |
当時感じた「なんか違う」の正体は、道具が揃っていない環境でパターンだけを真似しようとしていたことでした。
MVVMはバインディング基盤とセットで初めて機能するパターンです。WPFやXAMLが必要とされた理由も、今なら腑に落ちます。
.NET 10のWindowsFormsは、昔と比べると別物と言っていいくらい整備されています。レガシーなFormsのコードベースを持っている方にとっても、段階的にMVVMを取り入れていける環境になってきました。