40
51

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2021-10-28

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

2024/08/23 編集

だいぶ遅れましたが、v 9.0で追加された内容について追記しました。

2023/02/28 追記

02/12にv 9.0がリリースされました。
更新事項は@okazukiさんのZennnの記事を御覧ください。

私はまだアップデートできておりませんが、記事を読んで重要かな~と思ったのは以下の点です。

  • コマンドのスリム版ReactiveCommandSlimの追加
  • バリデーション機能を持つValidatableReactiveProperty<T>の追加
  • Notifier 系クラスの BooleanNotifier, BusyNotifier, CountNotifier, MessageBroker, AsyncMessageBrokerがスレッドセーフではなくなった(従来と同じくスレッドセーフ版の**Legacyクラスもある

前提知識

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

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

Reactiveたち

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

クラス 簡単な説明
ReactiveProperty<T> 基本となるクラス。Valueプロパティで値の書き換えが可能
ReadOnlyReactiveProperty<T> Valueプロパティで値の取得のみ行える版。データはソースとなるIObservable<T>から流れてくる
ReactivePropertySlim<T> ReactiveProperty<T>の軽量版。理由がなければこれを使う
ValidatableReactiveProperty<T> 入力されたTに対するバリデーション機能を持つ軽量なクラス。
ReadOnlyReactivePropertySlim<T> ReadOnlyReactiveProperty<T>の軽量版。理由がなければこれを使う
ReactiveCollection<T> コレクションの追加・変更・削除操作を行う場合に使うクラス。
ReadOnlyReactiveCollection<T> コレクションの内容がソースとなるIObservable<T>または各操作のCollectionChanged<T>によって決まるクラス
ReactiveCommand ボタン等のコマンドのクラス。Subscribeメソッドには値が流れてこないためActionを設定する
ReactiveCommand<T> イベント引数を使いたいときなどに使う。SubscribeメソッドにはTが流れてくるためAction<T>を設定する
ReactiveCommandSlim ReactiveCommandの軽量版。理由がなければこれを使う
ReactiveCommandSlim<T> ReactiveCommand<T>の軽量版。理由がなければこれを使う
AsyncReactiveCommand コマンドに設定した非同期メソッドが完了するまでExecuteできなくなるコマンド。SubscribeメソッドにはFunc<Task>(またはasync void)を設定する
AsyncReactiveCommand<T> 上記の引数が流れてくる版。Func<T, Task>を設定する
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>を使うということが言えます。
v 9.0で事情が変わりました。基本的にReactivePropertySlim<T>を使用します。ただし、入力検証が必要な場合はValidatableReactiveProperty<T>を使用することになります。 あえてSlimでないほうを使うのは、下記にあるように「別スレッドで発生したPropertyChangedイベントを必ずUIスレッドで処理したいケース」のみとなります。

以下、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にバインドすることはできます。しかし後述の理由によりほとんど多くのケースではValidatableReactiveProperty<string>(v 9.0未満の場合は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>には削ぎ落とされた機能になるため使用できません。画面入力が完全に任意入力可能なことってあまりないと思いますので、バリデーションを実現するためにReactivePropertySlim<T>は使えないことになります。

v 9.0より古いバージョンの場合、TextBoxなどの入力項目にバインドするものはDataAnnotationsを使うためにReactiveProperty<T>を使うことになるでしょう。

v 9.0以降の場合、DataAnnotationsを使わずにValidatableReactiveProperty<T>を使用することでバリデーションの実装が可能です。

ReactiveCommandReactiveCommand<T>の使い分け

表に書いた通り、コマンドに引数があるかどうかで使い分けます。
※ v 9.0以降の場合、基本的にはより軽量で高速なReactiveCommandSlimまたはReactiveCommandSlim<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ボタンクリック時処理
            });
    }
}

ReactiveCommandReactiveCommandSlimの使い分け

ReactivePropertyのケースと同様に、Slim版はCanExecuteChangedイベントの購読がUIスレッドにディスパッチされなくなっています。そのぶんインスタンスを作る時間は70倍、基本的なユースケースでは13倍の速度差があるそうです。コマンドはボタンなどの画面部品にバインドすることが多いので、UIスレッドを2個以上増やして並列に相互作用したい、といったケース2でもなければ全部Slimでよいと思います。

なお、ReactiveCommandSlimAsyncReactiveCommandのように、実行可能状態をほかのCommandと共有できるようになりました。この点からもSlimでないほうを使うケースはほとんどなくなるでしょう。以下のサンプルコードは上記の記事からの引用です。

// CanExecute のソースになる IObservable<bool>
IObservable<bool> someCanExecuteSource = ...;
// これを共有することで CanExecute の状態を共有可能にする
ReactivePropertySlim<bool> sharedStatus = new();

// command1 と command2 で CanExecute の状態が同期される
// AsyncReactiveCommand はコマンド実行中に自動的に CanExecute が false になるので command1 が実行中は sharedStatus 経由で command2 の CanExecute も false になる
AsyncReactiveCommand command1 = someCanExecuteSource.ToAsyncReactiveCommand(sharedStatus)
    .WithSubscribe(async () =>
    {
        await Task.Delay(3000); // 何か時間のかかる処理
    });
ReactiveCommandSlim command2 = someCanExecuteSource.ToReactiveCommandSlim(sharedStatus)
    .WithSubscribe(() =>
    {
        // do something
    });

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(async () => await 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からはできない3。これは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. UIスレッドを増やすのはそもそもアンチパターン

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

40
51
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
51

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?