34
Help us understand the problem. What are the problem?

posted at

updated at

結局ReactivePropertyはどれを使えばいいのか、まとめてみた

私は最近、VBのWinFormsアプリでスパゲティをほぐしたりおかわりさせたりする作業からC#でのWPFアプリケーションのシステム開発と業務内容が大きく変わりました。当然MVVMなのでRxを活用しているのですが、ちょっと調べた程度ではいまいちReactivePropertyたちの違いがピンと来なかったので、備忘録と将来の初学者の方たちのために本稿にまとめます。

前提知識

  • オブザーバーパターンをある程度理解していること
    • こちらの記事がとてもわかりやすいです

  • ReactiveProperty<T>を使ってMVVMアプリケーションの実装を少しでもしたことがあること
    • 全く経験のない方は初学者ですらないため本稿の対象外です、まずはHelloWorldアプリでも作ってみてください

Reactiveたち

Reactiveと名のつくクラスは以下の一覧になります。

クラス 簡単な説明
ReactiveProperty<T> 基本となるクラス。Valueプロパティで値の書き換えが可能
ReadOnlyReactiveProperty<T> Valueプロパティで値の取得のみ行える版。データはソースとなるIObservable<T>から流れてくる
ReactivePropertySlim<T> ReactiveProperty<T>の軽量版。理由がなければこれを使う
ReadOnlyReactivePropertySlim<T> ReadOnlyReactiveProperty<T>の軽量版。理由がなければこれを使う
ReactiveCollection<T> コレクションの追加・変更・削除操作を行う場合に使うクラス。
ReadOnlyReactiveCollection<T> コレクションの内容がソースとなるIObservable<T>または各操作のCollectionChanged<T>によって決まるクラス
ReactiveCommand ボタン等のコマンドのクラス。Subscribeメソッドには値が流れてこないためActionを設定する
ReactiveCommand<T> イベント引数を使いたいときなどに使う。SubscribeメソッドにはTが流れてくるためAction<T>を設定する
AsyncReactiveCommand コマンドに設定した非同期メソッドが完了するまでExecuteできなくなるコマンド。SubscribeメソッドにはFunc<Task>(またはasync void)を設定する
AsyncReactiveCommand<T> 上記の引数が流れてくる版。Func<Task<T>>を設定する
ReactiveTimer タイマーのReacitve版。スタート、ストップができる。タイマー到達時にSubscribeに流れてくるのはタイマー到達回数

これ以外の機能はこちらの記事によくまとまっているので参照してください。

ReactiveProperty<T>ReadOnlyReactiveProperty<T>の使い分け

2つの違いは単純に「値をValueプロパティ経由で直接変えるかどうか」です。以下2パターンのどちらかをするならReadOnlyでない方を使います。

  • ViewModelのコードからRp.Value = 1;のように値を代入する場合
  • xamlで{Binding Rp.Value, Mode=TwoWay}のように画面からVMのプロパティへ値が設定される方向にバインドする場合(TextBoxなど)

IObservable<T>から流れてきた値や、他のReactiveProperty<T>の内容を元に値が一意に決まるような場合はReadOnlyReactiveProperty<T>を使いましょう。これはReactivePropertySlim<T>ReadOnlyReactivePropertySlim<T>の場合も同じです。

ReactiveProperty<T>ReactivePropertySlim<T>の使い分け

すごく雑にまとめるとTextBoxTextプロパティをBindingMode=TwoWayで受ける場合はSlimでないReactiveProperty<T>を使うということが言えます。

以下、ReactivePropertySlim<T>にできないことを記述します。以下のことをやりたい場合でなければ、Slimのほうを基本的に使いましょう。以下のドキュメントの紹介では、インスタンスを作る時間は5倍、基本的なユースケースでは36倍の速度差があるそうです。本節では一部の説明及びコードを以下から引用します。

UIスレッドへのディスパッチは自動で行われない

Observable.Timerなどのように非同期に動作するIObservable<T>を作って画面にバインドしたいときSlim版ではUIスレッドへのディスパッチ、つまりスレッド切り替えが行われずに値が流れます。そうなると、画面要素の別スレッドからのアクセスになるためInvalidOperationExceptionが発生してしまいます。
対策としては通常の(ReadOnly)ReactiveProperty<T>を使うほか、明示的にディスパッチしてやればいいようです。つまり、以下の実装となります。

var rp = Observable.Interval(TimeSpan.FromSeconds(1))
    .ObserveOnUIDispatcher() // dispatch to UI thread
    .ToReadOnlyReactivePropertySlim();

当然ですが、非同期で動作するわけではないIObservable<T>から作った(ReadOnly)ReactivePropertySlim<T>なら全く問題なく画面にバインドできます。

書き換え可能なReactivePropertySlim<T>IObservable<T>から作れない

IObservable<T>からは、ReadOnlyReactivePropertySlim<T>しか作れません。理由は以下となります1

機能的に低下した所は他に、ToReactivePropertySlimがありません。これは、Sourceから流れてくるのとValueへのセットの二通りで値が変化する(Mergeされてる)のが気持ち悪いというか、使いみちあるのそれ?みたいに思ったからです。ない、とはいわないまでも、存在がおかしい。のでいっそ消しました。かわりにToReadOnlyReactivePropertySlimがあります。値の変化はSourceからのみ。このほうが自然でしょふ。

よって、IObservable<T>から作りつつValueから値をセットしたい(BindingModeTwoWayOneWayToSourceも含む)場合は素直にReactiveProperty<T>を使いましょう。
もちろん、IObservable<T>から作らずに普通にnewする場合、BindingMode=TwoWayでもReactivePropertySlim<T>TextboxTextにバインドすることはできます。しかし後述の理由によりほとんど多くのケースではReactiveProperty<string>を使うことになります。

DataAnnotations属性を使ったバリデーションはできない

例えばTextBoxの入力内容でバリデーションをかけたい場合、System.ComponentModel.DataAnnotations名前空間にある属性を使ってReactiveProperty<string>で以下の実装をすることができます。

using System.ComponentModel.DataAnnotations;
public class ViewModel
{
    // 必須かつ 0以上100以下の数値のみ受付
    [Required("入力が必須です")]
    [Range(0, 100, "入力値が範囲外です")]
    public ReactiveProperty<string> InputedText { get; }
}

これはReactivePropertySlim<T>には削ぎ落とされた機能になるため使用できません。画面入力が完全に任意入力可能なことってあまりないと思いますので、DataAnnotationsを使うとなるとTextBoxなどの入力項目にバインドするものはほとんどReactiveProperty<T>を使うことになるでしょう。

ReactiveCommandReactiveCommand<T>の使い分け

表に書いた通り、コマンドに引数があるかどうかで使い分けます。

引数なしコマンド

以下のケースにおけるOnClickedCommandLoadedCommandReactiveCommandです。

view.xaml
<UserControl x:Class="Sample.View"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
    <!-- 以下略 -->
    >
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Loaded">
            <i:InvokeCommandAction Command="{Binding LoadedCommand}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
    <Button Content="Button"
        Command="{Binding OnClickedCommand}" />
</UserControl>
ViewModel.cs
using System.Diagnostics;
public class ViewModel
{
    public ReactiveCommand LoadedCommand { get; }
    public ReactiveCommand OnClickedCommand { get; }
    public ViewModel()
    {
        // Subscribeの中身はActionなので() => { }で記述する
        LoadedCommand = new ReactiveCommand().
            WithSubscribe(() => Debug.WriteLine("Loaded"));
        // 引数なしなのでこんなふうにも略記できる
        OnClickedCommand = new ReactiveCommand().WithSubscribe(Write);

        void Write() => Debug.WriteLine("OnClicked");
    }
}
引数ありコマンド

以下のケースにおけるOnClickedCommandCommandParameterBooleanを指定しているためReactiveCommand<bool>です。以下の例の他にも、EventToReactiveCommandを使う場合などでReactiveCommand<T>が登場するでしょう。

view.xaml
<UserControl x:Class="Sample.View"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:system="clr-namespace:System;assembly=mscorlib"
    <!-- 以下略 -->
    >
    <Button Content="Ok"
        Command="{Binding OnClickedCommand}" >
        <Button.CommandParameter>
            <system:Boolean>True</system:Boolean>
        </Button.CommandParameter>
    </Button>
    <Button Content="Cancel"
        Command="{Binding OnClickedCommand}" >
        <Button.CommandParameter>
            <system:Boolean>False</system:Boolean>
        </Button.CommandParameter>
    </Button>
</UserControl>
ViewModel.cs
using System.Diagnostics;
public class ViewModel
{
    public ReactiveCommand<bool> OnClickedCommand { get; }
    public ViewModel()
    {
        // Subscribeの中身はAction<bool>
        OnClickedCommand = new ReactiveCommand().
            WithSubscribe(isOkClicked => 
            {
                if (isOkClicked) // OKボタンクリック時処理
                else // Cancelボタンクリック時処理
            });
    }
}

ReactiveCommandAsyncReactiveCommandの使い分け

ReactiveCommandIObservable<bool>から生成することもできます。最後の値がCanExecute()の戻り値になるため、例えばButtonならIsEnabledを管理しなくても以下のように作ったReactiveCommandをバインドすることで簡単に押下可否を制御できるようになっています。

ViewModel.cs
public class ViewModel
{
    public ReactiveCommand OnClickedCommand { get; }
    public ViewModel()
    {
        // 5秒おきに押せる/押せないが切り替わるボタン
        OnClickedCommand = Observable.Interval(TimeSpan.FromSeconds(5)).
            Select(i => i % 2 == 0).
            ToRectiveCommand().
            WithSubscribe(() => 
            {
                System.Diagnotics.Debug.WriteLine("Clicked!");
            });
    }
}

ただし、上記方法ではボタンを連打したら連打しただけ"Clicked!"が出力されてしまいます。これは、「ボタンの押下時処理(Subscribeに指定したAction)が完了するまでボタンを押下不可にする」という制御がかかっていないためです。
これを本来のボタン押下可否のIObservable<bool>と一緒に制御するのは、CombineLatestValuesAreAllTrueなど使えば実装可能ですが煩雑ですし頻出パターンです。そこで、Subscribeに指定したasyncメソッドが完了するまでCanExecute()falseになるコマンドとしてAsyncReactiveCommandを利用できます。

ViewModel.cs
public class ViewModel
{
    public AsyncReactiveCommand OnClickedCommand { get; }
    public ViewModel()
    {
        // 5秒おきに押せる/押せないが切り替わり、二重押しもできないボタン
        OnClickedCommand = Observable.Interval(TimeSpan.FromSeconds(5)).
            Select(i => i % 2 == 0).
            ToAsyncRectiveCommand().
            WithSubscribe(async () => // 非同期メソッドにする
            {
                await Task.Delay(1000); // なんか重い処理
                System.Diagnotics.Debug.WriteLine("Clicked!");
            });
            // Taskが返ってくればいいので非同期処理しないならこれでもOK
            WithSubscribe(() =>
            {
                System.Diagnotics.Debug.WriteLine("Clicked!");
                return Task.CompletedTask;
            });

    }
}

上記実装をすると、OnClickedCommandはボタン押し後に"Clicked"が表示されるまでの約1秒間、つまり非同期メソッドが完了するまで押せない状態になります。

ただし、この「完了するまで再度ボタンが押せない状態」を他のボタンのAsyncReactiveCommandと共有することはできません。これをしたい場合は、Reactive.Bindings.Notifiers名前空間のBusyNotifierを使って以下のように実装できます。ただしこのパターンはAsyncでないReactiveCommandにする必要があります。

ViewModel.cs
using Reactive.Bindings.Notifiers;
public class ViewModel
{
    public BusyNotifier BusyNotifier { get; } = new();
    public ReactiveCommand OnClickedCommand1 { get; }
    public ReactiveCommand OnClickedCommand2 { get; }
    public ReactiveCommand OnClickedCommand3 { get; }
    public ViewModel()
    {
        var isEnabled = Observable.Interval(TimeSpan.FromSeconds(5)).
            Select(i => i % 2 == 0).
            // BusyNotifierはIObservable<bool>
            // ただしBusyなときにtrueなので反転する
            CombineLatest(BusyNotifier, (x, y) => x && !y);

        // 5秒おきに押せる/押せないが切り替わるボタン
        // 二重押しと他のボタンが押してる間も押せない
        OnClickedCommand1 = isEnabled.
            ToRectiveCommand().
            WithSubscribe(() => ait OnClicked("1"));
        OnClickedCommand2 = isEnabled.
            ToRectiveCommand().
            WithSubscribe(async () => await OnClicked("2"));
        OnClickedCommand3 = isEnabled.
            ToRectiveCommand().
            WithSubscribe(async () => await OnClicked("3"));

        // 例のため1秒間押せなくしたいのでasyncメソッドにしているが、
        // 同期処理でいいなら普通のvoidにすればOLK
        async Task OnClicked(string no)
        {
            if (BusyNotifier.IsBusy) return;
            using (BusyNotifier.ProsessStart())
            {
                await Task.Delay(1000);
                System.Diagnotics.Debug.WriteLine($"Button{no} Clicked!");
            }
        }
    }
}

ReactiveCommad<T>AsyncReactiveCommand<T>の使い分けは全く同じなので省略します。

ReacitveCollection<T>ReadOnlyReacitveCollection<T>の使い分け

ReactiveProperty<T>ReadOnlyReactiveProperty<T>のケースと同様に、ObservableCollection<T>から流れてきた値や、他のReactiveCollection<T>の内容を元に値が一意に決まるような場合はReadOnlyReacitveCollection<T>を使いましょう。

サーバーから取得したデータ群に、画面操作で新しくデータを増やしたいときはReactiveCollection<T>が向いています。
2つの共通点として、ソースとなったIObservable<T>から値が流れてきた場合、すべてAddされます。そのため必要なら適切なタイミングでClearする必要があることに注意してください。

ReadOnlyReacitveCollection<T>ReadOnlyReacitveProperty<IEnumerable<T>>の使い分け

こちらは特殊パターンです。以下の2つは直感的には同じに見えませんか?

  • コレクションの各要素をコードから操作したりしない。コレクションの中身はすべてソースとなるIObservable<T>から決まる
  • コレクションの操作をコードからはしない。内容が更新される場合はすべてClearしてAddRangeする(洗い替え)

データを一覧表示する画面を考えてみてください。一覧画面ではサーバーから受け取った複数件のデータを画面表示しますが、画面からデータを追加したり各行の内容を更新できる必要がない場合があります。このような場合にはコレクションへのAddResetUpdateDeleteの各操作と監視が不要です(というかデータ取得のたびに1件ずつAddのイベントが起きられても困る)。
そのため、Model側ではIEnumerable<T>でデータを持っておき、それをReadOnlyReactiveProperty<IEnumerable<T>>にする方法が考えられます

public class Model : INotifyPropertyChanged
{
    public IEnumerable<MyData> Received { get; private set => /* 変更通知処理 */ }
    public void Request()
    {
        // サーバへのデータ問い合わせ
    }
}
public class ViewModel
{
    // 取得したデータ(型名が長い...)
    public ReadOnlyReactivePropertySlim<IEnumerable<MyData>> Items { get; }
    public ViewModel(Model model)
    {
        // IObservable<IEnumerable<T>>を生成してModelのデータを監視
        Items = model.ObserveProperty(m => m.Received).
            // もし必要ならSelectメソッドを噛ましてデータを変換する
            // C#9.0以降でNullable=enableなら型指定が必要
            // (ないとTが<IEnumerable<MyData>>?に推論される)
            ToReadOnlyReactivePropertySlim<IEnumerable<MyData>>();
    }
}

ReadOnlyReactiveProperty<IEnumerable<T>>を使うことによる利点と欠点を述べます。

  • 利点
    • Modelがデータを受信したとき、Receivedの値変更の通知が1回だけになる
      • ObservableCollection<T>からReadOnlyReactiveCollection<T>にする場合、洗い替えして10件のデータになるとClearの1回とAddの10回で変更通知が11回起きるため、場合によっては速度改善になりえる
    • 要素を追加・削除しないことが明確になる
  • 欠点
    • 1要素だけを追加・削除したりすることができない
    • 内容の更新は監視できない。ListView等のItemSourceに指定しているとき、特定の要素の内容を変えたりしても変更は通知されない
      • 例えばIsSelectedを表示用データにバインドさせたい場合、Itemsから要素のどれかのIsSelectedが変更されたかの監視をViewModelからはできない2。これはObserveElementProperty(ReadOnly)ObservableCollection<T>の拡張メソッドのため。

個人的には以下の条件をすべて満たす場合、ReactiveCollection<T>ではなくReactiveProperty<IEnumerable<T>>にしています。

  1. コレクションの各要素の監視が不要
  2. コレクションが更新されるときは常に洗い替えで全件更新

IEnumerable<T>からReadOnlyReactiveCollection<T>の生成

「Modelは常に洗い替えでデータを取得するが、各要素の状態を監視する必要があるためViewModelではReactiveCollection<T>で保持したい」というケースがあります。この場合はSelectManyを使うことで、IObservable<IEnumerable<T>>からReadOnlyReactiveCollection<T>を作ることが可能です。
ただし、ModelのIEnumerable<T>プロパティが洗い替えされるときは一度ReadOnlyReactiveCollection<T>をクリアする必要があります。クリアしないと前節の通り全件Addされるからです。ToReadOnlyReactiveCollection<T>()の第一引数にIObservable<Unit>を渡すとOnNextされたときにクリアされるコレクションを作れるので、検索ボタンなどプル型でModelのデータが更新される場合は更新トリガーの前にクリアするようにしましょう。

public class Model : INotifyPropertyChanged
{
    public IEnumerable<MyData> Received { get; private set => /* 変更通知処理 */ }
    public void Request()
    {
        // サーバへのデータ問い合わせ
    }
}

public class ViewModel
{
    private Model _model;
    // コレクションをクリアするためのオブザーバー
    private Subject<Unit> _clearSubject = new();
    public ReadOnlyReactiveCollection<MyData> Items { get; }
    public ReadOnlyReactivePropertySlim<int> SelectedCount { get; }
    public ViewModel(Model model)
    {
        _model = model;

        // IObservable<IEnumerable<MyData>>を生成してModelのデータを監視
        Items = model.ObserveProperty(m => m.Received).
            // SelectManyでIObservable<T>に変換
            SelectMany(mydata => mydata /* 必要なら別のTに変換する処理を書く */).
            ToReadOnlyReactiveCollection(_clearSubject);

        // データのIsSelectedを監視して変わったらその度にカウント
        SelectedCount = Items.
            ObserveElementProperty(item => item.IsSelected).
            Select(_ => Items.Count(item => item.IsSelected)).
            ToReadOnlyReactivePropertySlim();
    }

    /// <summary>検索ボタン押下時処理</summary>
    private void OnSearchButtonClicked()
    {
        // 現在のデータをクリアしてから取得しにいく
        _clearSubject.OnNext(Unit.Default);
        _model.Request();
    }
}

あとがき

VBでWinFormsをいじっている間は「リアクティブプログラミング?なにそれおいしいの?」という気持ちでいましたが、オブザーバーパターンを理解すると「これはすごいぞ!」と理解できるようになりました。今ではRxなしでコーディングすることはできません。
ただ、まだまだ使いこなせてない機能が多いです。本稿は記事作成時点での理解で書いているため間違っている部分があればどしどし指摘していただけると幸いです。

なお、本稿で一番重要なのは以下だと思っています。

  • 理由がなければReactivePropertySlim<T>を使う

  1. http://neue.cc/2018/01/18_562.html 

  2. Tの各要素をすべて個別に監視するなら可能だが、そんなことをするくらいならReactiveCollection<T>ObserveElementPropertyするほうが自然 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
34
Help us understand the problem. What are the problem?