WPF

【C#/WPF実践入門編(11)】MVVMパターン/アーキテクチャの基礎③ ~複数ViewModelでModelを共有する設計と依存性注入(DI)の実践~

前回はMVVMパターンの実践的なフォルダ構成とサービス層について学びました。今回は、複数のViewModelで1つのModelを共有する方法を学習します。

実際のアプリ開発では、複数の画面やコンポーネントが同じデータを扱うことがよくあります。MVVMパターンでこれをどう実現するかを理解することが、本記事のゴールです。

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

  • MVVMでコードを適切に分割したい
  • 複数のViewModelで同じModelを共有する方法を知りたい
  • View・ViewModel・Modelの適切な構築方法を知りたい
  • 依存性注入の実例を学びたい

この記事では、前回作成したカウンターアプリをベースに、偶数/奇数を表示する新しいView/ViewModelを追加し、複数のViewModelが1つのModelを共有する実装を行います。

MVVM解説の記事①・②から続けて見ていただくと、理解が深まるかと思います。

【C#/WPF実践入門編(9)】MVVMパターン/アーキテクチャの基礎①~Model・View・ViewModelの3層で役割分担~ WPFアプリ開発において重要なアーキテクチャパターンであるMVVM(Model-View-ViewModel)について学びます。 ...
【C#/WPF実践入門編(10)】MVVMパターン/アーキテクチャの基礎② ~VisualStudioでフォルダ構成とサービス層で実務向け設計~ 前回はMVVMパターンの基本概念を学びました。今回は、より実践的なMVVMアプリの設計について学習します。 適切なフォルダ構成で...
プロ太

今回は複数のViewModelを扱う方法を学びましょう!実務ではとても重要なテクニックです。

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

動画も作成しています。

講義:複数ViewModel間でのModelの共有

前回の振り返り

前回は、MVVMパターンの実践的な設計として以下を学習しました。

  • 適切なフォルダ構成による整理(Commands、Models、Services、ViewModels、Views)
  • サービス層を使った外部連携(JSONファイルによる永続化)

現在のアプリは1つのViewModel(CounterViewModel)のみで以下のように構成されていますが、

実際のアプリでは複数のViewModelを扱うことが一般的です。

プロ美

複数のViewModelを使うとどんなメリットがあるの?

プロ太

1つの画面でも、機能ごとにViewModelを分けることで、それぞれの役割が明確になり、コードの可読性や再利用性が上がるのです。

今回、その方法を学びましょう!

今回の演習で作る機能

前回のカウンターアプリにおいてカウンターの値が「偶数/奇数」のどちらかであるかを表示する機能を追加すると考えましょう。以下の2つの機能をもつアプリになります。

  • (a)カウンターの増減操作と表示
  • (b)カウンターの「偶数/奇数」表示

以下が画面の完成イメージです。

それぞれの機能ごとにView/ViewModelを作ると以下の構成になります。→は依存の方向です。(View→ViewModel→Modelという一方向の依存関係にします)

重要なポイントは以下です。

  • 1つのModelを2つのViewModelで共有
  • カウンターの値が変更されたら、両方のViewModelが更新される
    (そして、それぞれのViewも更新される)
  • CounterViewにはカウンター値と増減ボタン
  • EvenOddViewには偶数/奇数の表示
プロ太

この仕組みを実現するために必要な要素技術・概念について説明します。その後に、演習で具体的にアプリを作ります。

必要な要素技術・概念

(1)Modelの変更通知の仕組み

複数のViewModelで同じModelを共有する場合、Modelの値が変更されたことを各ViewModelに通知する必要があります。

このために、Modelには変更通知用のイベントを持たせます。

public class CounterModel
{
    public int Value { get; private set; }
    public event Action? ValueChanged;  // ★変更通知用のイベント

    public void Increment()
    {
        Value++;
        ValueChanged?.Invoke();  // ★ViewModelへ変更を通知
    }
}

そして、各ViewModelは、Modelの変更通知イベントを購読します。

public class CounterViewModel
{
    private readonly CounterModel _model;

    public CounterViewModel(CounterModel model, ...)
    {
        _model = model;
        _model.ValueChanged += OnCountChanged;  // ★変更通知を購読
    }

    private void OnCountChanged()
    {
        OnPropertyChanged(nameof(Count));  // ★Viewに変更を通知
    }
}
プロ美

なるほど!Modelが変更されたら、それを購読している全てのViewModelに通知が届き、Viewも更新されるってことだね。

(2)ユーザコントロールを使ってViewを部品化

今回のアプリでは、MainWindow(Window) の中に、CounterViewとEvenOddView(ユーザコントロール) を配置します。

以下のようなイメージです。views:CounterView、views:EvenOddViewがユーザコントロールです。

...
<StackPanel Margin="20" VerticalAlignment="Center">
    <views:CounterView x:Name="CounterView" Margin="0,0,0,20"/>

    <Separator Margin="20,0" Background="LightGray" Height="1"/>

    <views:EvenOddView x:Name="EvenOddView" Margin="0,20,0,0"/>
</StackPanel>
...

ユーザコントロールは「自分で定義したコントロール」です。コントロールについては以下で学習しました。

【C#/WPF実践入門編(4)】WPFの主要コントロール入門 ~ListView、ComboBox、TabControl、Image等の使い方~ Windows Presentation Foundation (WPF) の主要なコントロールについて、実際にアプリを作りながら学び...
プロ太

今回のように、1つの画面を機能ごとに分割する場合、ユーザコントロールが便利です。

(3)コンポジションルートと依存性注入

今回、複数のView、ViewModel、Modelを生成してそれらを組み立てる(依存関係を構築する)必要があります。

このような依存関係を構築する場所をコンポジションルートと呼びます。WPFアプリでは、App.xaml.csOnStartupメソッドがコンポジションルートとして適しています。

        protected override void OnStartup(StartupEventArgs e)
        {//ここが、コンポジションルート
            base.OnStartup(e);

            // サービスを作成
            var storage = new JsonCounterStorage();

            // Modelを作成
            var model = new CounterModel();

            // ViewModelを作成(依存性注入)
            var counterViewModel = new CounterViewModel(model, storage);
            var evenOddViewModel = new EvenOddViewModel(model);

            // MainWindowを作成し、ViewModelを設定(依存性注入)
            var mainWindow = new MainWindow();
            mainWindow.CounterView.SetViewModel(counterViewModel);
            mainWindow.EvenOddView.SetViewModel(evenOddViewModel);

            // ウィンドウを表示
            mainWindow.Show();
        }

コンポジションルートを決めて依存関係の構築を1箇所に集約すると、コードの見通しが良くなります。

このように、必要な部品(ModelやStorageなど)を外側で作り、ViewModelやViewに「渡して組み立てる」設計を「依存性注入(Dependency Injection: DI)」と呼びます。

プロ美

(modelとか、storageとか、)ViewModelの中でnewするんじゃなくて、外側から渡すんだね。

プロ太

今回の例では、ModelやStorageを App.xaml.cs で生成し、それらをコンストラクタ引数やメソッド引数としてViewModel・Viewに渡しています。

これにより、各クラスが自分で依存するオブジェクトを new せずに済むため、再利用性やテストのしやすさが高まります。

演習:カウンターアプリに偶数/奇数表示機能を追加

講義で説明した通り、前回作成したカウンターアプリへ「偶数/奇数表示機能」を追加します。

カウンター用・偶数/奇数表示用のView/ViewModelをそれぞれ作成し、カウンター値を保持するモデルは共有させます。

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

  • 手順1:CounterModel修正
  • 手順2:EvenOddViewModel、EvenOddViewを作成
  • 手順3:CounterViewModel、CounterViewを作成
  • 手順4:MainWindow.xamlを作成
  • 手順5:App.xaml.csでコンポジションルートを実装
プロ太

講義で学んだ考え方を活用して、コードを書いていきましょう!

手順1:CounterModel修正

CounterModel.csを以下のように修正します。

namespace WpfCounterApp.Models
{
    public class CounterModel
    {
        public int Value { get; private set; }
        public event Action? ValueChanged;

        public CounterModel(int initialValue = 0)
        {
            Value = initialValue;
        }

        private void Notify() => ValueChanged?.Invoke();

        public void SetValue(int value)
        {
            Value = value;
            Notify();
        }

        public void Increment()
        {
            Value++;
            Notify();
        }

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

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

Model内でValueChangedイベントを定義し、値変更時にNotify()で一括通知することで、複数のViewModelに変更を伝達できるようにしています。

初期値設定時も通知が必要なため、SetValueメソッドを使います。これにより、すべてのViewModelに初期状態が確実に伝わります。

手順2:EvenOddViewModel、EvenOddViewを作成

ViewModelとしてEvenOddViewModel.csを作成します。

using System.ComponentModel;
using System.Runtime.CompilerServices;
using WpfCounterApp.Models;

namespace WpfCounterApp.ViewModels
{
    public class EvenOddViewModel : INotifyPropertyChanged
    {
        private readonly CounterModel _model;

        public EvenOddViewModel(CounterModel model)
        {
            _model = model;

            // ★モデルの変更を購読
            _model.ValueChanged += OnCountChanged;
        }

        public string EvenOddText => _model.Value % 2 == 0 ? "Even" : "Odd";

        private void OnCountChanged()
        {
            OnPropertyChanged(nameof(EvenOddText));
        }

        public event PropertyChangedEventHandler? PropertyChanged;

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

ModelのValueChangedイベントを購読し、カウンター値の変化に応じてEvenOddTextプロパティを更新する仕組みを実装しています。

次に、EvenOddViewをユーザコントロールとして追加します。

ソリューションエクスプローラで「Views」フォルダを右クリックし、「追加>新しい項目>ユーザコントロール(WPF)」で追加しましょう。

EvenOddView.xamlは以下のように修正します。

<UserControl x:Class="WpfCounterApp.Views.EvenOddView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfCounterApp.Views"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <StackPanel Margin="20">
        <TextBlock Text="Even/Odd" 
                   FontWeight="Bold" 
                   HorizontalAlignment="Center" 
                   Margin="0,0,0,10"/>
        <TextBlock Text="{Binding EvenOddText}" 
                   FontSize="18" 
                   HorizontalAlignment="Center"/>
    </StackPanel>
</UserControl>

EvenOddView.xaml.csは以下のように修正します。

using System.Windows.Controls;
using WpfCounterApp.ViewModels;

namespace WpfCounterApp.Views
{
    /// <summary>
    /// EvenOddView.xaml の相互作用ロジック
    /// </summary>
    public partial class EvenOddView : UserControl
    {
        public EvenOddView()
        {
            InitializeComponent();
        }

        public void SetViewModel(EvenOddViewModel viewModel)
        {
            DataContext = viewModel;
        }
    }
}

これで、EvenOddViewModelに基づいて、EvenOddViewへ偶数/奇数の判定結果が表示されます。

手順3:CounterViewModel、CounterViewを作成

CounerViewModelについては、もともとのViewModelへ以下を追加します。

…
    public class CounterViewModel : INotifyPropertyChanged
    {
        …
        // ★コンストラクタで依存関係を受け取る
        public CounterViewModel(CounterModel model, JsonCounterStorage storage)
        {
            _model = model;
            _storage = storage;

            var initialValue = _storage.Load();
            _model.SetValue(initialValue); 

            // ★モデルの変更を購読
            _model.ValueChanged += OnCountChanged;

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

        ...

        private void ExecuteIncrement()
        {
            _model.Increment();
             // ★ 削除:OnPropertyChanged(nameof(Count));
            _storage.Save(_model.Value);
        }

        private void ExecuteDecrement()
        {
            _model.Decrement();
            // ★ 削除:OnPropertyChanged(nameof(Count));
            _storage.Save(_model.Value);
        }

        // ★Modelの変更通知を受け取る
        private void OnCountChanged()
        {
            OnPropertyChanged(nameof(Count));
        }

        …
    }
}

CounterViewをユーザコントロールとして作成します。

CounterView.xamlを以下のように修正します。内容的には、もとのアプリにおけるCounterWindowと同じものですね。

<UserControl x:Class="WpfCounterApp.Views.CounterView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfCounterApp.Views"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <StackPanel Margin="20">
        <TextBlock Text="Counter" 
                   FontWeight="Bold" 
                   HorizontalAlignment="Center" 
                   Margin="0,0,0,10"/>

        <TextBlock Text="{Binding Count}" 
                   FontSize="24" 
                   HorizontalAlignment="Center" 
                   Margin="0,0,0,15"/>

        <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
            <Button Content="-" 
                    Command="{Binding DecrementCommand}" 
                    Width="40" Height="30" 
                    Margin="5"/>
            <Button Content="+" 
                    Command="{Binding IncrementCommand}" 
                    Width="40" Height="30" 
                    Margin="5"/>
        </StackPanel>
    </StackPanel>
</UserControl>

CounterView.xaml.csは以下のように修正します。

using System.Windows.Controls;
using WpfCounterApp.ViewModels;

namespace WpfCounterApp.Views
{
    /// <summary>
    /// CounterView.xaml の相互作用ロジック
    /// </summary>
    public partial class CounterView : UserControl
    {
        public CounterView()
        {
            InitializeComponent();
        }

        public void SetViewModel(CounterViewModel viewModel)
        {
            DataContext = viewModel;
        }
    }
}

Modelとストレージを外部から受け取り、ValueChangedイベントを購読してViewに反映することで、状態変更を自動同期できるようにしています。

手順4:MainWindow.xamlを作成

まず、前回のCounterWindow.xamlMainWindow.xamlにリネームします。

ソリューションエクスプローラでCounterWindow.xamlを右クリックし、「名前の変更」で名前を「MainWindow.xaml」へ修正します。

MainWindows.xamlは以下のように修正します。

<Window x:Class="WpfCounterApp.Views.MainWindow"
        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"
        xmlns:views="clr-namespace:WpfCounterApp.Views"
        mc:Ignorable="d"
        Title="CounterWindow" Height="450" Width="800">
    <StackPanel Margin="20" VerticalAlignment="Center">
        <views:CounterView x:Name="CounterView" Margin="0,0,0,20"/>

        <Separator Margin="20,0" Background="LightGray" Height="1"/>

        <views:EvenOddView x:Name="EvenOddView" Margin="0,20,0,0"/>
    </StackPanel>
</Window>

2つのユーザコントロール(CounterViewEvenOddView)を同一ウィンドウ内に配置し、1つのModelを共有して動作させる構成にしています。

MainWindow.xaml.csは以下のように修正します。(DataContextの設定はここでは行わず、コンポジションルートでまとめて行います。)

using System.Windows;

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

手順5:App.xaml.csでコンポジションルートを実装

App.xaml.csを以下のように修正します。

using System.Windows;
using WpfCounterApp.Models;
using WpfCounterApp.Services;
using WpfCounterApp.ViewModels;
using WpfCounterApp.Views;

namespace WpfCounterApp
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {//ここが、コンポジションルート
            base.OnStartup(e);

            // サービスを作成
            var storage = new JsonCounterStorage();

            // Modelを作成
            var model = new CounterModel();

            // ViewModelを作成(依存性注入)
            var counterViewModel = new CounterViewModel(model, storage);
            var evenOddViewModel = new EvenOddViewModel(model);

            // MainWindowを作成し、ViewModelを設定(依存性注入)
            var mainWindow = new MainWindow();
            mainWindow.CounterView.SetViewModel(counterViewModel);
            mainWindow.EvenOddView.SetViewModel(evenOddViewModel);

            // ウィンドウを表示
            mainWindow.Show();
        }
    }
}

アプリ起動時に依存関係(Model・ViewModel・View)をまとめて生成・接続し、全体構成を一元管理する「コンポジションルート」を実装しています。

App.xamlからStartupUriを削除します。(mainWindow.Show()をApp.xaml.csで明示的に行っているため不要となります)

<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">
    <Application.Resources>
         
    </Application.Resources>
</Application>

アプリを実行

アプリを実行すると、以下のように動作します。

プロ美

きちんとカウンターの値に応じて偶数/奇数を判定してくれているね!コードも全体的に整理された気がする!

プロ太

今回、ちょっと長かったですが、無事にできましたね!

複数のViewやViewModelを活用しModelを共有するような、実践的なMVVMのコードができあがりました。

まとめ

本記事では、MVVMパターンにおける複数ViewModelでの単一Model共有について学びました。

MVVMパターンでこれを適切に実現する方法として、以下の重要な要素を習得しました。

  • Modelの変更通知:Modelにイベントを持たせ、値の変更を各ViewModelへ通知する仕組み
  • ユーザコントロール:機能ごとにViewを部品化する仕組み
  • コンポジションルート:App.xaml.csで部品の組み立てを行い、依存関係を一元管理することで、コードの見通しを良くする
  • 依存性注入:必要な部品を外側で作り、それをコンストラクタ等で渡すことで組み立てる

カウンターアプリの演習を通じて、2つのViewModelが1つのModelを共有し、それぞれが同期して動作する実装を体験しました。

次回は「データテンプレート」と「コンテンツコントロール」を学び、ViewとViewModelの紐付けを宣言的に、より簡潔に行う方法を習得します。

プロ太

引き続き、一緒にC# WPFアプリ開発を学んでいきましょう!


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

ご依頼・ご相談について

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