Prism に Model ⇔ VM の双方向バインドは難しい【step: 6 .NET Core WPF Prism MVVM 入門 2020】
前回は Prism で部分 View を Region へ表示する方法と破棄する方法を紹介しました。今回は Prism の MVVM サポートクラスを利用して WPF アプリを MVVM パターンで作成する場合の中心的な存在と言えるデータバインディングについて紹介します。
尚、この記事は Visual Studio 2019 Community Edition で .NET Core 3.1 以上 と C# + Prism + MahApps.Metro + Material Design In XAML Toolkit を使用して、WPF アプリケーションを MVVM パターンで作成するのが目的なので、C# の文法や基本的なコーディング知識を持っている人が対象です。
目次
XAML 側の Binding 設定
データバインディングを紹介する例として fig. 1 のような入力画面を作成します。
fig. 1 の画面は step: 4 で紹介した通り、『Blank App × 1、Prism Module × 1』の構成で作成していて、Module の部分 View(BindSamplePage.xaml)を Shell の Region に表示しています。
まず、src. 1 のように XAML 側の『名前 TextBox』へバインディングを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <UserControl x:Class="PrismSample.BindSamplePage" 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:prism="http://prismlibrary.com/" xmlns:md="http://materialdesigninxaml.net/winfx/xaml/themes" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300" prism:ViewModelLocator.AutoWireViewModel="True"> <Grid Margin="15, 10, 10, 0"> ~ 略 ~ <TextBox Grid.Row="1" Style="{StaticResource MaterialDesignFloatingHintTextBox}" md:HintAssist.Hint="名前" Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/> ~ 略 ~ </Grid> </Grid> </UserControl> |
『名前 TextBox』の Text プロパティと VM の Name プロパティをバインドするために【Binding マークアップ拡張】を指定します。
尚、TextBox 等の入力項目だけは項目ラベルを置くのが面倒なので Material Design In XAML Toolkit の FloatingHintTextBox を使用します。FloatingHintTextBox については WPF UI Gallery case: 1-3 で詳しく紹介しています。
Binding クラスには src. 1 で指定した以外のプロパティも多く定義されていますが、管理人の使用頻度が高いのは src. 1 でも指定している以下のプロパティです。
プロパティ名 | 内容 |
---|---|
Path | バインディング先(DataContext に設定される VM)のプロパティ名を指定します。 src. 1 では『Name プロパティ』を指定していて、デフォルトプロパティなので『Path=』は省略可能です。 |
Mode | 以下の BindingMode 列挙型の内の 1 つを設定します。
尚、TextBox 等は何も指定しないとデフォルト値が TwoWay なので、IsReadOnly 等で読み取り専用に設定してもバインド開始時は View ⇒ VM 間もバインドを設定します。そのため読み取り専用のコントロールは IsReadOnly を設定するだけでなくこのプロパティも OneWay 又は OneTime を設定すると少しパフォーマンスが良くなるようです。 |
UpdateSourceTrigger | 以下の UpdateSourceTrigger 列挙型の内の 1 つを設定します。
|
以上が XAML 側に必要な設定です。
XAML だけでバインドする
データバインディングには VM(DataContext)を介さず XAML だけでコントロール同士をバインドする方法もあります。例えば fig. 2 のように ToggleButton で『名前 TextBox』の IsEnabled が切り替わるような画面で可能す。
fig. 2 のような画面を作成する場合、XAML を src. 2 のように書けば 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 | <UserControl x:Class="PrismSample.PrismMvvm.BindSamplePage" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> <Grid Margin="15, 5, 15, 0"> ~ 略 ~ <Grid Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="0.75*"/> <ColumnDefinition Width="0.25*"/> </Grid.ColumnDefinitions> <TextBox Grid.Column="0" md:HintAssist.Hint="名前" Style="{StaticResource MaterialDesignFloatingHintTextBox}" IsEnabled="{Binding ElementName=tglNameEnabled, Path=IsChecked}" Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <StackPanel Grid.Column="1" Orientation="Horizontal" Margin="10, 0, 0, 0"> <TextBlock Text="使用可能" VerticalAlignment="Center"/> <ToggleButton Name="tglNameEnabled" Margin="10, 0, 0, 0" IsChecked="True"/> </StackPanel> </Grid> ~ 略 ~ </Grid> </Grid> </UserControl> |
Binding クラスのプロパティ一覧では紹介していませんが、14 行目のように【ElementName プロパティ】を指定すると XAML 上の同名コントロールにバインドできます。Path は値をバインドする ToggleButton のプロパティを指定します。
MVVM パターンで WPF アプリを作成する場合、通常はコードビハインドイベントハンドラを追加する機会はあまり無いのでコントロールの【Name プロパティ】を設定する必要はありません。(【x:Name プロパティ】も設定不要)ですが、XAML だけでバインドする場合はバインディングソースコントロール(ここでは ToggleButton)側だけは【Name プロパティ】が必要なので【Name プロパティ】も指定します。
このように XAML のみで UI 制御を行う事もできますが、基本的には UI だけで完結する単純な動作のみに限定して適用すべきだと思います。アプリケーションロジックに関係なくコントロールの IsEnabled や Visiblity 等を変更したい場合には VM へプロパティを追加しなくても手軽に利用できるので機会があれば試してみると良いと思います。
Prism の MVVM サポート
step: 2 で紹介した通り、WPF アプリを MVVM パターンで作成する場合、View ⇔ VM 間をデータバインディングでやり取りするため、WPF が標準で提供するのは INotifyPropertyChanged 等のインタフェースのみです。
step: 2 では INotifyPropertyChanged インタフェース等の実装クラスを紹介しましたが、同様のクラスは Prism にも当然含まれています。
BindableBase
Prism Template Pack から【Blank App】や【Prism Module】を選択した場合に生成される VM や、fig. 3 のように【新しい項目の追加ダイアログ】から追加した VM の継承元に設定される BindableBase は Prism の INotifyPropertyChanged 実装です。
src. 1 で Binding を追加した『名前 TextBox』とバインドする VM(BindSamplePageViewModel)には src. 3 のように Binding.Path に指定した『Name プロパティ』を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | using Prism.Mvvm; namespace PrismSample { /// <summary>Prismバインディングのサンプル部分Viewを表します。</summary> public class BindSamplePageViewModel : BindableBase { private string name = "管理人"; /// <summary>名前を取得・設定します。</summary> public string Name { get { return name; } set { SetProperty(ref name, value); } } /// <summary>コンストラクタ。</summary> public BindSamplePageViewModel() { } } } |
実行すると、fig. 4 のように VM で初期値に設定した『管理人』が 『名前 TextBox』に表示される事が確認できます。
このように XAML 側の Binding.Path に指定したプロパティを VM 側にも追加すると View ⇔ VM 間でデータを受け渡す事ができるようになります。VM に追加するプロパティは public で定義する必要があり、同じ public でもフィールドにはバインドできません。そして Prism の BindableBase で変更を通知するにはプロパティの Setter で BindableBase.SetProperty を呼び出す必要があります。
プロパティ用の Prism コードスニペット
Prism の BindableBase を使用して変更を通知する場合、通常使い慣れている自動プロパティの構文は使用できないので入力が非常に面倒だと思いますが、Prism Template Pack がインストール済みであればプロパティ入力用のコードスニペットも同時にインストールされるので、fig. 5 のように【propp コードスニペット】で入力負荷を多少軽減できます。
コードスニペットを忘れてしまった場合は Visual Studio の [ツール] – [コードスニペットマネージャー]から fig. 6 のコードスニペットマネージャーを表示して確認する事もできます。
Prism Template Pack から生成した VM が継承する BindableBase は INotifyPropertyChanged を継承していて以下のメンバが定義されています。
プロパティ名 | 内容 |
---|---|
SetProperty | 元の値から変更されている場合は RaisePropertyChanged を呼び出します。 |
RaisePropertyChanged | protected メンバなので継承先の VM からは呼び出せます。 INotifyPropertyChanged.PropertyChanged を呼び出します。 パラメータ:propertyName に null 又は string.Empty を指定して呼び出すと全プロパティの PropertyChanged が発火します。 |
上記の両メソッドとも最終的には INotifyPropertyChanged.PropertyChanged を呼び出しますが、SetProperty は元の値と違う場合のみ、変更の有無にかかわらず View を強制的に更新したい場合は RaisePropertyChanged を呼び出します。
子 VM
データバインディングを設定するには View に配置したコントロールの 1 プロパティに対して VM 側にもプロパティを 1 つ追加する必要があるので、例えば『名前 TextBox』の Text プロパティと IsEnabled プロパティをバインドしたい場合は VM へプロパティを 2 つ追加する必要があります。
そのような場合、src. 4 のようなコントロール用の 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 | using Prism.Mvvm; namespace PrismSample.PrismMvvm { /// <summary>TextBox用ViewModel</summary> public class TextBoxViewModel : BindableBase { private string text; /// <summary>Textプロパティを取得・設定します。</summary> public string Text { get { return text; } set { SetProperty(ref text, value); } } private bool enabled; /// <summary>IsEnabledプロパティを取得・設定します。</summary> public bool IsEnabled { get { return enabled; } set { SetProperty(ref enabled, value); } } /// <summary>コンストラクタ。</summary> /// <param name="initText">Textプロパティの初期値を表す文字列。</param> /// <param name="initEnabled">IsEnabledプロパティの初期値を表すbool。</param> public TextBoxViewModel(string initText, bool initEnabled) { this.text = initText; this.enabled = initEnabled; } } } |
src. 4 の VM を作成する場合でも fig. 1 の新しい項目の追加ダイアログから【Prism ViewModel】を選択すると BindableBase を継承した VM が追加されます。
そして src. 4 を使用するには View 用の VM へ src. 5 のように TextBoxViewModel 型のプロパティを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | using Prism.Commands; using Prism.Mvvm; using System; using System.Collections.Generic; using System.Linq; namespace PrismSample.PrismMvvm { public class BindSamplePageViewModel : BindableBase { private TextBoxViewModel nameTextBox; /// <summary>名前TextBoxを取得・設定します。</summary> public TextBoxViewModel NameTextBox { get { return nameTextBox; } private set { SetProperty(ref nameTextBox, value); } } public BindSamplePageViewModel() => this.nameTextBox = new TextBoxViewModel("管理人VM", false); } } |
src. 5 とバインドする XAML は src. 6 のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <UserControl x:Class="PrismSample.PrismMvvm.BindSamplePage" 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:prism="http://prismlibrary.com/" xmlns:md="http://materialdesigninxaml.net/winfx/xaml/themes" mc:Ignorable="d" d:DesignHeight="640" d:DesignWidth="640" prism:ViewModelLocator.AutoWireViewModel="True"> <Grid Margin="15, 5, 15, 0"> ~ 略 ~ <TextBox Grid.Row="1" md:HintAssist.Hint="名前" Style="{StaticResource MaterialDesignFloatingHintTextBox}" Text="{Binding NameTextBox.Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" IsEnabled="{Binding NameTextBox.IsEnabled, Mode=OneWay}"/> ~ 略 ~ </Grid> </Grid> </UserControl> |
WPF のデータバインディングはリフレクションと PropertyDescriptor でバインド先のプロパティを探すので src. 6 のようにクラスのプロパティも指定可能で、実行すると fig. 7 の画面が表示されます。
Text、IsEnabled の両方とも src. 6 で指定した TextBoxViewModel の初期値が反映されている事が確認できます。
但し、こんなバインディングもできるというだけで実際に TextBoxViewModel のような子 VM を作成する事はあまり無いと思います。管理人も有用性をイマイチ感じないので src. 3 のような VM を作成する事はありませんが、このような方法がある事も知っておくことは大切だと思います。
コレクションのバインディング
step: 2 でも紹介しましたが、INotifyPropertyChanged は単一値とバインドするためのインタフェースで、複数値(いわゆるコレクション)とバインドする場合は WPF に標準で含まれている ObservableCollection<T> を利用します。
インタフェースではなくクラスとして標準添付されているため Prism 独自のバインド用コレクションクラス等は用意されていません。そのため、コレクションのバインドは WPF 標準と同じく ObservableCollection<T> で定義しますが、ここでは使い方を紹介しません。
次項では ICommand を継承した DelegateCommand を紹介します。
DelegateCommand
fig. 4 と同じ画像ですが、流用して Prism の DelegateCommand を紹介します。
fig. 4 の検索ボタン Command を VM で受け取る場合、Prism には ICommand を継承した DelegateCommand が用意されているので src. 7 のように追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | using Prism.Commands; using Prism.Mvvm; namespace PrismSample.PrismMvvm { /// <summary>Prismデータバインディングのサンプル用ViewModelを表します。</summary> public class BindSamplePageViewModel : BindableBase { ~ 略 ~ /// <summary>検索ボタンClick Commandを取得します。</summary> public DelegateCommand SearchButtonClick { get; } /// <summary>検索ボタンClick Commandを処理します。</summary> void onSearchButtonClick() { } /// <summary>コンストラクタ。</summary> public BindSamplePageViewModel() { this.nameTextBox = new TextBoxViewModel("管理人VM", false); this.SearchButtonClick = new DelegateCommand(this.onSearchButtonClick, () => true); } } } |
src. 7 は『検索ボタン』の Command を受け取りますが、14 行目の onSearchButtonClick が未実装なので『検索ボタン』をクリックしても何も起こりません。
src. 7 21 行目の第 2 パラメータは ICommand.CanExecute を呼び出すのでラムダ式かデリゲート先のメソッドで bool を返せば Command のバインド先(ここでは検索ボタン)の IsEnabled に反映されます。このパラメータは省略可能なので、src. 7 のように『() => true』ラムダ式をわざわざ書く必要はありません。
src. 7 とバインドする XAML 側はプロパティバインディングと同じく src. 8 のように Binding マークアップ拡張を追加して Command のバインド先を指定します。
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 | <UserControl x:Class="PrismSample.PrismMvvm.BindSamplePage" 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:prism="http://prismlibrary.com/" xmlns:md="http://materialdesigninxaml.net/winfx/xaml/themes" mc:Ignorable="d" d:DesignHeight="640" d:DesignWidth="640" prism:ViewModelLocator.AutoWireViewModel="True"> <Grid Margin="15, 5, 15, 0"> ~ 略 ~ <StackPanel Grid.Row="0" Orientation="Horizontal"> <TextBox Width="100" HorizontalAlignment="Left" md:HintAssist.Hint="ID" Style="{StaticResource MaterialDesignFloatingHintTextBox}" /> <Button Content="検索" Margin="15, 5, 0, 5" Command="{Binding SearchButtonClick}"/> </StackPanel> ~ 略 ~ </Grid> </Grid> </UserControl> |
実行すると src. 7 の onSearchButtonClick メソッドが呼び出されるので、必要な処理を追加すれば Command を処理できます。イベントハンドラの追加と比べてコード量は多いですが、WPF アプリを MVVM パターンで作成する場合のイベントを処理する基本コードです。
DelegateCommand 用の Prism コードスニペット
DelegateCommand も入力量が多いので、Prism Template Pack には fig. 8 のような DelegateCommand 用のコードスニペットも用意されています。
DelegateCommand 用のコードスニペットは以下の 4 種類が用意されています。
- cmd
- cmdfull
- cmdg
- cmdgfull
DelegateCommand 用のコードスニペットは CanExecute のデリゲート先まで追加される『full 付』が 2 種類、パラメータを受け取るための『g 付』が 2 種類の計 4 種類で、fig. 8 で『g 付』は省いています。fig. 8 の入力例と src. 7 の DelegateCommand 定義を見比べてもらえれば分かると思いますが、管理人好みにするためコードスニペット入力後に手作業でかなり修正しています。
コードスニペットを無理に使う必要はありませんが、それなりに便利だと管理人的には感じています。
Prism の MVVM サポートまとめ
ひとまず、Prism が提供する MVVM サポートクラスを紹介しましたが、ここまでで紹介したのは View ⇔ VM 間のバインディングだけで Model は表れていません。MVVM パターンの紹介であれば本来 Model との連携も含めるべきだと思いますが、Model とのデータ I/O まで含めて紹介している情報はあまり見かけません。
Model との連携を含まないデータバインディングの説明しか読んでいない人が Model との連携を実装しようとした場合、おそらく今まで書き慣れてた方法を流用しつつ何となく実装している人も多いと予想しているので、次章では MVVM パターンを覚え始めた頃の管理人がよく書いていた実装を紹介します。
Prism の MVVM サポートクラスを利用した MVVM パターンのデータバインディング
本章では管理人が WPF Prism episode シリーズの episode: 5 ~ 7 辺りを書いていた頃によく実装していた Model とのデータ I/O 方式を紹介します。
尚、本章のコードサンプルは多階層アーキテクチャ的な構造分割は最低限理解している前提で書いているので、『知らないよ!』と言う人は WPF MVVM L@bo シリーズの #4 ~ 5 辺りを読んで頂くと良いと思います。
又、Model は step: 4 で紹介したデータ処理をほぼそのまま流用していて、Prism の DI コンテナ利用方法が分からない場合は先に step: 4 を読んで頂く方が良いと思います。
一般的に多いと思っている Model ⇔ ViewModel 間の連携
タイトルを『一般的…』としましたが、実際にどんな実装している人が多いのかは知りません。MVVM パターンを覚え始めの人が書きそうな実装だとは思っていますが、あくまでも管理人の独断と偏見です。Model へのデータ I/O 処理を紹介するために画面を fig. 9 のように変更しました。
fig. 3 の画面に保存ボタンを追加しただけですが、VM にデータの読込・保存処理を追加しています。『ID』には何を入力しても常に同じ Person オブジェクトを返しますが、『ID』が未入力の場合は単純にスルーしているだけなので何か入力してから『読込ボタン』をクリックしてください。サンプルなのでエラー処理等はほとんど考慮していません。
src. 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 | using System; using Prism.Commands; using Prism.Ioc; using Prism.Mvvm; namespace PrismSample.PrismMvvm { /// <summary>Prismデータバインディングのサンプル用ViewModelを表します。</summary> public class BindSamplePageViewModel : BindableBase { ~ 略 ~ /// <summary>保存ボタンのクリックコマンドを取得します。</summary> public DelegateCommand SaveButtonClick { get; } /// <summary>画面のデータを保存します。</summary> void onSaveButtonClick() { using (var agent = this.container.Resolve<IDataAgent>()) { agent.SavePerson(new Person() { Id = this.Id.Value, Name = this.Name, BirthDay = this.BirthDay.Value }); } } /// <summary>検索ボタンClick Commandを取得します。</summary> public DelegateCommand SearchButtonClick { get; } /// <summary>データを読み込みます。</summary> void onSearchButtonClick() { if (!this.Id.HasValue) return; using (var agent = this.container.Resolve<IDataAgent>()) { var person = agent.GetPerson(this.id.Value); this.Id = person.Id; this.Name = person.Name; this.BirthDay = person.BirthDay; } } private IContainerProvider container = null; private Person person = new Person(); /// <summary>コンストラクタ。</summary> /// <param name="containerProvider">DIコンテナからインジェクションするIContainerProvider。</param> public BindSamplePageViewModel(IContainerProvider containerProvider) { this.container = containerProvider; this.SearchButtonClick = new DelegateCommand(this.onSearchButtonClick, () => true); this.SaveButtonClick = new DelegateCommand(this.onSaveButtonClick); } } } |
Command の delegate 先では step: 4 で紹介した IContainerProvider をインジェクションした ServiceLocator パターンでアプリケーション層の IDataAgent を取得してデータの読み込みと保存処理をそれぞれ呼び出しています。
わざわざ説明する必要は無いと思いますが、取得処理ではアプリケーション層から返されたクラスの値を VM のプロパティに反映しています。その際は各プロパティ用のフィールドではなく VM のプロパティへ設定しないと View が更新されません。View への更新は各プロパティの Setter で呼び出されている SetProperty の呼び出しが必要な事は意識しておく必要があります。尚、保存の場合はフィールド・プロパティのどちらから取得しても構いません。
データの保存処理は何も実装していませんが、データアクセス層の PersonRepository へブレークポイントを設定すると fig. 10 のように View で入力したデータの受け取りが確認できます。
このように単純なデータ I/O であればデータ処理クラスを DI コンテナから取得している等の違いはあっても大きな流れは Windows Form 等とあまり変わらないと思います。値の設定先がコントロールから VM のプロパティになっただけと言う見方もできます。
この実装が間違っているとは言いませんが、この実装だと VM を作成するメリットはほとんど感じられずコードビハインドにイベントハンドラを書く方法と変わらない気がします。つまり VM の作成時間が余分にかかるだけと思えるので Model の設計を見直す必要があると思いました。
Model のインタフェース
MVVM パターンの Model をどのように作成すべきかと言うような情報は実際、検索しても Model まで突っ込んで紹介しているサンプルはあまり見当たりませんが、Model のインタフェースについて Livet 作者の尾上さんが自身のブログ『MVVMのModelにまつわる誤解 – the sea of fertility』で書かれています。その内容を少し長めに引用します。
ViewModel に公開する Model のインタフェースは以下の二つしかありません。
- Model のステートの公開とその変更通知
- Model の操作のための戻り値のないメソッド
ステートの公開とその変更通知を行うのは簡単な話でしょう。リッチクライアントの Model はステートフルです。そのステートを公開しないと ViewModel と View は表示すべき情報がありません。そしてその変化を ViewModel と Viewに伝えるために変更通知を行うのも当然の事です。
Model の操作のためのメソッドには戻り値がない・・これにはひっかかる方も多いかもしれません。しかしこれも難しい話ではありません。
Model のメソッド呼び出しは何をもたらすのでしょうか?。それは Model 内状態の変化(あるいは外部サービス呼び出しとそれに伴う Model 内状態の変化)と、なんらかのイベント発生(通信エラー発生とか)しかないのです。ViewModel が Model の影であれば当然それしかないのです。ViewModel が Model を呼び出して Model から戻り値を受け取って何になるんでしょう?それは Model 内のステートの不完全な意味のないコピーでしかありません。
ViewModel に対する Model のインターフェースこれを初めて読んだ時の管理人にはあまり意味が分かりませんでした(今でも理解できているかは怪しいかもしれません…)が、注目すべきは【Model を操作するための戻り値のないメソッド】だと思いました。
src. 9 のような実装に慣れていた管理人は『保存処理は VM からデータを渡して戻り値無しは分かるけど、GetPerson のような名前のメソッド作って戻り値が void とか意味分からん』でしたが、データバインディングで『VM を更新すれば View も更新される』なら『エンティティ系モデルに変更通知を付ければ Model から VM が更新できる』的な情報をどこかで見たことがあるのを思い出しました。
この辺りの実装についてはサンプル的な実装もあまり見かけませんし、上で引用した尾上さんの文章から思い付いたので的外れな可能性もあります。間違っているなら指摘して欲しいと思っていますが、おそらく指摘等は来ないはずなので何か気付いた事でもあればコメントでももらえると有り難いです。
と言う訳で次章から Model ⇔ VM 間も双方向でバインドする方法を紹介します。
Model ⇔ ViewModel 間を双方向でバインドする
Model ⇔ VM 間を双方向でバインドするためにまず、Model に変更通知機能を組み込みます。変更通知には INotifyPropertyChanged の実装が必要なので独自で作成しても良いと思いますが、せっかく Prism で作成しているので上で紹介した BindableBase を使います。
エンティティ系モデルは独立したプロジェクトにまとめているので、エンティティ系モデル用のプロジェクトにも Prism のインストールが必要ですが、BindableBase を使うだけなら Shell や Module が使う Prism.Wpf パッケージではなく Prism.Wpf パッケージにも含まれている Prism.Core パッケージだけインストールすれば OK です。
いつものように fig. 11 の『ソリューションの Nuget パッケージの管理』から【Prism.Core】をエンティティ系モデル用のプロジェクトへインストールします。
インストールが完了するとエンティティ系モデル用のプロジェクトで BindableBase が使えるようになるので、変更通知機能を備えた Person クラスを作成します。
変更通知機能を備えた Person クラス
Person クラスは一般的に読み書き可能なプロパティを備えただけの単純なクラスだったのでエントリ内でソースコードを紹介していませんでしたが、これまで出て来たプロパティに加えて『誕生日から年齢を算出する読み取り専用プロパティ』を追加した変更通知可能な Person クラスが src. 10 です。
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 | using System; using Prism.Mvvm; namespace PrismSample { /// <summary>エンティティ系モデルクラス。</summary> public class Person : BindableBase { private int? id; public int? Id { get { return id; } set { SetProperty(ref id, value); } } private string name; public string Name { get { return name; } set { SetProperty(ref name, value); } } private DateTime? birthday; public DateTime? BirthDay { get { return birthday; } set { SetProperty(ref birthday, value); if (value.HasValue) this.Age = DateTime.Now.Year - value.Value.Year; } } private int age = 0; public int Age { get { return age; } private set { SetProperty(ref age, value); } } public string Kana { get; set; } = string.Empty; /// <summary>性別(男:1、女:2)を取得・設定します。</summary> public int? Sex { get; set; } = null; } } |
src. 2 で紹介した VM のプロパティと変わりませんが、読み取り専用の Age プロパティは src. 10 の通り Setter を private にして BirthDay 変更時に値を更新しています。
Model と双方向でバインドする ViewModel
次は Model と双方向でバインドする VM ですが、正直な所今まで紹介してきたインタフェース類だけでは実現不可能です。BindableBase の継承元になる INotifyPropertyChanged は変更の通知はできますが受け取ることはできないからです。詳しくは調べていないので実現方法に当たりを付けている訳ではありませんが、DependencyObject や Binding クラス等を駆使すればできそうな気もします。
ただ、src. 11 のように Model を疑似的なフィールドと見なせば一応できない事は無さそうです。とりあえず新しく追加した『現在年齢を返す Age 読み取り専用プロパティ』と『BirthDay プロパティ』の 2 つのみ Model と双方向でバインドしてみました。
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 | using System.Windows; using Prism.Commands; using Prism.Mvvm; using Prism.Unity; using Prism.Ioc; using System; namespace PrismSample.PrismMvvm { /// <summary>Prismデータバインディングのサンプル用ViewModelを表します。</summary> public class BindSamplePageViewModel : BindableBase { ~ 略 ~ /// <summary>誕生日を取得・設定します。</summary> public DateTime? BirthDay { get { return this.person.BirthDay; } set { this.person.BirthDay = value; this.RaisePropertyChanged(nameof(this.Age)); this.RaisePropertyChanged(nameof(BirthDay)); } } /// <summary>現在年齢を取得します。</summary> public int Age => this.person.Age; ~ 略 ~ private Person person = null; /// <summary>コンストラクタ。</summary> /// <param name="initPerson">インジェクションするPersonの初期値。</param> public BindSamplePageViewModel(Person initPerson) { this.person = initPerson; ~ 略 ~ } } } |
今まで VM の変更通知プロパティはフィールドを読み書きするように作成していましたが、双方向でバインドするために追加した Person 型のフィールドを読み書きするよう変更しています。プロパティの Setter で元々呼び出していた SetProperty は第 1 パラメータを ref 付で渡す必要があるためクラスのプロパティは指定できません。そのため VM ⇒ View への変更通知を発行するために RaisePropertyChanged を呼び出すという苦しい実装です。
そして、今回新規で追加した『現在年齢を返す Age 読み取り専用プロパティ』ですが、src. 11 のように Person.Age を返すように作成する事で、VM に年齢計算を追加しなくても済むため Model の処理が VM ににじみ出る事を防ぎます。但し、VM が Person クラスの変更通知を受け取れないので誕生日の変更に連動して Age プロパティの変更通知も発行しています。
又、新しく追加した Person 型のフィールドに設定するインスタンスは、コンストラクタにパラメータを追加して DI コンテナからインジェクションしてもらうようにしています。
そのためエンティティ系モデル用プロジェクトへの参照をスタートアッププロジェクトに追加して src. 12 のように App.xaml.cs へ DI コンテナへの登録処理を追加しています。
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 | using System; using System.Reflection; using System.Windows; using Prism.Ioc; using Prism.Mvvm; namespace PrismSample { /// <summary> /// Interaction logic for App.xaml /// </summary> public partial class App { ~ 略 ~ /// <summary>DIコンテナへ型を登録します。</summary> /// <param name="containerRegistry">登録専用のDIコンテナを表すIContainerRegistry。</param> protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.Register<IPersonRepository, PersonRepository>(); containerRegistry.Register<IDataAgent, DataAgent>(); containerRegistry.Register<Person>(); containerRegistry.RegisterInstance(this.Container); } } } |
この辺りの DI コンテナの使用法と IContainerProvider をインジェクションする方法については step: 4 で詳しく紹介しています。
src. 11 のように実装すれば Model ⇔ VM 間を疑似的に双方向バインディングできそうなので、読込・保存処理まで加えた VM が src. 13 です。
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 | using System; using Prism.Commands; using Prism.Ioc; using Prism.Mvvm; namespace PrismSample.PrismMvvm { /// <summary>Prismデータバインディングのサンプル用ViewModelを表します。</summary> public class BindSamplePageViewModel : BindableBase { /// <summary>IDを取得・設定します。</summary> public int? Id { get { return this.person.Id; } set { this.person.Id = value; this.RaisePropertyChanged(nameof(Id)); } } /// <summary>名前を取得・設定します。</summary> public string Name { get { return this.person.Name; } set { this.person.Name = value; this.RaisePropertyChanged(nameof(Name)); } } /// <summary>誕生日を取得・設定します。</summary> public DateTime? BirthDay { get { return this.person.BirthDay; } set { this.person.BirthDay = value; this.RaisePropertyChanged(nameof(this.Age)); this.RaisePropertyChanged(nameof(BirthDay)); } } /// <summary>現在年齢を取得します。</summary> public int Age => this.person.Age; /// <summary>保存ボタンのクリックコマンドを取得します。</summary> public DelegateCommand SaveButtonClick { get; } /// <summary>画面のデータを保存します。</summary> void onSaveButtonClick() { using (var agent = this.container.Resolve<IDataAgent>()) { agent.SavePerson(this.person); } } /// <summary>検索ボタンClick Commandを取得します。</summary> public DelegateCommand SearchButtonClick { get; } /// <summary>データを読み込みます。</summary> void onSearchButtonClick() { if (!this.Id.HasValue) return; using (var agent = this.container.Resolve<IDataAgent>()) { agent.UpdatePerson(this.Id.Value, this.person); this.RaisePropertyChanged(null); } } private IContainerProvider container = null; private Person person = null; /// <summary>コンストラクタ。</summary> /// <param name="initPerson">インジェクションするPersonの初期値。</param> /// <param name="containerProvider">DIコンテナからインジェクションするIContainerProvider。</param> public BindSamplePageViewModel(Person initPerson, IContainerProvider containerProvider) { this.container = containerProvider; this.person = initPerson; this.SearchButtonClick = new DelegateCommand(this.onSearchButtonClick, () => true); this.SaveButtonClick = new DelegateCommand(this.onSaveButtonClick); } } } |
個々のプロパティは src. 11 と同じ実装ですが、データ読込処理の onSearchButtonClick で呼び出すメソッドを UpdatePerson に変更してフィールドの Person を渡すようにしています。
変更した UpdatePerson の中身は src. 14 のように実装しています。
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 | using System; namespace PrismSample { /// <summary>データを操作します。</summary> public class DataAgent : IDataAgent { ~ 略 ~ /// <summary>Personを更新します。</summary> /// <param name="id">取得するPersonのIDを表すint。</param> /// <param name="person">更新するPerson。</param> public void UpdatePerson(int id, Person person) { var temp = this.personRepository.GetPerson(id); person.Id = temp.Id; person.Name = temp.Name; person.BirthDay = temp.BirthDay; } /// <summary>Personを保存します。</summary> /// <param name="person">保存するPerson。</param> public void SavePerson(Person person) => this.personRepository.SavePerson(person); private IPersonRepository personRepository = null; /// <summary>コンストラクタ。</summary> /// <param name="personRepo"></param> public DataAgent(IPersonRepository personRepo) => this.personRepository = personRepo; ~ 略 ~ } } |
src. 9 では VM に実装していたプロパティへの代入処理を Model に移動しただけですが、Model 内で値を設定することで、Model ⇒ VM ⇒ View の順に変更通知が伝わり最終的に画面の表示まで更新されます。Model のインタフェースを src. 14 のように定義することが上で引用した尾上さんの【Model のインタフェース】に一致すると考えています。
但し、Model 内でエンティティ系モデルへ値をセットしても VM への変更通知は発行されないため、src. 13 74 行目のように BindableBase.RaisePropertyChanged のパラメータに『null』をセットして全プロパティの変更通知を強制的に発行しないと View が更新されないので注意が必要です。
このように取得処理も Model 内部に閉じた状態にする事で【UI 層とアプリケーションロジックの分離】にも繋がり、管理人が MVVM パターンのメリットと考える【UI に依存しない Model(アプリケーションロジック)を作成する事で Model 部だけでユニットテストが実行できる】事にも繋がると考えています。
この実装でサンプルを実行すると fig. 12 のように動作します。
『誕生日プロパティ』の表示に使用している DatePicker は UpdateSourceTrigger の設定に関わらず LostFocus 時しか VM が更新されないため、年齢の更新確認にはフォーカスの移動が必要です。
今回のまとめ的な
fig. 12 は一応想定通りに動作しているとは思いますが、やはり src. 13 の実装は微妙な気がしますし、画面を更新したいタイミングで RaisePropertyChanged を呼び出す必要があるのもミスの元になりそうですし、何より面倒です。
やはり最大の問題は最初に書いた通り VM で変更通知を受け取れない点なので、Model ⇔ VM 間の双方向バインディングは Prism だけで実現する事は難しいと言えます。ですが、現時点では Model ⇔ VM 間の双方向バインディングまでサポートする高機能な MVVM データバインディング用拡張ライブラリの ReactiveProperty があるので、データバインディングの部分だけは ReactiveProperty に置き換える事にします。
こんなに長い記事読んで『結局出来ないって結論は時間の無駄』と思われる人も居ると思いますが、管理人は Prism 以外のパッケージを使用する理由は必要だと思っています。『ReactiveProperty は優れたライブラリだからインストールします。』的な記事をよく見かけますが『何故 Prism だけじゃダメなの?』への回答が無いと新規ライブラリに手を出す意欲が出て来ないと思っています。
Prism は step: 2 でも紹介した通り複合アプリケーション作成フレームワークとしてスタートしたため、疎結合に作成した各部品を実行時に動的結合するアプリの作成を目的に開発が進められているように感じます。
そのため MVVM 関連の機能はフレームワークと一体と言う訳ではなくデータバインディングに必要なインタフェースの実装が含まれていると言う程度です。Prism の MVVM のサポートは悪く言えば貧弱、良く言えばシンプルなので MVVM の部分を他のライブラリに入れ替えても Prism 本体への影響は特に気にせず使用できると言う利点もあります。
実際、データバインディングの部分だけで言えば、ReactiveProperty 以外にも選択肢はあるでしょうし、既に自社(自プロジェクト)内でカスタマイズしたデータバインディングライブラリを持っている場合もあるので、別のライブラリを使用しても問題ない構造になっていると思います。
ReactiveProperty を使って Model ⇔ VM 間を双方向バインディングする方法は次回紹介します。そのため、本エントリで紹介した Prism の SetProperty や DelegateCommand は以降この連載で出て来る予定はありません。
尚、今回紹介したサンプルコードはいつものように GitHub リポジトリ に上げています。
次回記事「ReactiveProperty を編む【step: 7 .NET Core WPF MVVM ReactiveProperty 入門 2020】」
10年ぶりにWPF+MVVMに再挑戦しています。
当時はNuGetなんて全く知らず、Binding用のプロパティ・イベント類も全て独自実装(コピペ)してましたが、再学習にあたってライブラリを調査していたところ、「Prism使うならReactivePropertyも入れなきゃダメ」という記事をどこかでみかけ、正直2つも同時にライブラリを学習するのは面倒に感じていました。
そんななか、本記事ではPrismのみで実装することの難しさが実例とともに解説されており、ReactivePropertyの必要性、まさしく『何故 Prism だけじゃダメなの?』への回答を得ることができ、非常にすっきりしました。
P.S. 一部箇所でTextBoxをTexBoxとtypoしているようです。細かい部分ですが、頭の中でコードを咀嚼する際にどうしても気になってしまうので、お手すきでしたら修正いただけるとこれ以降の読者の読みやすさにもつながるかなと思います。
MaySoMusician さん>
返信遅くなってすいません。
> ReactivePropertyの必要性、まさしく『何故 Prism だけじゃダメなの?』への回答を得る
そう言っていただけると非常に心強くありがたいです!
管理人もMaySoMusician さんと同じように感じていたのでこんなエントリにしてみました。
このエントリは長い割に結果的には「これじゃダメ」と言う内容なので読んだ人がどう感じるか正直不安でしたが役に立ったのなら良かったです。
後、「TexBox」のtypo直しておきました。
指摘ありがとうございました。 #予測変換を盲目的に信じちゃダメですねw