それもそのはず、ViewModelはViewの実装上の制約を吸収する層なので、画面の状態(表示内容など)自体はModelもプロパティとして持っています。そうでなければMVVMではありません。詳しくは以前書いた記事を読んでください。
MVVMとは何かですが、その通りに実装すると同じ名前のプロパティがViewModelとModelで大量に出てきてしまうという問題もありました。
MVVMのサンプルプログラム - TwitterViewerこちらのページで紹介しているサンプルプログラムは基本的にはTwitterからツイートを持ってきて画面に表示するだけですので、ModelのプロパティもGet-Onlyのプロパティばかりです。ですが、もっと動的に内容が動くような状況の場合は、もっとしっかりViewModelにModelの変更通知を受けてプロパティをコピーするコードや、逆にViewの操作で変化したプロパティをModelにコピーするコードを書かなければなりません。
問題提起
サンプルプログラム
例えば、指定したURLのHTMLを取得するアプリケーションを作るとします。このアプリケーションはURLを入力しGoボタンを押すとそのサイトのHTMLを取得して画面に表示するシンプルなものです。これをMVVMで作ると次のようなコードになります。
【View】
<Window x:Class="Frontend.Views.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions" xmlns:l="http://schemas.livet-mvvm.net/2011/wpf" xmlns:v="clr-namespace:Frontend.Views" xmlns:vm="clr-namespace:Frontend.ViewModels" Title="MainWindow" Height="350" Width="525"> <Window.DataContext> <vm:MainWindowViewModel/> </Window.DataContext> <i:Interaction.Triggers> <!--WindowのContentRenderedイベントのタイミングでViewModelのInitializeメソッドが呼ばれます--> <i:EventTrigger EventName="ContentRendered"> <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/> </i:EventTrigger> <!--Windowが閉じたタイミングでViewModelのDisposeメソッドが呼ばれます--> <i:EventTrigger EventName="Closed"> <l:DataContextDisposeAction/> </i:EventTrigger> <!--WindowのCloseキャンセル処理に対応する場合は、WindowCloseCancelBehaviorの使用を検討してください--> </i:Interaction.Triggers> <Grid> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="1*" /> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="1*" /> <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <TextBlock Grid.Row="0" Grid.Column="0" Text="URL: " VerticalAlignment="Center" /> <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}" /> <Button Grid.Row="0" Grid.Column="2" Content="Go" Width="50" Command="{Binding GoCommand}" /> <TextBox Grid.Row="1" Grid.ColumnSpan="3" IsReadOnly="True" Text="{Binding Html}" ScrollViewer.HorizontalScrollBarVisibility="Visible" ScrollViewer.VerticalScrollBarVisibility="Visible" /> </Grid> </Grid> </Window>
【ViewModel】
public class MainWindowViewModel : ViewModel { Model model; public void Initialize() { model = Model.GetInstance(); GoCommand = new ViewModelCommand(() => model.Go(), () => !string.IsNullOrEmpty(Url)); GoCommand.RaiseCanExecuteChanged(); this.PropertyChanged += This_PropertyChanged; model.PropertyChanged += Model_PropertyChanged; this.Url = model.Url; this.Html = model.Html; } private void This_PropertyChanged(object sender, PropertyChangedEventArgs e) { switch(e.PropertyName) { case nameof(Url): model.Url = this.Url; GoCommand.RaiseCanExecuteChanged(); break; } } private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e) { switch(e.PropertyName) { case nameof(Model.Html): this.Html = model.Html; break; } } #region Url変更通知プロパティ public string Url { get { return _Url; } set { if(_Url == value) return; _Url = value; RaisePropertyChanged(nameof(Url)); } } private string _Url; #endregion #region Html変更通知プロパティ public string Html { get { return _Html; } set { if(_Html == value) return; _Html = value; RaisePropertyChanged(nameof(Html)); } } private string _Html; #endregion #region GoCommand変更通知プロパティ public ViewModelCommand GoCommand { get { return _GoCommand; } set { if(_GoCommand == value) return; _GoCommand = value; RaisePropertyChanged(nameof(GoCommand)); } } private ViewModelCommand _GoCommand; #endregion protected override void Dispose(bool disposing) { if(disposing) { this.PropertyChanged -= This_PropertyChanged; model.PropertyChanged -= Model_PropertyChanged; } base.Dispose(disposing); } }
【Model】
public class Model : NotificationObject { #region Singleton static Model Instance; public static Model GetInstance() { if(Instance == null) Instance = new Model(); return Instance; } #endregion private Model() { Url = @"https://www.google.co.jp/"; } #region Url変更通知プロパティ public string Url { get { return _Url; } set { if(_Url == value) return; _Url = value; RaisePropertyChanged(nameof(Url)); } } private string _Url; #endregion #region Html変更通知プロパティ public string Html { get { return _Html; } set { if(_Html == value) return; _Html = value; RaisePropertyChanged(nameof(Html)); } } private string _Html; #endregion public async void Go() { using(WebClient wc = new WebClient()) { try { Html = await wc.DownloadStringTaskAsync(Url); } catch(Exception e) { Html = e.Message; } } } }
大まかな流れとしては
ViewでURLを入力→ViewModel→Modelへ入力値が伝搬する
↓
ViewでGoボタンを押す→ViewModel→Modelへメソッドの呼び出しが伝搬する
↓
ModelがGoメソッド内でUrlプロパティの値を使ってHTMLをダウンロードし、Htmlプロパティにセットする
↓
ModelがHtmlプロパティが変化したことをイベントで通知する
↓
Model→ViewModel→ViewへHtmlプロパティが伝搬する。この時、ViewModelはViewの制約である「単一スレッドでしか動作をしない」を受け、UIスレッドでHtmlプロパティの値を更新する。
↓
ViewにHTMLが表示される
となります。まさに厳密なMVVMです。ViewModelはViewの実装上の制約を吸収するためのみの仕事に徹し、ModelはViewの実装上の制約などは何も気にせずのびのびと.NETの機能を使っています。
さて、ですが実際にこのコードを書いていると、問題点も見えてきます。
ViewModelとModelのプロパティ同期関係のコードが散在する
当然、ModelとViewModelを同期するのはViewModelの仕事となるわけですが、この同期作業は割と面倒です。やることとしては、
- 初期化時に値を同期する作業
- Modelのプロパティ変化時にViewModelのプロパティを更新する作業
- ViewModelのプロパティ変化時にModelのプロパティを更新する作業
- View消滅時にModelの監視をやめる作業
実際に書いたコードがこんな感じになります。
この画像を見れば一目瞭然です。あちこちにプロパティ同期のコードが散らばっています。
ViewModelの仕事がプロパティ同期だけならまだいいのですが、他にもCommandの管理など、ModelとViewを繋ぐ仕事は沢山あります。そうすると、それぞれの仕事があちこちに散在してとても見にくいプログラムになりますし、あとからプロパティを増やすときなんかは変更忘れの原因にもなってしまいます。
正直言ってこんなのやってられません。何かもっと簡単に同期作業ができるライブラリが必要です。
ですが、たいていのMVVMライブラリの関心ごとは「ViewとViewModelをいかにして連携させるか」です。ViewModelとModelは通常のC#の言語機能を用いて連携できますから、勝手にやってくださいどうぞというスタンスになってしまうのもある程度しかたのないことなのでしょうが…。
NotifyPropertyHelper
さて、というわけで、ModelとViewModelの連携に的を絞ったライブラリを書いてみました。1. プロパティの同期
流れとしては、- 同期元の同期したいプロパティにPropertySync属性を付ける
- PropertySyncServiceで同期元と同期先のクラスを指定する
- 同期元がDisposeされたときにPropertySyncServiceをDisposeする
上記のViewModelを書き換えてみます。
public class MainWindowViewModel : ViewModel { Model model; PropertySyncService propsync; public void Initialize() { model = Model.GetInstance(); GoCommand = new ViewModelCommand(() => model.Go(), () => !string.IsNullOrEmpty(Url)); GoCommand.RaiseCanExecuteChanged(); this.PropertyChanged += This_PropertyChanged; propsync = new PropertySyncService(this, model); } private void This_PropertyChanged(object sender, PropertyChangedEventArgs e) { switch(e.PropertyName) { case nameof(Url): GoCommand.RaiseCanExecuteChanged(); break; } } #region Url変更通知プロパティ [PropertySync(PropertySyncMode.TwoWay, Direction.TargetToSource)] public string Url { get { return _Url; } set { if(_Url == value) return; _Url = value; RaisePropertyChanged(nameof(Url)); } } private string _Url; #endregion #region Html変更通知プロパティ [PropertySync(PropertySyncMode.OneWayToSource, Direction.TargetToSource)] public string Html { get { return _Html; } set { if(_Html == value) return; _Html = value; RaisePropertyChanged(nameof(Html)); } } private string _Html; #endregion #region GoCommand変更通知プロパティ private ViewModelCommand _GoCommand; public ViewModelCommand GoCommand { get { return _GoCommand; } set { if(_GoCommand == value) return; _GoCommand = value; RaisePropertyChanged(nameof(GoCommand)); } } #endregion protected override void Dispose(bool disposing) { if(disposing) { propsync.Dispose(); } base.Dispose(disposing); } }
public PropertySyncAttribute(PropertySyncMode Mode, Direction InitializeCopyDirection, Type PropertyConverter = null); public PropertySyncAttribute(string TargetPropertyName, PropertySyncMode Mode, Direction InitializeCopyDirection, Type PropertyConverter = null);
PropertySyncModeは同期方向で、InitializeCopyDirectionが初期化時(PropertySyncServiceインスタンス生成時)にコピーをする方向です。注意点としては、同期元、同期先ともに、これらの同期方向に支障が無いようなアクセサビリティ・型のプロパティにする必要があるということです。
PropertyConverterはプロパティを変換するクラスです。後述します。
後は、クラスの生成時(コンストラクタ等)でPropertySyncServiceのインスタンスを生成し、そこで同期元と同期先のインスタンスを設定してあげます。不要になったらDisposeを呼び出すことで、同期処理を打ち切ります。
2. 型の違うプロパティの同期
ときどき型の違うプロパティを同期したくなります。例えば、ModelではReadOnlyObservableCollectionだけど、ViewModelはUIスレッドに同期したReadOnlyObservableCollectionにしたいときなどです。
そのようなときは、IPropertyConverter<TSource, TTarget>を継承した変換クラスを作り、PropertySync属性のコンストラクタに指定してあげるとそれが実現できます。
例えば、ListViewExtensionsではModel用にSortableObservableCollection、ViewModel用にListViewViewModelを用意していますが、それらを変換するには次のようなコードを書けばよいでしょう。
class PersonListViewConverter : IPropertyConverter<ListViewViewModel<PersonViewModel, Person>, SortableObservableCollection<Person>> { public ListViewViewModel<PersonViewModel, Person> ConvertToSource(ListViewViewModel<PersonViewModel, Person> OldSourceValue, SortableObservableCollection<Person> NewTargetValue) { if(OldSourceValue != null) OldSourceValue.Dispose(); if(NewTargetValue == null) return null; else return new ListViewViewModel<PersonViewModel, Person>(NewTargetValue, person => new PersonViewModel(person), DispatcherHelper.UIDispatcher); } public SortableObservableCollection<Person> ConvertToTarget(SortableObservableCollection<Person> OldTargeteValue, ListViewViewModel<PersonViewModel, Person> NewSourceValue) { throw new InvalidOperationException("Not defined."); } }
3. ReadOnlySynchronizationContextCollection
さて、今回のブログ記事のテーマとはちょっと違うのですが、ViewModelとModelの連携を取るためのライブラリということで、このようなコレクションも用意しています。WPFではUIが単一スレッドからしかアクセスできません。そのような制約の吸収はViewModelがやることです。外から変更させる気が無いリストの場合は、ModelがReadOnlyCollectionでリストを公開し、それをViewModelがUIのスレッドに合わせながらUIに橋渡ししてあげる必要があります。それをやってくれるのがReadOnlySyncronizationContextCollectionです。
使い方は使ってみればわかると思います(雑)。
ライセンス
以下の各項目をお守りください- このライブラリを利用する方は自己責任でお願いします。いかなる問題が起きても作者は責任を負いません。
- このソフトを悪用しないでください。
- このソフトウェアを無断で単体での転載、再配布しないでください。ただし、このライブラリを参照しているソフトウェアと一緒に配布する場合を除きます。
- 作者は使用方法やバグに関するサポートをする義務を負いません。
- 有償アプリケーションには使用してはならない。
- 完成したソフトウェアのどこか(ヘルプ、バージョン情報など)と、ReadMeなどのドキュメンテーションに私のライブラリを使用したことを明記すること。ただし、作者(私)がこのライブラリを自分のソフトで使用するときはその限りではない。
公開
Nugetにて公開しています。NotifyPropertyHelper - Nuget
なお、 プレリリース版ですので、VisualStudioからの検索時にはプレリリースもヒットするオプションを指定するようにしてください。