かずきのBlog@hatena

日本マイクロソフトに勤めています。XAML + C#の組み合わせをメインに、たまにASP.NETやJavaなどの.NET系以外のことも書いています。掲載内容は個人の見解であり、所属する企業を代表するものではありません。

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