MVVM さえあればいい。【#2 WPF MVVM L@bo】
前回から始まった WPF MVVM L@bo シリーズの 2 回目です。
前回はアプリを作りたいと思ったきっかけ的な前置きと、MahApps.Metro の小ネタ的な機能を紹介しました。
そして今回は 3 回目となる .NET Core WPF でコモンダイアログを表示する方法と MVVM パターンで実装する場合の重要なポイントと MVVM パターンを採用するメリットを紹介します。
尚、この記事は Visual Studio 2019 Community Edition で .NET Core 3.1 以上 と C# + Prism 7.2 以降 + ReactiveProperty + Livet + MahApps.Metro + Material Design In XAML Toolkit を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
目次
.NET Core でコモンダイアログを表示する
コモンダイアログのネタは WPF Prism episode シリーズから数えて 3 回目になりますが、書いてる内容は違うはず!と信じて先を進めます。
そしてこのエントリで紹介する内容は基本的に WPF Prism episode: 17 で紹介した内容が前提なので、できれば WPF Prism episode: 17 を先に読んで頂けるとありがたいです。
WPF Prism episode: 17 は Service クラスからコモンダイアログを表示する方法を紹介したので、今回も同じくコモンダイアログを表示するサービスを .NET Core ベースのクラスライブラリプロジェクトで作成します。
.NET Core のクラスライブラリプロジェクトに UI 系アセンブリの参照を追加する
.NET Framework のクラスライブラリプロジェクトから OpenFileDialog を表示するには、fig. 1 の参照マネージャーダイアログから PresentationFramework.dll を選択することで可能でした。
ですが、.NET Core プロジェクトから参照マネージャーを開くと fig. 2 のように【アセンブリ】タブが無くなっているのでチェックするだけで簡単に追加できなくなっています。
そのため、インストール済みの .NET Core アセンブリを参照に追加するには、右下の参照ボタンから fig. 3 のように『C:\Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\3.1.0(.NET Core のバージョン)』を開いて PresentationFramework.dll を選択するように変わったようです。
fig. 3 の方法でアセンブリ参照を追加すると通常通り実装も実行も特に問題なくできますが、公式の情報が見つからなかったので正直この方法が正しいかは分かりません。
もし不安な場合は fig. 2 ~ 3 のようにアセンブリ参照を追加するのではなく、新規プロジェクト追加時に fig. 4 の赤枠で囲んだ UI 系プロジェクトを作成するテンプレートを選択する方が良いと思います。
fig. 4 の赤枠で囲んだテンプレートであれば UI 系のアセンブリがデフォルトで自動参照に含まれるため手動で参照を追加する必要はありません。(このプロトタイプアプリは fig. 4 の方法で作成しました)
コモンダイアログ Service 用のプロジェクト構成
WPF Prism episode: 17 では全クラスを単一のサービス用プロジェクトへ押し込んで作成しましたが、今回は事情があって(後述します)複数プロジェクトへ分割して fig. 5(クリックで拡大)のような構成で作成しました。
このエントリを書くためにソースコードを見直してると「作っている時はコモンダイアログを表示するのに何個もプロジェクトを作りたくなかったから無理やり fig. 5 の構成にしたけど、構造がかなり歪だな…」とか「面倒がらずに fig. 6(クリックで拡大)のような構成で作った方がちゃんと責務が分割できたな…」等と少し後悔しています。
まあ、現状作成しているのはプロトタイプ兼実験用なのでリリース版作成時に fig. 6 の構成で作り直せば良いだけなのであまり気にせず先を進めます。
現状は fig. 5 のようにプロジェクトを分割していますが、内部実装自体は WPF Prism episode: 17 で紹介した内容とほとんど同じで Template、Factory パターンを組み合わせた構成(多分…)になっているはずなので、詳細は WPF Prism episode: 17 を見てください。
フォルダ参照ダイアログ(FolderBrowserDialog)
さて、コモンダイアログを表示するだけの Window Form では特にどうと言う事は無い内容のエントリを 3 度も書く羽目になった最大の原因であるフォルダ参照ダイアログ(FolderBrowserDialog)です。
WPF はリリース時から、現在の .NET Core 3.1(2019/12 現在)に至るまで FolderBrowserDialog を標準でサポートしていません。
.NET Core が 3.1 になっても、FolderBrowserDialog を使用する場合は System.Windows.Forms の参照を追加しないと表示できないと言う気持ち悪い状態が続いています。
今回、fig. 5 のようにプロジェクトを複数に分割したのは System.Windows.Forms を参照するプロジェクトを分けたかったと言うのが正に 1 番の理由で、fig. 7 の赤枠で囲んだプロジェクトのみが System.Windows.Forms を参照する構成にしたかったからです。
GitHub リポジトリ に上げているソースコードに System.Windows.Forms を参照するプロジェクトは含めていませんが、もし System.Windows.Forms を参照して実行した場合 fig. 8 のダイアログが表示されます。
Visual Studio でフォルダを選択する際に表示されるタイプのダイアログに変わっています。
Windows Form の FolderBrowserDialog が .NET Core では現在主流のフォルダ選択ダイアログに更新されたようです。
プロパティ等のインタフェースは互換性保持のため以前からは変わっていないので、RootDirectory や Description 等のプロパティを設定したらどうなるんだろう?的な違和感は若干あったものの今までと変わらず問題なく使用できると思います。
(System.Windows.Forms を参照して表示できる程度は確認しています)
.NET Framework では System.Windows.Forms を参照する以外にも方法がいくつかあったので、次項からはそれらの .NET Core への対応を紹介します。
Ookii.Dialogs の .NET Core 対応
まず最初は WPF Prism episode: 15 で紹介した Ookii.Dialogs。
WPF Prism episode: 15 で紹介した派生版パッケージ は現在メンテナンスが中止されているようなので、.NET Core 対応版のリリースは無いと考えた方が良さそうです。
そして、公式と思われる Ookii.Dialogs は Issue で若干の反応はあった ものの 2019 年 7 月以降から止まったままなので .NET Core 対応版のリリースは難しいと予想しています。
このような訳で .NET Core への対応は絶望的と思われる Ookii.Dialogs ですが、公式と思われる Ookii.Dialogs から Fork した Ookii.Dialogs .NET Core 対応版 がリリースされています。
この Ookii.Dialogs .NET Core 対応版 は Nuget からもインストール できますし、管理人も試しに入れてみましたが、問題なく使用できそうでした。
そのため、現時点(2019/12 現在)で .NET Core に対応済みの WPF ネイティブなフォルダ選択ダイアログは Ookii.Dialogs .NET Core 対応版 しか存在しないと言えますが、管理人がこのパッケージを試していて困った点が 1 つ出て来ました。
前回の WPF MVVM L@bo #1 で書いたように、この連載は管理人が必要としているアプリを作成する過程を紹介していて、アプリの要求仕様を GitHub リポジトリの ReadMe.md に書いていますが、この要求仕様 2 番目『複数のフォルダを選択して指定可能』を満たさない点です。
Ookii.Dialogs も System.Windows.Forms を参照して表示する FolderBrowserDialog も単一フォルダは選択できますが、複数フォルダを選択する機能は持っていません。
これでは要求仕様を満たさないので、他の選択肢を探してみました。
※ 複数フォルダの選択が必要ない場合は Ookii.Dialogs .NET Core 対応版 を使用すれば良いと思います。
複数フォルダが選択可能な FolderBrowserDialog
少し調べて分かったのは、複数フォルダが選択可能な FolderBrowserDialog は WPF Prism episode: 17 で紹介した Windows API Code Pack に含まれる CommonFileDialog しか見当たらないことです。
Windows API Code Pack で最も有名(ダウンロード数が多い)なのは aybe さんがリリースした Nuget パッケージ ですが、このリポジトリは 2016/2 のコミットを最後に更新されていませんし、Issue やプルリクへの反応も無いので .NET Core 版のリリースは絶望的と思われます。
但し、Windows API Code Pack は aybe さん以外にもいくつかの Nuget パッケージがリリースされているので、それらも調べてみました。
結論から言うと、Windows API Code Pack で .NET Core に対応したパッケージはリリースされていませんでした… が、要求仕様は変えたくありませんし、.NET Framework 版のパッケージでも .NET Core のソリューションで使うことはできる(物に依るようです)ので、とりあえずこのプロトタイプアプリとしては aybe さんの Windows API Code Pack を使用することにしました。
尚、『.NET Framework 版のパッケージでも .NET Core のソリューションで使うことはできる』については nuits.jp blog を主宰されている中村充志さんが SlideShare で公開されている『Visual Studio 2019で始める「WPF on .NET Core 3.0」開発』の中で解説(27 ページ辺りからです)されています。
そのため現時点で GitHub リポジトリ に上がっているソースコードは、fig. 7 の赤枠で囲んだプロジェクトが .NET Framework 版の Windows API Code Pack を参照したバージョンです。
WPF 公式 OSS
上記のような経緯で、このプロトタイプアプリでは Windows API Code Pack のフォルダ選択ダイアログを使用することにしましたが、Microsoft の 公式 WPF リポジトリの Issue で、近い将来 WPF へ FolderBrowserDialog が標準で含まれることになったようです。
とは言え、設定されているマイルストーン は Future(3.12 ~ .NET 5)の間なのでまだまだ先の話でしょうし、リリースされたとしても複数フォルダが選択可能になるかも不明なので何か代替手段は用意しておくべきだとは考えています。
現時点で最有力の代替手段としては『WPF でフォルダー選択のダイアログを選択・実装する – sh1’s diary』で紹介されている Windows ネイティブライブラリからコモンダイアログを表示する方法を丸パクリするのが早いかな…?と思っています。
以上で複数ファイル、複数フォルダを選択する準備ができたので、次章からは画面に入力した内容を MVVM パターンで処理する場合の重要なポイントを紹介します。
MVVM パターンで画面処理を構成する
MVVM パターンを採用する場合、管理人が最重要と考えているのは以下の 2 点です。
- ViewModel は極力薄く作る
- ReactiveProperty を採用する
2 番目の『ReactiveProperty を採用する』は必須ではありませんし、プロジェクトに依っては導入が難しい場合も多いと思いますが、ReactiveProperty を採用しない場合と比べると効果は半減(3 割減:管理人比)すると思うので、できるだけ導入することをお勧めします。
この連載は WPF Prism episode シリーズと同じく、ReactiveProperty を使用する前提で書いていきます。
ViewModel は極力薄く作る
【VM を極力薄く作る】事は MVVM パターンで作成する場合の最重要ポイントだと管理人は考えていますが、Windows Form での開発経験が多いと、つい忘れて VM へ処理を書いてしまうことも多いので、常に意識して設計・実装を進める必要があると思っています。
VM を薄く作ると相対的に厚くするのは Model 層(以降、MVVM パターンの Model を表す場合は英語の Model と表記します)なので、いわゆる Fat Model を作成する事になります。
そして WPF Prism episode シリーズでも何度か書いていますが、MVVM パターンでは View(XAML)、VM を除く全ては Model 層に分類されることも忘れてはいけない重要なポイントです。
極端な言い方をするなら、VM には Model と View の中継設定しか書いてはいけないと注意しながら実装(設計)すべきだと管理人的には考えています。(あくまでも『極端に言えば』です)
fig. 9 は前回のエントリ でも紹介した zip ファイル作成用の設定値を入力するための画面で、この画面を例に進めます。
src. 1 は fig. 9 の VM です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows.Controls; using HalationGhost.WinApps.ImaZip.AppSettings; using HalationGhost.WinApps.Services.CommonDialogs; using HalationGhost.WinApps.Services.CommonDialogs.DialogSettings; using Prism.Services.Dialogs; using Prism.Services.Dialogs.Extensions; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace HalationGhost.WinApps.ImaZip.ImageFileSettings { /// <summary>ソースファイルリストを追加するViewを表します。</summary> public class ZipFileListPanelViewModel : HalationGhostViewModelBase { /// <summary>zip ファイルを作成元のアーカイブファイルやフォルダを取得します。</summary> public ReadOnlyReactiveCollection<ImageSourceViewModel> ImageSources { get; } /// <summary>アーカイブファイル解凍先フォルダのパスを取得・設定します。</summary> public ReactivePropertySlim<string> ImageFilesExtractedFolderPath { get; set; } /// <summary>フォルダ名に使用する連番の桁数の選択肢を取得します。</summary> public ReadOnlyReactiveCollection<int> FolderNameSequenceDigits { get; } /// <summary>フォルダ名に使用する連番の桁数を取得・設定します。</summary> public ReactiveProperty<int?> FolderNameSequenceDigit { get; set; } /// <summary>フォルダ名のテンプレートを取得・設定します。</summary> public ReactiveProperty<string> FolderNameTemplate { get; set; } /// <summary>ファイル名のテンプレートを取得・設定します。</summary> public ReactiveProperty<string> FileNameTemplate { get; set; } /// <summary>ファイル・フォルダ参照ボタン用コマンドを表します。</summary> public ReactiveCommand<string> AddImageSource { get; } /// <summary>ファイル・フォルダ参照ボタンClick時のイベントハンドラ。</summary> /// <param name="param">コマンドパラメータを表す文字列。</param> private void onAddImageSource(string param) { var settings = this.createCommonDialogSettings(param); if (this.commonDialogService.ShowDialog(settings)) { switch (param) { case "Archives": var openFileSetting = settings as OpenFileDialogSettings; this.zipSettings.MergeToViewModels(openFileSetting.FileNames, ImageSourceType.File); break; case "Folders": break; } } } /// <summary>コモンダイアログ呼び出し設定情報を生成します。</summary> /// <param name="param">コマンドパラメータを表す文字列。</param> /// <returns>コモンダイアログ呼び出し設定情報を表すCommonDialogSettingBase。</returns> private CommonDialogSettingBase createCommonDialogSettings(string param) { switch (param) { case "Archives": return new OpenFileDialogSettings() { Filter = this.appSettings.SourceFileSelectedFilter, InitialDirectory = this.appSettings.SourceFileInitialDirectoryPath, Multiselect = true }; case "Folders": return new WinApiFolderPickerDialogSettings(); } return null; } ~ 略 ~ /// <summary> /// アプリケーション設定を表します。 /// </summary> private IImaZipCoreProto01Settings appSettings = null; /// <summary> /// コモンダイアログサービスを表します。 /// </summary> private ICommonDialogService commonDialogService = null; /// <summary> /// ダイアログサービスを表します。 /// </summary> private IDialogService dialogService = null; /// <summary> /// zipファイル作成設定情報を表します。 /// </summary> private ZipFileSettings zipSettings = new ZipFileSettings(); /// <summary> /// イメージソースListBoxのデータソースを表します。 /// </summary> private ObservableCollection<ImageSource> imgSrcList = new ObservableCollection<ImageSource>(); /// <summary>コンストラクタ。</summary> /// <param name="comDlgService">コモンダイアログサービスを表すICommonDialogService。</param> /// <param name="imaZipSettings">アプリケーション設定を表すIImaZipCoreProto01Settings。</param> public ZipFileListPanelViewModel(ICommonDialogService comDlgService, IImaZipCoreProto01Settings imaZipSettings, IDialogService dlgService) { this.commonDialogService = comDlgService; this.appSettings = imaZipSettings; this.dialogService = dlgService; this.ImageSources = this.zipSettings.ImageSources .ToReadOnlyReactiveCollection(i => new ImageSourceViewModel(i)) .AddTo(this.disposable); this.ImageFilesExtractedFolderPath = this.zipSettings.ImageFilesExtractedFolder .AddTo(this.disposable); ~ 略 ~ this.SelectExtractFolder = new ReactiveCommand() .WithSubscribe(() => this.onSelectExtractFolder()) .AddTo(this.disposable); this.CreateZip = this.zipSettings.IsComplete .ToAsyncReactiveCommand() .WithSubscribe(() => this.onCreateZip()) .AddTo(this.disposable); var folderSequences = new ObservableCollection<int>(Enumerable.Range(1, 4)); this.FolderNameSequenceDigits = folderSequences .ToReadOnlyReactiveCollection(i => i) .AddTo(this.disposable); this.FolderNameSequenceDigit = this.zipSettings.FolderNameSequenceDigit .ToReactiveProperty() .AddTo(this.disposable); this.FolderNameTemplate = this.zipSettings.FolderNameTemplate .ToReactiveProperty() .AddTo(this.disposable); this.FileNameTemplate = this.zipSettings.FileNameTemplate .ToReactiveProperty() .AddTo(this.disposable); } } } |
ReactiveProperty を使用してコンストラクタでエンティティ系モデル(以降、データ格納用のモデルはカタカナで『モデル』又は『エンティティ系モデル』と表記します)のプロパティと VM のプロパティを双方向でバインドしておくと、画面で入力した値が即座に Model 層まで反映されるので VM ⇔ Model でデータを出し入れするコードを書かずに済みます。
データの出し入れを書く必要が無くなると実装が楽になる事に加えて、本来は Model 層へ書くべき処理を VM に書いてしまうことを防ぐと言う効果もあると管理人的には思っています。
Model 層のプロパティを ReactivePropertySlim で定義する
src. 1 のようにエンティティ系のモデルと双方向バインドするには、エンティティ系モデルのプロパティも通知可能なプロパティ(INotifyPropertyChanged)で定義する必要があるので、このプロトタイプアプリでは src. 2 のようにエンティティ系モデルのプロパティを ReactivePropertySlim で定義しています。
(リスト系のプロパティは ObservableCollection で定義しています)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.IO; using System.Linq; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace HalationGhost.WinApps.ImaZip.ImageFileSettings { /// <summary>作成するZipファイルの設定情報を表します。</summary> public class ZipFileSettings : BindableModelBase { /// <summary>イメージファイルのソースファイルを取得します。</summary> public ObservableCollection<ImageSource> ImageSources { get; } = new ObservableCollection<ImageSource>(); /// <summary>イメージファイルの展開先フォルダのパスを取得・設定します。</summary> public ReactivePropertySlim<string> ImageFilesExtractedFolder { get; set; } /// <summary>設定情報の状態を取得します。</summary> public ReactivePropertySlim<bool> IsComplete { get; } /// <summary>フォルダ名の連番桁数を取得・設定します。</summary> public ReactivePropertySlim<int?> FolderNameSequenceDigit { get; set; } /// <summary>フォルダ名のテンプレートを取得・設定します。</summary> public ReactivePropertySlim<string> FolderNameTemplate { get; set; } /// <summary>ファイル名のテンプレートを取得・設定します。</summary> public ReactivePropertySlim<string> FileNameTemplate { get; set; } /// <summary>イメージソースのパスを重複を削除して追加します。</summary> /// <param name="sourcePaths">イメージソースに追加するパスを表すList<string>。</param> /// <param name="sourceType">追加するイメージソースの種類を表すImageSourceType列挙型の内の1つ。</param> public void MergeToViewModels(List<string> sourcePaths, ImageSourceType sourceType) { sourcePaths .Where(p => this.ImageSources.All(s => s.Path.Value != p)) .ToList() .ForEach(p => this.ImageSources.Add(new ImageSource(p, sourceType))); } ~ 略 ~ /// <summary>イメージソースのCollectionChangedイベントハンドラ。</summary> /// <param name="sender">イベントのソース。</param> /// <param name="e">イベントデータを格納しているNotifyCollectionChangedEventArgs。</param> private void ImageSources_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) => this.updateSettingState(); /// <summary>設定情報の状態を更新します。</summary> private void updateSettingState() { if (this.ImageSources.Count == 0) { this.settingComplete.Value = false; this.WorkRootFolderPath = string.Empty; } else { this.settingComplete.Value = 0 < this.ImageFilesExtractedFolder.Value.Length; this.WorkRootFolderPath = Path.Combine(this.ImageFilesExtractedFolder.Value, ZipFileSettings.WORK_ROOT_FOLDER_NAME); } } /// <summary>設定情報が完全かを表します。</summary> private ReactivePropertySlim<bool> settingComplete { get; set; } /// <summary>デフォルトコンストラクタ。</summary> public ZipFileSettings() { this.settingComplete = new ReactivePropertySlim<bool>(false) .AddTo(this.Disposable); this.ImageSources.CollectionChanged += this.ImageSources_CollectionChanged; this.ImageFilesExtractedFolder = new ReactivePropertySlim<string>(string.Empty) .AddTo(this.Disposable); this.ImageFilesExtractedFolder.Subscribe(_ => this.updateSettingState()); this.IsComplete = this.settingComplete .AddTo(this.Disposable); this.FolderNameSequenceDigit = new ReactivePropertySlim<int?>(2) .AddTo(this.Disposable); this.FolderNameTemplate = new ReactivePropertySlim<string>("?巻") .AddTo(this.Disposable); this.FileNameTemplate = new ReactivePropertySlim<string>("?_*") .AddTo(this.Disposable); } } } |
Slim 付きの ReactiveProperty(ReactivePropertySlim)は WPF Prism episode: 5 でも紹介したように、無印 ReactiveProperty からバリデーション(INotifyDataErrorInfo)とスケジューラ経由の起動を除去した軽量かつ高速動作するクラスなので、エンティティ系モデルのプロパティに使用するには最適だと思っています。
特に注目して欲しい実装は 2 か所あって 1 つ目は、src. 1 の 113 ~ 115 行目。
fig. 9 でイメージソースの Pathを表示している ListBox.ItemsSource とバインドしている、ImageSources プロパティ(VM 側・Model 側共に)を初期化している箇所です。
WPF Prism episode: 14 でも紹介していますが、ListBox とバインドしている VM の ReactiveCollection とエンティティ系モデル側の ObservableCollection を双方向でバインドしている箇所は、どの List 系コントロールを使用した場合でも応用が利くので覚えておいて損は無いと思っています。
詳細は WPF Prism episode: 14 を見てください。
注目して欲しい実装の残り 1 つはエンティティ系モデルの IsComplete プロパティの動作です。
IsComplete プロパティは fig. 9 の画面でファイル・フォルダを追加すると以下の順序で値が通知されます。
- fig. 9 の画面でファイル・フォルダを追加。
- VM からエンティティ系モデルの MergeToViewModels を呼び出す。
(Model 層のメソッドなのに ViewModels のような名前を付けてしまったのでリリース版では変更予定) - エンティティ系モデル内では自分自身の ImageSources プロパティへ重複しない項目を追加。
追加したタイミングで VM 側の ImageSources プロパティも同時に自動更新される。 - エンティティ系モデル内では ImageSources プロパティ(ObservableCollection)の CollectionChanged イベントが発火して、ImageSources_CollectionChanged も自動で呼び出される。
- ImageSources_CollectionChanged 内で IsComplete プロパティの値を更新する。
- IsComplete プロパティの変更が VM の CreateZip コマンド(ReactiveCommand)に通知され、fig. 9 右下 丸ボタンの Enabled が更新される。(CreateZip コマンドの CanExecute は IsComplete プロパティにバインドされている)
処理的にはエンティティ系モデルの MergeToViewModels を呼び出しているだけですが、上記全ての処理が順番に連鎖して最終的にボタンの IsEnabled プロパティまで通知されます。
イベントの連鎖自体は少し慣れれば良いだけなので注目して欲しい所ではなく、重要なのは IsComplete プロパティのような Windows Form ではコードビハインド(UI 層)に書くことが多かった VM の状態を管理するような値も Model 層で管理すると言う点です。
VM の状態は Model 層で管理する
Windows Form 等では if (0 < txtName.Text.Length) { }… のようにコントロールの入力値から処理実行ボタンを制御しているソースコードを見ることが多かったと思いますが、src. 1、2 のように ReactiveProperty で VM と Model 層を双方向でバインドすると、Model 層で View での入力状態を把握できるようになります。
オブジェクト指向設計的な観点では、ある処理が実行可能かを判別するには本来、エンティティ系モデルの状態で判別するように設計すると管理人的には思ってますが、一般的な業務系システム開発では画面(UI)から設計を始めることが多く、コントロールの入力値を判定してからコントロールの値をエンティティ系モデルに設定する画面ファーストと呼べるような設計手法が太古の昔から伝承されているため、Model の状態判別はコードビハインド(UI 層)に書くことが一般的になっているのだと思います。
そのため、本来は Model 層で管理すべき処理や値が UI 層へ染み出すことになり、今日のような UI 層の肥大化に繋がったと管理人は考えています。
MVVM パターンを採用してもコードビハインドと同じような感覚で VM を書くと VM を薄く作ることはできません。
せっかく MVVM パターンを採用するのであれば Model 層で管理すべき状態は Model 層に閉じ込めるように作成すべきだと思います。
MVVM パターンについて解説しているサイトやブログは多いですが、Hello World や簡単な計算機アプリではこのような状態管理まで説明することは難しく、管理人も実際にいくつか作成してみるまで気が付きませんでした。
他のサイトが間違っていると考えている訳ではなく、もう少し突っ込んだ例があった方が理解し易いだろうと思って書くことにしました。
MVVM パターンを採用するメリット
MVVM パターンを採用する利点として『UI をテストし易くなる』点を挙げている記事をよく見かけますが、管理人的にバインドしているプロパティのテストを書くと言う利点がイマイチ ピンと来なかったので、今までは敢えて書きませんでした。
MVVM パターンでいくつかアプリを作ってきて、実際にテストがし易くなるのは UI(VM)ではなく Model 層ではないかと考えるようになりました。
上に書いた通りデータの状態管理までエンティティ系モデルで管理すれば、状態管理まで含めたテストが Model 層だけで完結するはずです。
そして状態管理まで含んだ Model(複数のエンティティ系モデルになる場合もある)を VM のデータソースとすることで、VM は Model 層の情報を View へ中継するだけの役割しか持たなくなるため、シンプルで薄く作れることになると管理人的には考えています。
その結果、VM まで連動したテストもできると言う事であれば管理人的に納得できました。
VM のテストを書くことを否定している訳ではなく、それを利点と紹介するには MVVM パターンの採用は面倒すぎると感じていましたが、Model 層と UI 層を完全に分離できると考えれば、MVVM パターンを採用する理由には足ると思い直しました。
と、まあ、偉そうなことを書いていますが、管理人は MVVM マスターでもなければ WPF スペシャリストでもないので「ぼくのかんがえたさいきょうのえむぶいぶいえむ」程度に読んでもらえれば良いと思っています。
なので、異論・反論は受け付ける! と言うよりできればおかしな箇所はガンガン指摘して欲しいくらいですが、おそらく指摘等をしてもらえる事は無いだろうと諦めてもいます…w
さて、2020 年最初のエントリは以上で、ここで紹介したコードはいつものように GitHub リポジトリ (WPF Prism episode シリーズとはリポジトリが変わっています)に上げています。
相変わらずの拙い文章ですが 2020 年もよろしくお願い致します。
:: halation ghost:: 管理人 妖精作戦
次回記事「SQLite ですが?【#3 WPF MVVM L@bo】」