ReactiveProperty : Rx + MVVMへの試み

Reactive Extensionsといったら非同期、じゃなくて、その前にイベントですよ!イベント!というわけで、随分手薄になっていたイベント周りの話を増強したいこの頃です。イベントと一口に言っても色々あります。UI(クリックやマウスムーブ)、センサー、変更通知(INotifyPropertyChanged)などなど。中でも一番よく使うのは、UI周りのイベントでしょう。

しかし、UIの持つTextChangedイベントだのから直接FromEventPatternで変換してしまったら、Viewと密接に結びついてしまってよろしくない。ここはMVVM的にやりましょう。でも、どうやって?

View(UI)が持つネイティブなイベントを、ViewModelの持つ更新通知付きのプロパティに変換します。これはバインディングにより可能です。そこはWPF/SLの仕組みに任せましょう。ということで、RxでUIに対してプログラミングするというのは、ViewModelの通知に対してプログラミングするという形になります。

テキストボックスの変更に反応して、1秒ディレイをかけた後に表示する、という簡単な例を(何の面白みもありません、すみません)

Microsoft Silverlight を入手

public class ToaruViewModel : INotifyPropertyChanged
{
    private string input;
    public string Input
    {
        get { return input; }
        set { input = value; RaiseEvent("Input"); }
    }

    private string output;
    public string Output
    {
        get { return output; }
        set { output = value; RaiseEvent("Output"); }
    }

    public ToaruViewModel()
    {
        Observable.FromEvent<PropertyChangedEventHandler, PropertyChangedEventArgs>(
                h => (sender, e) => h(e),
                h => this.PropertyChanged += h, h => this.PropertyChanged -= h)
            .Where(e => e.PropertyName == "Input") // Inputが更新されたら
            .Select(_ => Input) // Inputの値を
            .Delay(TimeSpan.FromSeconds(1)) // 1秒遅らせて
            .ObserveOnDispatcher() // Dispatcherで(Silverlightではこれ必要・WPFでは不要)
            .Subscribe(s => Output = "入力が1秒後に表示される:" + s); // Outputへ代入
    }

    // この辺は別途、ライブラリを使って持ってくるほうが良いかも
    public void RaiseEvent(string propertyName)
    {
        var handler = PropertyChanged;
        if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
    }

    public event PropertyChangedEventHandler PropertyChanged;
}

// xaml.csはInitializeだけ、xamlのバインディングは各プロパティへ当てるだけ。
// ただしSL/WP7はUpdateSourceTrigger=PropertyChangedに対応してないので別途Behaviorの適用が必要
// 詳しくは、最後にソース配布(WPF/SL/WP7全て含む)URLを置いているのでそちらを見てください

……実にダサい。はい。全くいけてないです。バインディング可能なのはプロパティなので、そういった中間レイヤへの中継が発生していて、冗長だし、美味しさがかなり損なわれています。わかりきったINotifyPropertyChangedのWhere, Selectは無駄そのもので。勿論、簡単にDelayを混ぜられるといった時間の扱いの容易さはRxならでは、ではあるのですけれど。

ReactiveProperty

中継が手間ならば、中間レイヤだけを抜き出してやればいい。通知処理を内包したIObservable<T>があれば解決する。というわけで、ReactivePropertyと名付けたものを作りました。それを使うと、こうなります。

public class SampleViewModel : INotifyPropertyChanged
{
    public ReactiveProperty<string> ReactiveIn { get; private set; }
    public ReactiveProperty<string> ReactiveOut { get; private set; }

    public SampleViewModel()
    {
        // UIから入力されるものはnewで作成、デフォルト値も同時に指定出来る。
        ReactiveIn = new ReactiveProperty<string>(_ => RaiseEvent("ReactiveIn"), "でふぉると");

        // UIへ出力するIO<T>はToReactivePropertyで、初期値での発火も自動的にされます。
        ReactiveOut = ReactiveIn
            .Delay(TimeSpan.FromSeconds(1))
            .Select(s => "入力が1秒後に表示される:" + s)
            .ToReactiveProperty(_ => RaiseEvent("ReactiveOut"));
    }

    // 通常は、他のMVVMフレームワークなりを使い、それの更新通知システムを利用するといいでしょう
    // Rxを使ったからって、決してMVVMフレームワークと競合するわけではなく、むしろ協調すると考えてください
    public void RaiseEvent(string propertyName)
    {
        var handler = PropertyChanged;
        if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
    }

    public event PropertyChangedEventHandler PropertyChanged;
}
// これはWPF版のもの
<Window x:Class="ReactiveProperty.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:l="clr-namespace:ReactiveProperty"
        Title="MainWindow" Height="350" Width="525">
    <Window.DataContext>
        <l:SampleViewModel />
    </Window.DataContext>
    <StackPanel>
        <TextBox Text="{Binding ReactiveIn.Value, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Text="{Binding ReactiveOut.Value}" />
    </StackPanel>
</Window>

XAMLではPathに必ず.Valueまで指定します。これによりGetが求められれば最新の値を返し、値をSetされればPushするようになります。

今回はUI->ReactiveProperty->クエリ演算->ReactiveProperty->UIという風に戻してやりましたが、勿論、UIからの入力をModelに流してそれで止めてもいいし、Modelからの値をUIに流すだけでもいいし、トリガーはタイマーであってもいいし、その辺は完全に自由です。普通の通知プロパティと何も変わりません。また普通のプロパティとして使いたい時は.Valueで値を取り出す/セットできます。

かなりシンプルに仕上がります。通知付きプロパティは、本質的に値の変更毎に通知される無限長のIObservable<T>と見なせるので、そのことにより表現がより自然になっています。書き味も、リアクティブプログラミング(といわれてパッと浮かばれる値が自動更新されるという奴)にかなり近い感じの風合い。XAMLでのバインドも簡単ですし、VMの実装も自動実装プロパティだけで書けるので記述が楽チン。

そして、Rxを使うことによる最大の利点である、他のイベント(他の変更通知プロパティ)と合成しやすかったり、時間が扱いやすくなったり、非同期と混ぜても同じように扱えたり、スレッドの切り替えが簡単であったり、などを最大限に甘受できます。VMとして独立している、かつ全てがRxに乗っているため、単体テストも非常に作成しやすい状態です(時間軸を扱う処理のテストは通常難しいのですが、Rxの場合は自分で時間をコントロール可能なSchedulerを中間に挟むと、好きなように時間を進められるようになります、イベントのテストも、この状態ならばプロパティを変更するだけで生成されますし)。また、決して他のMVVMフレームワークと競合が起こるわけではない(多分……)のも見逃せない利点です。

単純な例なのでModelがありませんが、まあこんな感じ?(それと今はコマンドがないので単純なデータバインドのみの図です)。Modelへのアクセスは通常恐らくRx:Query内で行い、Modelの形態は色々だと思いますが、通信してデータを処理して返す、みたいなものはRxになっているとVMのReactiveProperty側での合成処理が容易なので、非同期にしてIObservable<T>で返すと良いのではかと思います。自身が通知を持つReactivePropertyになっていてもいいですね。そうなると、コードのほとんどがLINQになるという素敵な夢が見れる気がしますが気のせいです。

実装

ReactivePropertyの実装はこんな感じです。ご自由にコピペって使ってみてください。

using System;
#if WINDOWS_PHONE
using Microsoft.Phone.Reactive;
#else
using System.Reactive.Linq;
using System.Reactive.Subjects;
#endif

public class ReactiveProperty<T> : IObservable<T>, IDisposable
{
    T latestValue;
    IObservable<T> source;
    Subject<T> anotherTrigger = new Subject<T>();
    IDisposable sourceDisposable;

    public ReactiveProperty(Action<T> propertyChanged, T initialValue = default(T))
        : this(Observable.Never<T>(), propertyChanged, initialValue)
    { }

    public ReactiveProperty(IObservable<T> source, Action<T> propertyChanged, T initialValue = default(T))
    {
        this.latestValue = initialValue;

        var merge = source.Merge(anotherTrigger)
            .DistinctUntilChanged()
            .Publish(initialValue);
        this.sourceDisposable = merge.Connect();

        // PropertyChangedの発火はUIスレッドで行うことにする
        // UIへの反映の際に、WPFでは問題ないが、SL/WP7ではUIスレッドから発行しないと例外が出るため
        merge.ObserveOnDispatcher().Subscribe(x =>
        {
            latestValue = x;
            propertyChanged(x);
        });

        this.source = merge;
    }

    public T Value
    {
        get
        {
            return latestValue;
        }
        set
        {
            latestValue = value;
            anotherTrigger.OnNext(value);
        }
    }

    public IDisposable Subscribe(IObserver<T> observer)
    {
        return source.Subscribe(observer);
    }

    public void Dispose()
    {
        sourceDisposable.Dispose();
    }
}

// 拡張メソッド
public static class ObservableExtensions
{
    public static ReactiveProperty<T> ToReactiveProperty<T>(this IObservable<T> source, Action<T> propertyChanged, T initialValue = default(T))
    {
        return new ReactiveProperty<T>(source, propertyChanged, initialValue);
    }
}

Valueで値の中継をしているという、それだけです。Publish(value)はBehaviorSubjectというものを使った分配で、必ず最新の値一つをキャッシュとして持っていて、Subscribeされると同時に、まずその値で通知してくれます。これにより「初期値での自動発火」が自然に行える、という仕組みになっています。また、プロパティの変更時に同値の場合は変更通知をしない、というよくあるほぼ必須処理も、ここでDistinctUntilChangedを挟んで行っています(オプションで選択制にしてもいいかもしれない)。

それReactiveUI?

ReactiveUIというRxを前提にしたMVVMフレームワークがあって、それに用意されているObservableAsPropertyHelperと、ReactivePropertyはかなり近いです(ということにプロトタイプ作ってから気づいた、ReactiveUIはこれまで名前は知ってたけど中身完全ノーチェックだったので)。ただ、機能的にはOAPHは双方向バインディングに対応していないので、ReactivePropertyのほうが上です。また、OAPHは使い勝手もあまり良くないし、名前がダサい(ObservableAsPropertyHelperは長すぎるし型名として宣言させるにはイマイチに思える……)などなどで、あまり気に入るものではなかったです。

ReactiveUIは全体的には軽く眺めた程度なのですが、今ひとつ私には合わない。ちょっと、いや、かなり気にいらない。なので、私としてはそのうち他のMVVMライブラリをベースに置いた上での拡張として、Rx用のUI周りライブラリを作りたい。独自に上から下まで面倒を見るフレームワーク、という指針は今一つに思えるので、Rxならではの特異な部分だけを、最初から他のMVVMフレームワークの拡張として用意していく、という方向性のほうが良いものが作れると思っています。素のままのRxでは辛いので、何かしらの中間層が必要なのは間違いないので。

次は、ReactiveCommandを!あー、あとReactiveCollectionも必要かしら。Validationとかも……。まあ、そういうところは普通に書けばいいんですよ、何も全部Rxでやる必要はないですからね。

まとめ

WPFのバインディングの美味しさをRxで更に美味しくする、ということでした。世の中的には弱参照が~などなどというお話もありますが、それには全然追いついてませんので、おいおいちかぢかそのうち。

今回のコードの全体(WPF/SL/WP7)はneuecc / ReactiveProperty /Bitbucketに置いてありますので、好きに見てください。例が単純すぎると美味しさもよくわからないので、もう少し複雑な例で、サンプル準備中なのでしばしお待ちを。

ところで9/15にいよいよRx本が出ます。

オライリーで出ているProgramming C#の著者と、ReactiveUIの作者(元Microsoft Office Labs、つい最近Githubに転職した模様)の共著です。私も買いますので、うーん、読書会とかやったら来てくれる方います?

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive