2018年2月25日日曜日

MVVMにおけるViewModelとModelのプロパティ同期 - NotifyPropertyHelper 1.0.0-beta1

MVVMスタイルでWPFアプリケーションなどを作っていると、ViewModelとModelにやたら同じプロパティが出てきます。

それもそのはず、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;
            }
        }
    }
}
例によってLivetを使っています。

大まかな流れとしては

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の仕事となるわけですが、この同期作業は割と面倒です。
やることとしては、
  1. 初期化時に値を同期する作業
  2. Modelのプロパティ変化時にViewModelのプロパティを更新する作業
  3. ViewModelのプロパティ変化時にModelのプロパティを更新する作業
  4. View消滅時にModelの監視をやめる作業
の3つになります。
実際に書いたコードがこんな感じになります。

この画像を見れば一目瞭然です。あちこちにプロパティ同期のコードが散らばっています。
ViewModelの仕事がプロパティ同期だけならまだいいのですが、他にもCommandの管理など、ModelとViewを繋ぐ仕事は沢山あります。そうすると、それぞれの仕事があちこちに散在してとても見にくいプログラムになりますし、あとからプロパティを増やすときなんかは変更忘れの原因にもなってしまいます。

正直言ってこんなのやってられません。何かもっと簡単に同期作業ができるライブラリが必要です。
ですが、たいていのMVVMライブラリの関心ごとは「ViewとViewModelをいかにして連携させるか」です。ViewModelとModelは通常のC#の言語機能を用いて連携できますから、勝手にやってくださいどうぞというスタンスになってしまうのもある程度しかたのないことなのでしょうが…。

NotifyPropertyHelper

さて、というわけで、ModelとViewModelの連携に的を絞ったライブラリを書いてみました。

1. プロパティの同期

流れとしては、
  1. 同期元の同期したいプロパティにPropertySync属性を付ける
  2. PropertySyncServiceで同期元と同期先のクラスを指定する
  3. 同期元が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);
    }
}
同期したいプロパティにはPropertySyncという属性を付加します。
public PropertySyncAttribute(PropertySyncMode Mode, Direction InitializeCopyDirection, Type PropertyConverter = null);
public PropertySyncAttribute(string TargetPropertyName, PropertySyncMode Mode, Direction InitializeCopyDirection, Type PropertyConverter = null);
PropertySync属性はこのような2つのコンストラクタを持っています。前者はTargetPropertyNameが省略されていますが、省略された場合は同名のプロパティを当たります。
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.");
    }
}
2つのConvertToSourceとConvertToTargetの2つのメソッドは古い値も引き渡してくれるため、Disposeが必要なインスタンスに対しても適切な措置を取ることができます。

3.  ReadOnlySynchronizationContextCollection

さて、今回のブログ記事のテーマとはちょっと違うのですが、ViewModelとModelの連携を取るためのライブラリということで、このようなコレクションも用意しています。

WPFではUIが単一スレッドからしかアクセスできません。そのような制約の吸収はViewModelがやることです。外から変更させる気が無いリストの場合は、ModelがReadOnlyCollectionでリストを公開し、それをViewModelがUIのスレッドに合わせながらUIに橋渡ししてあげる必要があります。それをやってくれるのがReadOnlySyncronizationContextCollectionです。

使い方は使ってみればわかると思います(雑)。

ライセンス

以下の各項目をお守りください
  • このライブラリを利用する方は自己責任でお願いします。いかなる問題が起きても作者は責任を負いません。
  • このソフトを悪用しないでください。
  • このソフトウェアを無断で単体での転載、再配布しないでください。ただし、このライブラリを参照しているソフトウェアと一緒に配布する場合を除きます。
  • 作者は使用方法やバグに関するサポートをする義務を負いません。
  • 有償アプリケーションには使用してはならない。
  • 完成したソフトウェアのどこか(ヘルプ、バージョン情報など)と、ReadMeなどのドキュメンテーションに私のライブラリを使用したことを明記すること。ただし、作者(私)がこのライブラリを自分のソフトで使用するときはその限りではない。

公開

Nugetにて公開しています。
NotifyPropertyHelper - Nuget
なお、 プレリリース版ですので、VisualStudioからの検索時にはプレリリースもヒットするオプションを指定するようにしてください。

0 件のコメント:

コメントを投稿