Uncategorized

【C#/WPF実践入門編(9)】MVVMパターン/アーキテクチャの基礎①~Model・View・ViewModelの3層で役割分担~

WPFアプリ開発において重要なアーキテクチャパターンであるMVVM(Model-View-ViewModel)について学びます。

この記事では、MVVMパターンの基本的な概念とメリットを理解することに専念し、実際のクラス設計と実装を通じて役割分担(責務分離)の効果を体感していきます。

以下の方に役立つ内容となっています。

  • WPFアプリの設計パターンを学びたい方
  • MVVMパターンの各層の責務を理解したい方
  • コードの保守性・再利用性・テスタビリティを向上させたい方
プロ太

前回までのデータバインディングとコマンドが、MVVMパターンを支える2本柱でしたね。今回はそれらを活用した設計パターンを学びます!

プロ美

MVVMパターンは、WPF以外にもMAUI、WinUI3、AvaloniaUIとかでも使われている大事な考え方だね!

これまでのWPF実践入門編シリーズの各記事、特にデータバインディング、コマンドの基礎についてあらかじめ学んでおくと、理解が深まります。

【C#/WPF実践入門編(5)】データバインディングの基礎①~DataContextとは?~ Windows Presentation Foundation (WPF) の特徴的な機能の一つであるデータバインディングについて学び...
【C#/WPF実践入門編(8)】コマンドの基礎 ~ICommandによる操作の分離~【MVVMパターン理解の基礎】 Windows Presentation Foundation (WPF) の重要な機能の一つであるコマンドについて学びます。 ...

前回の記事(第8回)で「UI」と「ロジックとデータ」の役割を分離したクラス設計を行いました。今回これをベースに、更なる役割の分離を進めたものであるMVVMを学びます。

演習のコード一式はGitHubに置いてあります。

動画も作成しています。

講義:MVVMパターンとは?

これまでのアプローチの問題

これまでの記事では、以下のような構成でカウンターアプリを作成してきました。

データバインディングとコマンドの仕組みによって、「UI」と「データとロジック」の分離は実現できました。

しかし、Counterクラスに「UI用の処理」が混在しているという問題がまだ残っているのです。

以下のように、CounterクラスにはWPFの実装に依存したコード(プロパティ通知やコマンド)が含まれていますね。

public class Counter : INotifyPropertyChanged
{
...
  public ICommand IncrementCommand => _incrementCommand;
  ...
  public event PropertyChangedEventHandler? PropertyChanged;
...
}

これにより、具体的には次のような問題が生じます。

  • データ+ロジック」を再利用しにくい
    (例)別UIフレームワーク(Blazor、MAUIなど)で再利用しにくい
  • 純粋な「データ+ロジック」のみのテストを行いにくい
プロ太

Counterクラスはもうちょっと役割で分割できる余地がある、ということなんですね。

MVVMパターンによる解決

MVVM(Model-View-ViewModel)パターンは、アプリを以下の3つの層に分離する設計パターンです。

View→ViewModel→Modelという方向で依存しています。逆方向への依存はNGです。

この分離により、以下のように役割を明確化できます。

  • View層 (CounterWindow.xaml/.xaml.cs)
    • UI要素の定義(XAML)
    • DataContextの設定のみ(コードビハインド)
  • ViewModel層 (CounterViewModel.cs)
    • プロパティ変更通知 (INotifyPropertyChanged)
    • コマンドの公開 (ICommand)
    • Modelを操作
  • Model層 (CounterModel.cs) 【UIフレームワーク非依存】
    • 純粋なビジネスロジック
    • データの保持と操作
プロ美

Model層のCounterModelクラスはUI実装に依存しないコードになっていて、例えばBlazorとか別フレームワークでも再利用できるんだね!

プロ太

その通りです!あらためて、MVVMパターンのメリットを整理してみましょう。

MVVMパターンのメリット

MVVMパターンで View・ViewModel・Model を分けることで、次のようなメリットがあります。

「UI」と「データとロジック」が分離される

UIの見た目(XAML)とアプリの動作が分かれるため、デザイナーと開発者が同時に作業しやすくなります。(「View」 と 「View Model/Model」分離の利点

「データとロジック」を再利用できる

ModelはUIフレームワークに依存しないため、他の環境(Blazor、MAUI、コンソールアプリなど)でも流用可能です。(「View/ViewModel」と「Model」分離の利点

テストがしやすい

ModelやViewModelはUI依存がないため、単体テストを簡単に書けます。

特に ViewModelをテスト すると、「UIを起動せずに、UI操作に近い挙動」を確認できます。

プロ太

UIを含めたEndToEndテストは実装/実行にコストがかかるため、これは嬉しい点ですね。

保守性・拡張性が高まる

役割が明確に分かれているため、修正や追加の影響範囲を限定できます。

例えば「UIを新しく作り直す」「ロジックを改良する」といった変更が、他の層に波及しにくくなります。

演習:カウンターアプリをMVVMで再構築

作成するアプリの概要

前回作成したカウンターアプリを、MVVMパターンに従って新規プロジェクトとして再構築します。

主要なクラスは以下のような構成になります。(アプリの動作は前回と全く同じです)

WpfCounterApp/
├── CounterWindow.xaml          (View)
├── CounterWindow.xaml.cs       (View - コードビハインド)
├── CounterViewModel.cs         (ViewModel)
├── CounterModel.cs            (Model)
└── SimpleCommand.cs           (汎用コマンドクラス)
…
プロ太

今回、MVVMの基本的な概念理解に専念するため、プロジェクトフォルダ直下へ全てのコードを配置します。

MVVMにおけるより実践的なフォルダ配置については次回説明をします。

以下の手順で作成します。

  • 手順1:SimpleCommand.csを作成(汎用コマンド
  • 手順2:CounterModel.csを作成(Model
  • 手順3: CounterViewModel.cs (ViewModel)
  • 手順4:CounterWindow.xaml(View

手順1:SimpleCommand.csを作成(汎用コマンド)

WpfCounterAppという名前のWPF新規プロジェクトを作成し、前回と同じ汎用コマンドクラスとして「SimpleCommand.cs」を作成します。

using System.Windows.Input;

namespace WpfCounterApp
{
    public class SimpleCommand : ICommand
    {
        private readonly Action<object?> _execute;
        private readonly Predicate<object?>? _canExecute;

        public SimpleCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
        {
            _execute = execute ?? throw new ArgumentNullException(nameof(execute));
            _canExecute = canExecute;
        }

        public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
        public void Execute(object? parameter) => _execute(parameter);

        public event EventHandler? CanExecuteChanged;

        public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
    }
}
プロ太

SimpleCommandは、CommunityToolkit.MvvmにおけるRelayCommand相当のものです。

手順2: CounterModel.cs(Model)

UIフレームワークに一切依存しない、純粋な「データとロジック」です。

namespace WpfCounterApp
{
    public class CounterModel
    {
        public int Value { get; private set; }

        public void Increment()
        {
            Value++;
        }

        public bool CanDecrement()
        {
            return Value > 0;
        }

        public void Decrement()
        {
            if (CanDecrement())
            {
                Value--;
            }
        }
    }
}

手順3: CounterViewModel.cs (ViewModel)

ViewとModelの橋渡しを行うクラスです。

using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;

namespace WpfCounterApp
{
    public class CounterViewModel : INotifyPropertyChanged
    {
        private readonly CounterModel _model;
        private readonly SimpleCommand _incrementCommand;
        private readonly SimpleCommand _decrementCommand;

        public CounterViewModel()
        {
            _model = new CounterModel();
            _incrementCommand = new SimpleCommand(_ => ExecuteIncrement());
            _decrementCommand = new SimpleCommand(_ => ExecuteDecrement(), _ => _model.CanDecrement());
        }

        public int Count => _model.Value;

        public ICommand IncrementCommand => _incrementCommand;
        public ICommand DecrementCommand => _decrementCommand;

        private void ExecuteIncrement()
        {
            _model.Increment();
            OnPropertyChanged(nameof(Count));
        }

        private void ExecuteDecrement()
        {
            _model.Decrement();
            OnPropertyChanged(nameof(Count));
        }

        public event PropertyChangedEventHandler? PropertyChanged;

        protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

            if (propertyName == nameof(Count))
            {
                _decrementCommand.RaiseCanExecuteChanged();
            }
        }
    }
}
プロ太

純粋な「データとロジック」に関する役割は、CounterModelクラスへ任せています。

手順4: CounterWindow.xaml(View)

以下のステップでMainWindow(WPF新規プロジェクトひな型に含まれる)をCounterWindowで置き換えましょう。

  1. MainWindow.xamlを削除
  2. CounterWindow.xamlを追加
  3. App.xamlで、初期起動画面をCounterWindow.xamlへ修正

①MainWindow.xamlを削除

ソリューションエクスプローラ上でMainWindow.xamlを削除します。●●.xamlを削除すると対応するコードビハインド(●●.xaml.cs)も自動削除されます。

②CounterWindow.xamlを追加

新しい項目で「ウィンドウ(WPF)」を選択して「CounterWindow」という名前で追加しましょう。

CounterWindow.xamlは前回と同様、以下のようにします。

<Window x:Class="WpfCounterApp.CounterWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfCounterApp"
        mc:Ignorable="d"
        Title="CounterWindow" Height="450" Width="800">
    <StackPanel Margin="20" VerticalAlignment="Center">
        <!-- カウンター表示 -->
        <TextBlock Text="{Binding Count, StringFormat='Count: {0}'}" 
               FontSize="24" 
               HorizontalAlignment="Center" 
               Margin="0,0,0,20"/>

        <!-- ボタン群 -->
        <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
            <Button Content="-" 
                Command="{Binding DecrementCommand}" 
                Width="40" Height="30"/>
            <Button Content="+" 
                Command="{Binding IncrementCommand}" 
                Width="40" Height="30"/>
        </StackPanel>

        <!-- 説明テキスト -->
        <TextBlock Text="「-」ボタンは0以下で自動無効化されます" 
               HorizontalAlignment="Center" 
               Margin="0,15,0,0" />
    </StackPanel>
</Window>

そして、コードビハインド(CounterWindow.xaml.cs)で、DataContextとしてViewModelであるCounterViewModelのインスタンスを設定します。

using System.Windows;

namespace WpfCounterApp
{
    /// <summary>
    /// CounterWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class CounterWindow : Window
    {
        public CounterWindow()
        {
            InitializeComponent();
            DataContext = new CounterViewModel();
        }
    }
}

③App.xamlで、初期起動画面をCounterWindow.xamlへ修正

最後にApp.xamlで「StartupUri=”CounterWindow.xaml”」と修正しましょう。これで、アプリ起動時にCounterWindowが表示されます。

<Application x:Class="WpfCounterApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfCounterApp"
             StartupUri="CounterWindow.xaml">
    <Application.Resources>
         
    </Application.Resources>
</Application>
プロ太

これで、MVVM版のカウンターアプリが完成です!

アプリを実行

アプリを実行すると、前回と同じ機能を持つカウンターアプリが動作します。

しかし、内部的には以下のような役割分離が実現されています。

  • CounterModel: カウンターの値と操作ロジック(WPFに依存しない)
  • CounterViewModel: WPFとの橋渡し(データバインディング・コマンド)
  • CounterWindow: UI表示(XAML + 最小限のコードビハインド)
プロ美

前回と見た目は同じだけど、内部では明確に役割分担しているんだね!

プロ太

その通りです!

今回は非常にシンプルな例で、「MVVMパターンのHello World」的なものですね。これからさらに理解を深めていきましょう。

まとめ

本記事では、WPFアプリ開発における重要な設計パターンであるMVVM(Model-View-ViewModel)について学びました。

MVVMパターンによる役割分離には以下の特徴と利点があります。

  • 明確な責務の分離:View(UI)、ViewModel(UIとの橋渡し)、Model(純粋なデータとロジック)の3層に分けることで、それぞれの役割が明確になる
  • 再利用性の向上:ModelはUIフレームワークに依存しないため、Blazor、MAUI、コンソールアプリなど他の環境でも流用できる
  • テスタビリティの改善:UIを起動せずにViewModelやModelの単体テストが可能で、開発効率が大幅に向上する
  • 保守性・拡張性の向上:役割が分離されているため、UIの変更やロジックの修正が他の層に波及しにくい

カウンターアプリの演習を通じて、これまでの「UI」と「データとロジック」の2層構造から、より洗練された3層構造への発展を実践しました。

次回は、MVVMパターンにおけるフォルダ構成の基本について解説予定です。

プロ太

引き続き、一緒にWPFアプリ開発における実践的な設計パターンを学んでいきましょう!

ABOUT ME
プロ太
●仕事:現在は個人事業主(メンター・情報発信等)、大手IT企業で技術者・マネージャ(15年以上)、大学の外部講師、学生時代は学習塾で非常勤講師(約4年間) ●博士(工学)の学位取得 ●高校生の頃に独学で始め、プログラミング歴20年以上 ●言語:C# 、Java、C/C++、Python、JavaScript/TypeScript等 ●分野:Webアプリ、テスト自動化、生成AI、デバッガ、コード解析、ドメイン特化言語

ご依頼・ご相談について

プログラミング学習のご相談、お仕事のご依頼については、
こちらのお問い合わせページをご確認ください。