WPF Prism extra: 5 ~ ReactvieProperty の変更通知を Subscribe して MVVM 的にデータを更新する ~
前回の episode: 17 は Prism から表示するダイアログを制御する IDialogAware とコモンダイアログを表示する方法を紹介しました。今回の extra シリーズは元々 episode: 17 内の一章でしたが、文字数の関係で分割しました。
2020/9/16 追記
このエントリを書いている頃より MVVM パターンへが多少理解できたため、現在似た内容の連載を公開中です。【連載】.NET Core WPF Prism MVVM 入門 2020 の step: 7『ReactiveProperty を編む』でこのエントリに近い範囲の内容を書いているので、良ければそちらもご覧ください。
尚、このエントリに書いている Model(ReactivePropertySlim)⇔ VM(ReactivePropertySlim)を双方向でバインドするコードは間違っています。正しく同期するためには ReactiveProperty Ver. 7.3.0 が必要なので、方法は上記の step: 7 を見てください。
通常のアプリケーションではある値の変化に連動して他の値も更新するような動作はよくあると思います。
今回の extra シリーズは ReactvieProperty の相互バインディングと Subscribe メソッドを利用してそのような連動更新を MVVM 的に行う方法を紹介します。
尚、この記事は Visual Studio 2019 Community Edition で .NET Framework 4.8 以上 と C# + Prism 7.2 + ReactiveProperty を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
ReactvieProperty を利用した画面項目の MVVM っぽい更新
episode: 17 でダイアログを表示するボタンは fig. 1のようなコードを入力すると名称を表示する、業務系システム等でもよく見かける UI にしました。
この UI での処理は本来 episode: 14 で紹介するつもりでしたが、すっかり忘れていたので改めて紹介します。
Window Form で fig. 1 のような UI を処理する場合、TextBox の Change イベントや Leave、Validating 等のイベントをコードビハインドへ記述して処理していたと思いますが、MVVM パターンで実装する場合は VM へ処理を書くのではなく、更新処理は Model 層に書き、更新された値をバインドするのが基本的な考え方になるので、ここではプロパティを ReactvieProperty で定義したモデルと VM が双方向でバインドしている場合の方法を紹介します。
VM と Model を双方向でバインドする
src. 1 は BLEACH の登場人物情報を表すための BleachCharacter クラスで、全プロパティを ReactvieProperty で定義した MVVM パターンではエンティティ系のモデルに分類されるクラスです。
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 | using System; using System.Reactive.Linq; using Prism.Mvvm; using Reactive.Bindings; namespace WpfPrism72 { /// <summary>BLEACHキャラクターを表します。</summary> public class BleachCharacter { /// <summary>BLEACHキャラクターコードを取得します。</summary> public ReactiveProperty<string> Code { get; } /// <summary>キャラクター名を取得・設定します。</summary> public ReactivePropertySlim<string> Name { get; set; } /// <summary>キャラクター名読み仮名を取得・設定します。</summary> public ReactivePropertySlim<string> Yomigana { get; set; } /// <summary>斬魄刀銘を取得・設定します。</summary> public ReactivePropertySlim<string> Zanpakuto { get; set; } /// <summary>卍解名を取得・設定します。</summary> public ReactivePropertySlim<string> Bankai { get; set; } /// <summary>コンストラクタ。</summary> /// <param name="code">BLEACHキャラクターコードを表す文字列。</param> /// <param name="name">キャラクター名を表す文字列。</param> /// <param name="yomi">キャラクター名読み仮名を表す文字列。</param> /// <param name="swordName">斬魄刀銘を表す文字列。</param> /// <param name="bankai">卍解名を表す文字列。</param> public BleachCharacter(string code, string name, string yomi, string swordName, string bankai) : this() { this.Code.Value = code; this.Name.Value = name; this.Yomigana.Value = yomi; this.Zanpakuto.Value = swordName; this.Bankai.Value = bankai; } /// <summary>デフォルトコンストラクタ。</summary> public BleachCharacter() { this.Name = new ReactivePropertySlim<string>(string.Empty); this.Code = new ReactiveProperty<string>(string.Empty); this.Yomigana = new ReactivePropertySlim<string>(string.Empty); this.Zanpakuto = new ReactivePropertySlim<string>(string.Empty); this.Bankai = new ReactivePropertySlim<string>(string.Empty); } } } |
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 | using System; using System.Reactive.Disposables; using System.Reactive.Linq; using Prism.Mvvm; using Prism.Services.Dialogs; using Prism.Services.Dialogs.Extensions; using Reactive.Bindings; using Reactive.Bindings.Extensions; using WpfPrism72.CommonDialogs; using WpfPrism72.Extensions; namespace WpfPrism72.ViewModels { /// <summary>ShellのViewModelを表します。</summary> public class MainWindowViewModel : BindableBase { /// <summary>キャラクターコードを取得・設定します。</summary> public ReactiveProperty<string> BlearchCharacterCode { get; set; } /// <summary>キャラクター名を取得します。</summary> public ReadOnlyReactiveProperty<string> BleachCharacterName { get; } ~ 略 ~ private BleachCharacter character { get; set; } private CompositeDisposable disposables = new CompositeDisposable(); /// <summary>コンストラクタ。</summary> /// <param name="dialogService">Prismのダイアログサービスを表すIDialogService。</param> public MainWindowViewModel(IDialogService dialogService, ICommonDialogService comDlgService) { ~ 略 ~ this.character = new BleachCharacter(); ~ 略 ~ this.BlearchCharacterCode = this.character.Code .AddTo(this.disposables); this.BleachCharacterName = this.character.Name .ToReadOnlyReactiveProperty() .AddTo(this.disposables); ~ 略 ~ } } } |
但し、Window Form での開発に慣れていた場合、VM へ処理を書いてしてしまう事も多いと思いますが、MVVM パターンでは VM 層を極力薄くするため、Model 層(BleachCharacter クラス等)で処理する設計にする事が重要だと思います。
ReactiveProperty.Subscribe で値変更時のイベントを購読する
一般的にバインドしたプロパティの値変更に対応した処理を記述するには INotifyPropertyChanged.PropertyChanged を利用することが多く、INotifyPropertyChanged を継承した ReactvieProperty で PropertyChanged イベントを処理したい場合は src. 3 のように Subscribe(購読)メソッドを使用します。
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 | using System; using System.Reactive.Linq; using Prism.Mvvm; using Reactive.Bindings; namespace WpfPrism72 { /// <summary>BLEACHキャラクターを表します。</summary> public class BleachCharacter : BindableBase { ~ 略 ~ /// <summary>キャラクターコードのChangeイベントハンドラ。</summary> /// <param name="code">キャラクターコードを表す文字列。</param> private void onChangeCode(string code) { if (string.IsNullOrEmpty(code)) { this.Name.Value = string.Empty; return; } var chara = new BleachAgent().GetCharacter(code); if (chara == null) this.Code.Value = string.Empty; else this.Name.Value = chara.Name.Value; } ~ 略 ~ /// <summary>デフォルトコンストラクタ。</summary> public BleachCharacter() { this.Name = new ReactivePropertySlim<string>(string.Empty); this.Code = new ReactiveProperty<string>(string.Empty); this.Code.Where(c => c.Length == 0 || c.Length == 3) .Subscribe(c => this.onChangeCode(c)); this.Yomigana = new ReactivePropertySlim<string>(string.Empty); this.Zanpakuto = new ReactivePropertySlim<string>(string.Empty); this.Bankai = new ReactivePropertySlim<string>(string.Empty); } } } |
Subscribe メソッドは src. 3 の 34-35 行目のように Delegate 先を指定するかラムダ式で処理を直接記述します。
又、ReactiveProperty の特徴として src. 3 の 34 行目のように Rx(Reactive Extension)を挟むことで入力値をフィルタリングしたり変換することが可能な点です。
src. 3 では値が空文字又は 3 文字の場合のみ後続の処理を実行するように指定しています。
src. 1、2 のように View ⇔ VM ⇔ Model がそれぞれ双方向でバインドされていると、View での入力が Model まで即座に伝播するので BleachCharacter.Code を Subscribe して同じモデルの BleachCharacter.Name を更新するだけで fig. 2 のように View へキャラクター名が表示されるようになります。(キャラクター名のクリアも同様)
ここで勘違いして欲しくないのですが、管理人が言いたいのは『MVVM パターンでデータを読み込みたい場合はモデルのプロパティを Subscribe すれば良い』と言うことではありません。
ここではサンプルとして作成した画面的に分かり易い例として BleachCharacter.Code プロパティを Subscribe する方法を紹介しましたが、実際のアプリケーションでは、DB から取得したデータを Dapper 等の ORM を使ってマッピングする場合も多く、Code プロパティの Subscribe でデータを取得するとデータの取得処理が複数個所に分散することになります。
又、src. 1、2 のような構造では Code プロパティの Subscribe 内で Dapper 等を使用することは難しく、データマッピング処理を複数書く羽目になってしまうので避けた方が良いと思います。
とは言え、src. 3 の方法が全く間違いと言う訳ではなく、Entity 系モデルのプロパティを Subscribe して他の値を更新するのはアプリケーションによってはアリだと思いますが、通常は Agent や Controller 系のアプリケーションロジック層へ処理を書く方が構造的に健全だと思います。
MVVM っぽいデータの連動更新
今まで連載記事内で何度か書きましたが、MVVM パターンでは View と ViewModel 以外は全て Model であり、一般的に Model は何層かに分割します。
つまり src. 4 のように Model の アプリケーションロジック層に分類される BleachAgent を Subscribe 内で呼び出す方法です。
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 | using System; using System.Reactive.Disposables; using System.Reactive.Linq; using Prism.Mvvm; using Prism.Services.Dialogs; using Prism.Services.Dialogs.Extensions; using Reactive.Bindings; using Reactive.Bindings.Extensions; using WpfPrism72.CommonDialogs; using WpfPrism72.Extensions; namespace WpfPrism72.ViewModels { /// <summary>ShellのViewModelを表します。</summary> public class MainWindowViewModel : BindableBase { /// <summary>キャラクターコードを取得・設定します。</summary> public ReactiveProperty<string> BlearchCharacterCode { get; set; } /// <summary>キャラクター名を取得します。</summary> public ReadOnlyReactiveProperty<string> BleachCharacterName { get; } ~ 略 ~ private BleachCharacter character { get; set; } /// <summary>BLEACHのキャラクターデータを取得します。</summary> private BleachAgent agent = new BleachAgent(); ~ 略 ~ /// <summary>コンストラクタ。</summary> /// <param name="dialogService">Prismのダイアログサービスを表すIDialogService。</param> public MainWindowViewModel(IDialogService dialogService, ICommonDialogService comDlgService) { ~ 略 ~ this.character = new BleachCharacter(); ~ 略 ~ this.BlearchCharacterCode = this.character.Code .AddTo(this.disposables); this.BlearchCharacterCode.Where(v => v.Length == 0 || v.Length == 3) .Subscribe(_ => this.agent.SetCharacterValues(this.character)); this.BleachCharacterName = this.character.Name .ToReadOnlyReactiveProperty() .AddTo(this.disposables); ~ 略 ~ } } } |
src. 3 で紹介した BleachCharacter.Code の Subscribe は削除して、37 ~ 38 行目のようにMainWindowViewModel の BlearchCharacterCode を Subscribe するように変更して、値の設定を BleachAgent へ委譲しても動作は fig. 2 と全く同じになります。
src. 4 は委譲した BleachAgent です。
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 | using System.Collections.Generic; namespace WpfPrism72 { /// <summary>BLEACHキャラクター用Agentクラスを表します。</summary> public class BleachAgent { /// <summary>BLEACHキャラクターの値を設定します。</summary> /// <param name="target">設定先のBleachCharacter。</param> public void SetCharacterValues(BleachCharacter target) { var bleachChara = this.GetCharacter(target.Code.Value); if (bleachChara == null) target.ClearValues(); else target.SetCharacter(bleachChara); } /// <summary>キャラクターコードからBLEACHキャラクターを取得します。</summary> /// <param name="characterCode">BLEACHキャラクターコードを表す文字列。</param> /// <returns>BleachCharacter。</returns> public BleachCharacter GetCharacter(string characterCode) => BleachAgent.characters.Find(c => c.Code.Value == characterCode); ~ 略 ~ } } |
この設計がベスト!だと言うつもりはありませんが、VM からコードを Model 層へ追い出すことで VM の肥大化を防ぐことができ、MVVM っぽい作りにすることができます。
このエントリで管理人が最も言いたいのは『MVVM パターンで VM はその名の通り View と Model の関係のみを記述する薄いレイヤーである』事と、View と Model の関係以外は Model 層に記述すべきと言う点です。
ここで紹介したサンプルコードは大して為になるサンプルではないと思いますが、こんな感じのサンプルはあまり見たことがありませんし、実際に MVVM パターンで何度が実装しないと実感しない部分だと思うので何かの参考になればと思い記事としてまとめてみました。
ここで紹介したサンプルコードはいつもの通り GitHub リポジトリ へアップしています。
このエントリで紹介したサンプルコードは元々 episode: 17 で作成していたので、フォルダが分かれていません。
episode: 17 用のソリューションを開いてください。
次回は .NET Core 関連のネタを書いている最中で、2 週間ほど先の公開を予定しています。
次回記事「Livet が Prism に「IDisposable 呼び出し用」としてゲッツされた件【episode: 18 WPF Prism】」