[C#]WPFでのMVVMについてサンプルアプリからまとめ
WPF での MVVM
Model/View/ViewModel の話などありますが、きちんと理解するのに数年を要した概念なので改めて概念をまとめてみます。
この記事でまとめたいこと
- Model/View/ViewModel の書き方
- View と ViewModel の分離について
- ダイアログの出し方
.NET Community Toolkit, Microsoft.Extensions.DependencyInjection, Microsoft.Xaml.Behaviors.Wpf の使用を前提とします。
作ったアプリのサンプルは
に設置します。
作るアプリ
・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 参照。
なぜ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.xaml
の StartupUri="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 をインターフェイス化する理由
Moq の Mock
型でモックサービスを入れて 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();
}
Discussion