📥

[C#]WPFでのMVVMについてサンプルアプリからまとめ

2022/08/31に公開

WPF での MVVM

Model/View/ViewModel の話などありますが、きちんと理解するのに数年を要した概念なので改めて概念をまとめてみます。

この記事でまとめたいこと

  • Model/View/ViewModel の書き方
  • View と ViewModel の分離について
    • ダイアログの出し方

.NET Community Toolkit, Microsoft.Extensions.DependencyInjection, Microsoft.Xaml.Behaviors.Wpf の使用を前提とします。

作ったアプリのサンプルは

https://github.com/kzrnm/MvvmSample

に設置します。

作るアプリ

・Web APIから現在時刻を取得して、その結果を表示
・ダイアログで確認後、選択内容をクリップボードにコピーする

MVVM の実装

Model

MVVM における Model は「View に関わらないこと全般」です。
APIのアクセスだったり、OSとのやりとりだったり、データの保持だったりの全般を行います。

ここでは、Web から現在時刻を取得する WebTimeService を作成します。GetTimeAsync() はインターフェイス IWebTimeService としても呼べるようにしておきます。
なぜインターフェイスにするかは後述。

public interface IWebTimeService
{
    Task<WorldClock> GetTimeAsync();
}
public class WebTimeService : IWebTimeService
{
    private readonly HttpClient http;
    public WebTimeService(HttpClient http)
    {
        this.http = http;
    }
    public async Task<WorldClock> GetTimeAsync()
    {
        // {
        //     "$id": "1",
        //     "currentDateTime": "2021-12-13T15:30Z",
        //     "utcOffset": "00:00:00",
        //     "isDayLightSavingsTime": false,
        //     "dayOfTheWeek": "Monday",
        //     "timeZoneName": "UTC",
        //     "currentFileTime": 132838830284824910,
        //     "ordinalDate": "2021-347",
        //     "serviceResponse": null
        // }
        var url = "http://worldclockapi.com/api/json/utc/now";
        var response = await http.GetAsync(url).ConfigureAwait(false);
        using var stream = await response.Content.ReadAsStreamAsync();
        return await System.Text.Json.JsonSerializer.DeserializeAsync<WorldClock>(stream).ConfigureAwait(false);
    }
}

クリップボードについても同様。

public interface IClipboardService
{
    void SetText(string text);
}
public class ClipboardService : IClipboardService
{
    public void SetText(string text)
    {
        Clipboard.SetText(text);
    }
}

View model

View model は

  • View とのデータのやりとり
  • View とのコマンドのやりとり
    • API 呼び出しなどのロジックは Model に任せる

が役割です。

プロパティ・コマンド・イベントリスナーだけが並ぶクラスになるのが理想です。

View とデータのやりとりをするために INotifyPropertyChanged を実装する必要があります。

Community Toolkit では CommunityToolkit.Mvvm.ComponentModel.ObservableObject で実装済みですので、それを使います。

IWebTimeService, IClipboardService は Community Toolkit の DI(Dependency Injection) で解決することにします。

public partial class MainWindowViewModel : ObservableObject
{
    public MainWindowViewModel(IWebTimeService gitHubService, IClipboardService clipboardService)
    {
        WebTimeService = gitHubService;
        ClipboardService = clipboardService;
    }

    private IWebTimeService WebTimeService { get; }
    private IClipboardService ClipboardService { get; }

    [ObservableProperty]
    private WorldClock? _CurrentDateTime;
    // [ObservableProperty] がついていると ↓ のようなコードが CommunityToolkit によって自動生成される
    // public WorldClock? CurrentDateTime
    // {
    //    set
    //    {
    //         if (!System.Collections.Generic.EqualityComparer<DateTime>.Default.Equals(_CurrentDateTime, value))
    //         {
    //             _CurrentDateTime = value;
    //             PropertyChanged?.Invoke(new PropertyChangedEventArgs("CurrentDateTime"));
    //         }
    //    }
    //    get => _CurrentDateTime;
    // }

    [RelayCommand]
    private async Task GetDateTime()
    {
        CurrentDateTime = await WebTimeService.GetTimeAsync();
    }

    [RelayCommand]
    private void CopyCurrentDateTime()
    {
        var dialogResult = WeakReferenceMessenger.Default.Send(new DialogMessage
        {
            Caption = "クリップボードにコピー",
            Text = "クリップボードにコピーしますか?",
            MessageBoxButton = System.Windows.MessageBoxButton.YesNo,
            MessageBoxImage = System.Windows.MessageBoxImage.Question,
        });
        if (dialogResult.HasReceivedResponse
                && dialogResult.Response == System.Windows.MessageBoxResult.Yes
                && CurrentDateTime?.DateTime is { } time)
        {
            ClipboardService.SetText(time.ToString());
        }
    }
}

ViewModel で MessageBox.Show してしまうと、ViewとViewModelの分離ができなくなってしまいます。分離できるよう DialogMessage を Behavior に送りつけて表示します。
Behavior の実装は GitHub 参照。
https://github.com/kzrnm/MvvmSample/tree/HEAD/MvvmSample-Wpf/Behaviors

なぜViewとViewModelを分離する必要があるかは後述。

View

View は普通に xaml にレイアウトを書きます。

  • DataContext を ViewModel で初期化するためにコードビハインドが必要。ただし、添付ビヘイビアを使ってxaml側で書くことも可能です(後述)
  • ViewModel で DialogMessage を送っているので、これを受け取る DialogBehavior を設定しておく。
<Window x:Class="Kzrnm.MvvmSample.Wpf.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:Behaviors="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:localbehavior="clr-namespace:Kzrnm.MvvmSample.Wpf.Behaviors"
        xmlns:vm="clr-namespace:Kzrnm.MvvmSample.Wpf.ViewModels"
        d:DataContext="{d:DesignInstance vm:MainWindowViewModel}"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Behaviors:Interaction.Behaviors>
        <localbehavior:DialogBehavior />
    </Behaviors:Interaction.Behaviors>
    <StackPanel>
        <TextBlock Text="CurrentUserUrl"/>
        <StackPanel Orientation="Horizontal">
            <TextBlock Text="Timezone: "/>
            <TextBlock Text="{Binding CurrentDateTime.TimeZoneName}" />
        </StackPanel>
        <TextBox IsReadOnly="True" Text="{Binding CurrentDateTime.DateTime}" />
        <Button Content="API呼び出し" Command="{Binding GetDateTimeCommand, Mode=OneTime}" />
        <Button Content="クリップボードにコピー" Command="{Binding CopyCurrentDateTimeCommand, Mode=OneTime}" />
    </StackPanel>
</Window>
public MainWindow()
{
    InitializeComponent();
    DataContext = Ioc.Default.GetService<MainWindowViewModel>();
}

Application の初期化

Community Toolkit の DI を使っているので、Application の初期化時に DI の初期化をする必要があります。
使っている型を列挙するだけです。

public partial class App : Application
{
    public App()
    {
        Ioc.Default.ConfigureServices(
            new ServiceCollection()
            .AddSingleton<HttpClient>()
            .AddSingleton<IClipboardService, ClipboardService>()
            .AddSingleton<IWebTimeService, WebTimeService>()
            .AddTransient<MainWindowViewModel>()
            .BuildServiceProvider());
    }
}

ダイアログを開いているアプリ

非同期な初期化の場合

非同期な初期化だとコンストラクタではできません。

App.xamlStartupUri="Views/MainWindow.xaml" を削除して手動で MainWindow を起動してあげましょう。

public partial class App : Application
{
    protected override async void OnStartup(StartupEventArgs e)
    {
        var fooResult = await なんかのメソッド();
        Ioc.Default.ConfigureServices(
            new ServiceCollection()
            .AddSingleton(fooResult)
            .AddSingleton<HttpClient>()
            .AddSingleton<HttpClient>()
            .AddSingleton<IClipboardService, ClipboardService>()
            .AddSingleton<IWebTimeService, WebTimeService>()
            .AddTransient<MainWindowViewModel>()
            .BuildServiceProvider());
        new MainWindow().Show();
        base.OnStartup(e);
    }
}

追加検討(MVVM の利点/コードの改善)

「後述」としてきた内容について考えていきます。

Service をインターフェイス化する理由

MoqMock 型でモックサービスを入れて ViewModel のユニットテストが可能になります。
ユニットテストでAPI呼び出しなどするのは避けたいのでインターフェイスの方が都合が良いです。

[Fact]
public async Task GetDateTime()
{
    var webTimeServiceMock = new Mock<IWebTimeService>();
    var clipboardServiceMock = new Mock<IClipboardService>();

    webTimeServiceMock.Setup(s => s.GetTimeAsync())
        .ReturnsAsync(new Models.WorldClock
        {
            TimeZoneName = "UTC",
            CurrentFileTime = 133000000000000000,
        });

    var viewModel = new MainWindowViewModel(webTimeServiceMock.Object, clipboardServiceMock.Object);
    Assert.Null(viewModel.CurrentDateTime);
    await viewModel.GetDateTimeCommand.ExecuteAsync(null);
    Assert.NotNull(viewModel.CurrentDateTime);
    Assert.Equal(133000000000000000, viewModel.CurrentDateTime.CurrentFileTime);
}

ViewとViewModelの分離が必要な理由

こちらも ViewModel のユニットテストで重要です。
MessageBox.Show が存在すると UI での実行が絡んでくるのでユニットテストが出来ません。

WeakReferenceMessenger.Default.Register<DialogMessage>(recipient, (_, message) =>
            {
                message.Reply(System.Windows.MessageBoxResult.Yes);
            });

というように MessageBox.Show の代わりに固定値を返すようにすれば MessageBox の結果ごとのユニットテストが可能になります。

readonly object recipient = new object();
[Fact]
public void DialogYes()
{
    var webTimeServiceMock = new Mock<IWebTimeService>();
    var clipboardServiceMock = new Mock<IClipboardService>();

    lock (recipient)
    {
        try
        {
            WeakReferenceMessenger.Default.Register<DialogMessage>(recipient, (_, message) =>
            {
                message.Reply(System.Windows.MessageBoxResult.Yes);
            });
            var viewModel = new MainWindowViewModel(webTimeServiceMock.Object, clipboardServiceMock.Object);
            Assert.Null(viewModel.CurrentDateTime);

            viewModel.CopyCurrentDateTimeCommand.Execute(null);
            clipboardServiceMock.Verify(s => s.SetText(It.IsAny<string>()), Times.Never());


            var clock = new Models.WorldClock
            {
                TimeZoneName = "UTC",
                CurrentFileTime = 133000000000000000,
            };
            viewModel.CurrentDateTime = clock;

            viewModel.CopyCurrentDateTimeCommand.Execute(null);
            clipboardServiceMock.Verify(s => s.SetText(clock.DateTime.ToString()), Times.Once());
        }
        finally
        {
            WeakReferenceMessenger.Default.UnregisterAll(recipient);
        }
    }
}

[Fact]
public void DialogNo()
{
    var webTimeServiceMock = new Mock<IWebTimeService>();
    var clipboardServiceMock = new Mock<IClipboardService>();

    lock (recipient)
    {
        try
        {
            WeakReferenceMessenger.Default.Register<DialogMessage>(recipient, (_, message) =>
            {
                message.Reply(System.Windows.MessageBoxResult.No);
            });
            var viewModel = new MainWindowViewModel(webTimeServiceMock.Object, clipboardServiceMock.Object);
            Assert.Null(viewModel.CurrentDateTime);

            viewModel.CopyCurrentDateTimeCommand.Execute(null);
            clipboardServiceMock.Verify(s => s.SetText(It.IsAny<string>()), Times.Never());

            var clock = new Models.WorldClock
            {
                TimeZoneName = "UTC",
                CurrentFileTime = 133000000000000000,
            };
            viewModel.CurrentDateTime = clock;

            viewModel.CopyCurrentDateTimeCommand.Execute(null);
            clipboardServiceMock.Verify(s => s.SetText(It.IsAny<string>()), Times.Never());
        }
        finally
        {
            WeakReferenceMessenger.Default.UnregisterAll(recipient);
        }
    }
}

添付ビヘイビアで DataContext を初期化

添付ビヘイビアで自動初期化するとコードビハインド不要になります。

public class Ioc
{
    public static CommunityToolkit.Mvvm.DependencyInjection.Ioc DefaultIoc { set; get; } = CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default;
    public static Type GetAutoViewModel(DependencyObject obj) => (Type)obj.GetValue(AutoViewModelProperty);
    public static void SetAutoViewModel(DependencyObject obj, Type value) => obj.SetValue(AutoViewModelProperty, value);
    public static readonly DependencyProperty AutoViewModelProperty =
        DependencyProperty.RegisterAttached(
            "AutoViewModel",
            typeof(Type),
            typeof(Ioc),
            new FrameworkPropertyMetadata(null,
                FrameworkPropertyMetadataOptions.NotDataBindable,
                AutoViewModelChanged));

    private static void AutoViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (DesignerProperties.GetIsInDesignMode(d))
            return;
        if (d is FrameworkElement elm && e.NewValue is Type type)
            elm.DataContext = DefaultIoc.GetService(type);
    }
}
<Window x:Class="Kzrnm.MvvmSample.Wpf.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:Behaviors="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:localbehavior="clr-namespace:Kzrnm.MvvmSample.Wpf.Behaviors"
        xmlns:vm="clr-namespace:Kzrnm.MvvmSample.Wpf.ViewModels"
        d:DataContext="{d:DesignInstance vm:MainWindowViewModel}"
        localbehavior:Ioc.AutoViewModel="{x:Type vm:MainWindowViewModel}"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
public MainWindow()
{
    InitializeComponent();
}
GitHubで編集を提案

Discussion