1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WindowsFormsでMVVMをやろうとして自己満足で終わった話と、.NET 10ならこう書ける話

1
Posted at

はじめに

かずきさん(@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に以下を追加することでビルドは通るようになります。

MyWinFormsApp.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を自前実装する

NotificationObject.cs
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も自前実装する

DelegateCommand.cs
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を書く

GreetingViewModel.cs
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のコンストラクタでコントロールを生成・配置します。

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に倣って強化され、ButtonCommandプロパティが追加されました。さらにCommunityToolkit.Mvvmを使えば、ボイラープレートがほぼゼロになります。

NuGetパッケージを追加

terminal
dotnet add package CommunityToolkit.Mvvm

ViewModelを書く(今風)

GreetingViewModel.cs
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行

フォーム側を書く(今風)

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

        // 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プロジェクトを作成する

terminal
# 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.cssrc/MyWinFormsApp.Core/に移動します。

テストプロジェクトを作成する

terminal
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だけを参照するためです。

テストを書く

GreetingViewModelTests.cs
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);
    }
}

テストを実行する

terminal
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を取り入れていける環境になってきました。


参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?