かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

MVVMでめんどくさいと思ってる部分を、個人的にどうやって緩和してるか

MVVMのめんどくさいと感じてるところ

ModelとViewModelのクラスのマッピング

MVVMでアプリ組んでるとModelとViewModelで似た構造のクラスを作って、値の移し替えを行うことがあります。AutoMapperとか使ってもいいのですが、ReactivePropertyを使うことでも楽をすることができます。

以下のようなModelクラスがあるとします。(BindableBaseクラスはPrismのINotifyPropertyChangedを実装したクラスです)

public class Person : BindableBase
{
    private string name;

    public string Name
    {
        get { return this.name; }
        set { this.SetProperty(ref this.name, value); }
    }

    private int age;

    public int Age
    {
        get { return this.age; }
        set { this.SetProperty(ref this.age, value); }
    }

}

上記のクラスは2プロパティしかないですが、こんな感じでINotifyPropertyChangedの変更通知に対応したプロパティをたくさんもっているクラスがModelの中にはあります。

ModelからViewModelへの値の移し替えは、Codeplex.Reactive.Extensions名前空間に定義されているObserveProperty拡張メソッドを使うことで簡単に定義できます。

public class PersonViewModel
{
    public ReactiveProperty<string> Name { get; private set; }

    public ReactiveProperty<string> Age { get; private set; }

    public PersonViewModel(Person model)
    {
        this.Name = model
            .ObserveProperty(x => x.Name) // IObservable<string>に変換
            .ToReactiveProperty();        // ReactiveProperty<string>に変換

        this.Age = model
            .ObserveProperty(x => x.Age) // IObservable<int>に変換
            .Select(x => x.ToString())   // IObservable<string>に変換
            .ToReactiveProperty();       // ReactiveProperty<string>に変換
    }
}

これで、Personクラスのプロパティが書き換わると、ViewModelのプロパティが書き換わる処理が定義できます。

var p = new Person { Name = "tanaka" };
var vm = new PersonViewModel(p);

Console.WriteLine(vm.Name.Value); // tanaka

p.Name = "kimura";
Console.WriteLine(vm.Name.Value); // kimura

p.Age = 10;
Console.WriteLine(vm.Age.Value); // 10

Model → ViewModel間の一方向データバインディングと見ることもできます。

ModelとViewModelの双方向バインディング

単純なケースでは、MとVM間のプロパティを双方向でバインドするToReactivePropertyAsSynchronizedメソッドを提供しています。

public class PersonViewModel
{
    public ReactiveProperty<string> Name { get; private set; }

    public ReactiveProperty<string> Age { get; private set; }

    public PersonViewModel(Person model)
    {
        this.Name = model
            .ToReactivePropertyAsSynchronized(x => x.Name); // ReactiveProperty<string>に変換

        this.Age = model
            .ToReactivePropertyAsSynchronized(
                x => x.Age,
                convert: x => x.ToString(),   // M -> VMの変換処理
                convertBack: x =>             // VM -> Mの変換処理
                {
                    try
                    {
                        return int.Parse(x);
                    }
                    catch
                    {
                        return -1; // error
                    }
                });
    }
}
var p = new Person { Name = "tanaka", Age = 10 };
var vm = new PersonViewModel(p);

Console.WriteLine("{0} {1}", vm.Name.Value, vm.Age.Value); // tanaka 10

vm.Name.Value = "kimura";
vm.Age.Value = "30";
Console.WriteLine("{0} {1}", p.Name, p.Age); // kimura 30

vm.Age.Value = "xxx"; // error!!
Console.WriteLine("{0} {1}", p.Name, p.Age); // kimura -1

バリデーションとの連携

ToReactivePropertyAsSynchronized拡張メソッドは、シンプルな双方向のバインディングをサポートしていますが、エラーがあったらModelのデータを書き換えたくないという要望には対応していません。そこは、少しめんどくさいですが、自前で対応するしか無いです。

まず、順を追ってReactivePropertyの値の検証から説明します。ReactivePropertyのSetValidateNotifyErrorメソッドでReactivePropertyの値の検証が出来ます。(そのほかにも色々提供していますが一番単純な奴で)エラーがある場合はエラーメッセージを返して、エラーがない場合はnullを返すようにします。

public class PersonViewModel
{
    public ReactiveProperty<string> Name { get; private set; }

    public ReactiveProperty<string> Age { get; private set; }

    public PersonViewModel(Person model)
    {
        this.Name = model
            .ObserveProperty(x => x.Name)                                    // IObservable<string>に変換して
            .ToReactiveProperty()                                            // ReactiveProperty<string>に変換して
            .SetValidateNotifyError(x =>                                     // 空文字の時はエラーにする
                string.IsNullOrWhiteSpace(x) ? "Name is required" : null);

        this.Age = model
            .ObserveProperty(x => x.Age)                                     // IObservable<int>に変換して
            .Select(x => x.ToString())                                       // IObservable<string>に変換して
            .ToReactiveProperty()                                            // ReactiveProperty<string>に変換して
            .SetValidateNotifyError(x =>                                     // int型に変換できない場合はエラーにする
            {
                int result; // no use
                return int.TryParse(x, out result) ? null : "Error";
            });
    }
}

プロパティのエラーの有無は、HasErrorsプロパティで確認できます。

var p = new Person { Name = "tanaka", Age = 10 };
var vm = new PersonViewModel(p);

Console.WriteLine(vm.Name.HasErrors); // False
vm.Name.Value = ""; // Error!
Console.WriteLine(vm.Name.HasErrors); // True

Console.WriteLine(vm.Age.HasErrors); // False
vm.Age.Value = "xxx"; // Error!
Console.WriteLine(vm.Age.HasErrors); // True

エラーメッセージは、WPFの場合はValidation.Errors添付プロパティのErrorContentで取得できるのでXAMLで完結できます。WPF以外のプラットフォームでは、特に検証エラーのサポートが無いので多少めんどくさい手順を踏むことにになります。手順は別記事に譲ります。

追記!!めんどく無いようにしました。

ReactiveProperty v1.2.0をリリースしました - かずきのBlog@hatena

追記ここまで

VMのプロパティにエラーが無いとき、つまりReactivePropertyのHasErrorsプロパティがFalseの時に、VM → Mに値を移せばいいということになります。

public class PersonViewModel
{
    public ReactiveProperty<string> Name { get; private set; }

    public ReactiveProperty<string> Age { get; private set; }

    public PersonViewModel(Person model)
    {
        this.Name = model                                                    // M -> VM
            .ObserveProperty(x => x.Name)                                    // IObservable<string>に変換して
            .ToReactiveProperty()                                            // ReactiveProperty<string>に変換して
            .SetValidateNotifyError(x =>                                     // 空文字の時はエラーにする
                string.IsNullOrWhiteSpace(x) ? "Name is required" : null);
        this.Name                                                            // VM -> M
            .Where(_ => !this.Name.HasErrors)                                // エラーが無いときは
            .Subscribe(x => model.Name = x);                                 // 値を書き戻す
         
        this.Age = model                                                     // M -> VM
            .ObserveProperty(x => x.Age)                                     // IObservable<int>に変換して
            .Select(x => x.ToString())                                       // IObservable<string>に変換して
            .ToReactiveProperty()                                            // ReactiveProperty<string>に変換して
            .SetValidateNotifyError(x =>                                     // int型に変換できない場合はエラーにする
            {
                int result; // no use
                return int.TryParse(x, out result) ? null : "";
            });
        this.Age                                                             // VM -> M
            .Where(_ => !this.Age.HasErrors)                                 // エラーが無いときは
            .Select(x => int.Parse(x))                                       // int型に変換して
            .Subscribe(x => model.Age = x);                                  // 書き戻す
    } 
}

これで、バリデーションエラーのないときだけVM → Mへ値を書き戻す処理のあるViewModelが出来ました。

var p = new Person { Name = "tanaka", Age = 10 };
var vm = new PersonViewModel(p);

Console.WriteLine(p.Name); // tanaka
vm.Name.Value = "kimura";
Console.WriteLine(p.Name); // kimura

vm.Name.Value = ""; // Error!
Console.WriteLine(p.Name); // kimura

コレクションのマッピング

MとVMの値の連携がRxで簡単に出来るようになりました。ただ、通常は、単一のオブジェクトだけではなく、コレクションに入ったModelをVMのコレクションに変換するといった処理がよくあります。

ReactivePropertyでは、これを単純化するためにReadOnlyReactiveCollectionクラスを提供しています。これは、ObservableCollectionから簡単に作ることができます。

var m = new ObservableCollection<Person>();

var vm = m.ToReadOnlyReactiveCollection(x => new PersonViewModel(x)); // M -> VMの変換処理を渡すと後はよろしくしてくれる

m.Add(new Person { Name = "tanaka", Age = 30 });
Console.WriteLine(vm[0].Name.Value); // tanaka

m[0].Name = "kimura";
Console.WriteLine(vm[0].Name.Value); // kimura

m.Add(new Person { Name = "nakata", Age = 40 });
Console.WriteLine(vm[1].Name.Value); // nakata

関連

ReactiveProperty オーバービュー - かずきのBlog@hatena

ReactiveProperty/README-ja.md at master · runceel/ReactiveProperty · GitHub