🐈

ReactiveProperty v9.0.0 のプレリリース版を出してみました

2023/01/05に公開
5

リリースしました

NuGet から取得可能です。

https://www.nuget.org/packages/ReactiveProperty/9.0.0-pre202301050852

https://www.nuget.org/packages/ReactiveProperty.Core/9.0.0-pre202301050852

リリースしようと思った経緯

2023年1月3日から風邪気味なので今日は仕事はじめだったのですがリモートで必須の打ち合わせに参加して、それ以外は仕事をせずにお休みにしました。基本休み。
柔軟にお仕事出来るのって素晴らしいなぁと思ってました。

そこで、先日しばやん雑記の以下の記事を見て、前々から思っていたのですが「ReactiveProperty っぽいものは欲しいけど Rx の機能ってそんなにフルで使わなくてもいいよなぁ…」と改めて思い「ReactiveProperty にも Rx への依存を排除している ReactiveProperty.Core パッケージがあるけど、あれじゃぁコマンドすらないし、ちょっと物足りないよね」と思いました。

https://blog.shibayan.jp/entry/20230103/1672719218

ということで主要クラスの大部分を ReactiveProperty パッケージから ReactiveProperty.Core へ引っ越しをさせてみました。その過程で ReactiveProperty の最初のバージョンから大きく実装が変わっていなかった ReactiveCommand<T> クラスを ReactivePropertySlim<T> と同じような要領で neuecc さんの実装を参考にしつつ ReactiveCommandSlim<T> を足しました。

そしてベンチーマークを取ってみたところ、ただたんに new するだけの状態においては 1/50 くらいの時間になりました。コマンドは、ただ new するだけで終わることはないので実際に Execute の処理を足したりして呼び出すような流れでも 1/10 くらいの速度で終わるようになりました。圧倒的!!!

Method Mean Error StdDev Median
CreateReactiveCommand 262.654 ns 5.7692 ns 17.0105 ns 254.709 ns
CreateReactiveCommandSlim 5.223 ns 0.1473 ns 0.1150 ns 5.192 ns
BasicUsecaseForReactiveCommand 959.380 ns 18.7741 ns 31.3673 ns 951.834 ns
BasicUsecaseForReactiveCommandSlim 86.579 ns 1.7783 ns 4.3621 ns 84.436 ns

これに気をよくして、以下のような変更をしています。コンセプトは System.Reactive パッケージを追加しなくても基本的なユースケースに耐えられるように ReactiveProperty.Core パッケージを強化するといった形になります。

実際に行った変更

ReactiveProperty から ReactiveProperty.Core へ移動させたクラスやメソッド

  • AsyncReactiveCommand<T> を移動
  • ObserveProperty 拡張メソッドを移動
  • ToReactivePropertySlimAsSynchronized 拡張メソッドを移動
  • Notifier 系クラスの BooleanNotifier, BusyNotifier, CountNotifier, MessageBroker, AsyncMessageBroker を移動

ReactiveProperty.Core へ追加したクラスやメソッド

  • ReactiveCommandSlim<T> を追加 (上述のものです)
  • CompositeDisposableReactive.Bindings.Disposables 名前空間 (新設) に追加
  • Reactive.Bindings.TinyLinq 名前空間を追加して以下の IObservable<T> などへの拡張メソッドを追加
    • Select, Where, CombineLatest (CombineLatest だけ IEnumerable<IObservable<T>> への拡張メソッド)
  • ValidatableReactiveProperty<T> を追加

追加された機能についてピックアップ

いくつか追加した機能について説明しようと思います。

ReactiveCommandSlim<T>

基本的には ReactiveCommand<T> と同じになります。
大きな違いは ReactiveCommand<T>CanExecuteChanged イベントの発行を、自動的に UI スレッド (厳密には指定された Scheduler 上) で実行を行いますが、ReactiveCommandSlim<T> ではその機能を削除しました。UI スレッド上でイベントが発行されるようにするのは利用者側の責任で行うようにしました。
といっても、従来も多くのケースでは基本的に UI スレッド上で実行されていたと思います。レアケースに対応するために結構多大なコストを払っていたと思うのと、万が一 UI スレッドが複数個あるプラットフォーム上で利用する場合には、この自動ディスパッチ機能が仇になってクラッシュしてしまう恐れがあるため利用者側の責任で CanExecuteChanged を実行するスレッドを管理するようにして ReactiveCommandSlim<T> からは削除しました。

利用例

IObservable<bool> に対して ToReactiveCommandSlim()/ToReactiveCommandSlim<T>() を呼び出すことで作成できます。また、単純に new ReactiveCommandSlim() のようにして常時実行可能なコマンドも作成可能です。

https://github.com/runceel/ReactiveProperty/blob/main/Samples/ReactivePropertyCoreSample.WPF/ViewModels/ReactiveCommandViewModel.cs

また、AsyncReactiveCommand<T> にあった IReactiveProperty<bool> を共有することで実行可否のステータスをコマンド間で共有する機能も追加しています。以下のようなコードで実行可否の共有が可能なコマンドが作成できます。

// 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
    });

純粋に new ReactiveCommandSlim(sharedStatus); のようにコンストラクタで渡すことも出来ます。実際に動作している画面は以下のようになります。 A command ボタンと B command ボタンは AsyncReactiveCommand に紐づいていて C command ボタンは ReactiveCommandSlim と紐づいています。A command のボタンの処理の実行中は C command も非活性になっていることが確認できます。

ValidatableReactiveProperty<T>

こちらは、バリデーション機能を持った IReactiveProperty<T> の実装です。今までバリデーション機能が使いたければ ReactiveProperty<T> を使って、そうじゃない場合は ReactivePropertySlim<T> を使いましょうという感じの使い分けでしたが、ReactiveProperty<T>ReactivePropertySlim<T> の間には超えられない壁くらいの性能差があります。

具体的には純粋に new をしただけのケースで約 1/14 になりますし、以下のように new した後にちょっとしたコードを足すとおよそ 1/40(!?) くらいになるといった感じです。

// ReactiveProperty のケース
var rp = new ReactiveProperty<string>();
var rrp = rp.ToReadOnlyReactiveProperty();

rp.Value = "xxxx";
rp.Dispose();

// ReactivePropertySlim のケース
var rp = new ReactivePropertySlim<string>();
var rrp = rp.ToReadOnlyReactivePropertySlim();

rp.Value = "xxxx";
rp.Dispose();
Method Mean Error StdDev Median
CreateReactivePropertyInstance 71.495 ns 2.0367 ns 5.9734 ns 71.710 ns
CreateReactivePropertySlimInstance 5.869 ns 0.1379 ns 0.2940 ns 5.745 ns
BasicForReactiveProperty 1,914.923 ns 36.0733 ns 64.1203 ns 1,910.775 ns
BasicForReactivePropertySlim 51.584 ns 1.0703 ns 2.6653 ns 50.972 ns

なので、性能的にもう少しマシな ValidatableReactiveProperty<T> を実装しました。単純な値のチェックを行うロジックをつかって検証機能付きの ReactiveProperty<T>ValidatableReactiveProperty<T> を作って使うだけの以下のようなコードでベンチマークをとってみました。

// ReactiveProperty
var rp = new ReactiveProperty<string>("")
    .SetValidateNotifyError(x => string.IsNullOrEmpty(x) ? "invalid" : null);
rp.Value = "xxx"; // valid
rp.Value = ""; // invalid

// ValidatableReactiveProperty
var rp = ValidatableReactiveProperty.CreateFromValidationLogic(
    "",
    x => string.IsNullOrEmpty(x) ? "invalid" : null);
rp.Value = "xxx"; // valid
rp.Value = ""; // invalid

約 1/6 で終わるようになっています。

Method Mean Error StdDev Median
ReactivePropertyValidation 3,873.426 ns 75.5446 ns 115.3646 ns 3,819.379 ns
ValidatableReactivePropertyValidation 586.432 ns 11.6188 ns 23.4706 ns 574.883 ns

もう少し現実的にありそうな普通のクラスのプロパティを元に ReactiveProperty を作って画面入力用にバリデーションを追加するようなコードでもベンチマークをとってみました。以下のような 2 つのクラスを用意して

// ただの Name を持っただけの Person クラス
class Person : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private void SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
        {
            return;
        }

        field = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private string _name;
    public string Name
    {
        get { return _name; }
        set { SetProperty(ref _name, value); }
    }
}

// ReactiveProperty で実装
public class ReactivePropertyVM : IDisposable
{
    private Person _model = new();

    [Required]
    public ReactiveProperty<string> Name { get; }

    public ReactivePropertyVM()
    {
        Name = _model.ToReactivePropertyAsSynchronized(x => x.Name,
            ignoreValidationErrorValue: true)
            .SetValidateAttribute(() => Name);
    }

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

// ValidatableReactiveProperty で実装
public class ValidatableReactivePropertyVM : IDisposable
{
    private Person _model = new();

    [Required]
    public ValidatableReactiveProperty<string> Name { get; }

    public ValidatableReactivePropertyVM()
    {
        Name = _model.ToReactivePropertySlimAsSynchronized(x => x.Name)
            .ToValidatableReactiveProperty(() => Name, disposeSource: true);
    }

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

ベンチマークのコードを以下のように書きました。

[Benchmark]
public ReactivePropertyVM ReactivePropertyValidationFromPoco()
{
    var vm = new ReactivePropertyVM();
    vm.Name.Value = "valid";
    vm.Name.Value = "";
    vm.Dispose();
    return vm;
}

[Benchmark]
public ValidatableReactivePropertyVM ValidatableReactivePropertyValidationFromPoco()
{
    var vm = new ValidatableReactivePropertyVM();
    vm.Name.Value = "valid";
    vm.Name.Value = "";
    vm.Dispose();
    return vm;
}

結果は以下のようになりました。これでも倍以上の差があります。

Method Mean Error StdDev Median
ReactivePropertyValidationFromPoco 9,787.012 ns 194.8088 ns 470.4852 ns 9,709.158 ns
ValidatableReactivePropertyValidationFromPoco 4,151.960 ns 111.6744 ns 329.2744 ns 4,080.801 ns

System.Reactive を追加しなくても本当に必要最低限のことが出来るようにするための機能

いくつかクラスを追加した後にサンプルを書こうと思ったら、やっぱり LINQ が恋しくなりました。あと CompositeDisposable が無いと話にならないと思ったので本当に必要最低限だけ追加しました。

Reactive.Bindings.Disposables.CompositeDisposable を追加しています。IObservable<T> の拡張メソッドとして LINQ の基本のキの SelectWhere を追加しました。Delay とかみたいに処理を遅らせるようなものは見栄えはいいのですが、実際には SelectWhere があれば軽いものであれば大体書けてしまうと思います。

また ReactiveCommand 系を生成するために CombineLatestIObservable<bool> を束ねて ToAsyncReactiveCommand()ToReactiveCommandSlim() をしたくなったので IEnumerable<IObservable<bool>> に対しての拡張メソッドとして CombineLatest を追加しています。

とりあえずのやっつけ実装なので本家と比べると遅いし効率もそんなに良くないと思いますが…。もし、それ以上が欲しい場合は追加で System.Reactive を追加するか ReactiveProperty の方のパッケージを追加して使ってくださいというコンセプトです。

まとめ

ということで風邪で仕事を休んで ReactiveProperty のコードを書いていました。体調も割とよくなってきたと感じています。 (薬のせいかも?)

とりあえず思い立ったので実装してみた感じです。何かご意見や感想があれば Twitter で @okazuki にメンションしたり GitHub のリポジトリの Issue にコメントをください。この記事に対するコメントでも大丈夫です。

本当に、とりあえず思い立ったのでやってみたという感じなので取り下げる可能性もあります…。ということで皆さん体調には気を付けて楽しいコーディングをしましょう!

Microsoft (有志)

Discussion

robustPatchrobustPatch

reactivepropertyについて質問・要望があります。

記事、いつも参考にさせていただいております。
さて、reactivepropertyを使うにあたって、3つ困っていることがあります。
kazuki Otaさんのほうでも現象が確認できたならば、バージョンアップなどの念頭に入れていただければ幸いです。

  1. comboBoxとバインドするとV, Mが同期しない。

編集可能なcomboBoxとバインドした
reactivepropertyについて、
ToReactivePropertyAsSynchronized関数を使って、
ViewからModelへToUpperなど整形しながら同期させた時、
Modelは整形されるが、
Viewが更新されない。
(・TextBoxとのバインドでは、viewも更新される。
・reactivepropertyでModelとViewを橋渡ししている間に0秒のDelayを入れると、viewも更新される。)

  1. ReactivePropertyで、「s」をToUpperすると不具合。

編集可能なcomboBoxとバインドしたreactivepropertyについて
ToReactivePropertyAsSynchronized関数で、
ViewからModelへToUpper関数で整形しながら同期させた時、
Viewに「s」を代入すると、
Viewに「system.controls.separator」が表示される。

  1. 要望: SetValidateAttribute関数の既定値

SetValidateAttribute関数では、
自身のプロパティにセットする使い方が
多いと思うが、プロパティの数が多くなると人的ミスが起きやすい部分だと思っています。
SetValidateAttribute(()=>self)や
SetValidateAttribute()のように
予測してくれる機能は難しいでしょうか。

◽️動作環境
.Net Framework 4.8
ReactiveProperty 8.1.2

Kazuki OtaKazuki Ota

@robustPatch さん
フィードバックありがとうございます。それぞれについて回答を記載しますね。

  1. comboBoxとバインドするとV, Mが同期しない。

ComboBox の Binding を <ComboBox IsEditable="True" Text="{Binding Name1.Value, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" /> のように UpdateSourceTrigger を指定するとどうでしょうか?

  1. ReactivePropertyで、「s」をToUpperすると不具合。

お手数をおかけしますが、簡単にはこちらで再現できなかったので再現プロジェクトを提供いただけませんか?
GitHub から clone してソリューション開いて実行すれば動作確認できる形が有難いです。

  1. 要望: SetValidateAttribute関数の既定値

こちらは残念ながら、どのプロパティの属性を取得すればいいのかを指定する何らかの方法が必要なのでご要望の機能は実現できないと思います。実現可能な方法があればご提案いただければ実装にチャレンジすることは出来ます。

robustPatchrobustPatch

ご回答、ありがとうございます。

  1. comboBoxとバインドするとV, Mが同期しない。

ご提案のプロパティにしたところ、同期されました。
ご提案のプロパティからUpdateSourceTrigger=PropertyChangedにすると同期されないのですが、
仕様でしょうか。

  1. ReactivePropertyで、「s」をToUpperすると不具合。

kazukiさんの報告を受けて、
新規プロジェクトで、作成したところ私の方でも不具合は再現しませんでした。
そのため、他に使用しているライブラリの方を疑おうと思います。

Textプロパティを扱っているのがreactivepropertyのみだったので思い込んでいましたが、確認不足で、
お手数をおかけいたしまして、申し訳ございません。
(system.controls.separatorではなく、
system.Windows.controls.separatorでした。)

GitHub から clone してソリューション開いて実行すれば〜

質問先が違いそうなので、私の方でデバッグします。

  1. 要望: SetValidateAttribute関数の既定値

確かに難しそうですね。
納得しました。

Kazuki OtaKazuki Ota
  1. comboBoxとバインドするとV, Mが同期しない。
    ご提案のプロパティにしたところ、同期されました。
    ご提案のプロパティからUpdateSourceTrigger=PropertyChangedにすると同期されないのですが、仕様でしょうか。

恐らく ComboBox の仕様だと思います。以下のような普通のオブジェクトでも同様の動きをします。

using System.ComponentModel;

namespace WpfApp5;
internal class Data : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    private string? _firstName;
    public string? FirstName
    {
        get => _firstName;
        set
        {
            _firstName = value?.ToUpperInvariant();
        }
    }

    private string? _lastName;
    public string? LastName
    {
        get => _lastName;
        set
        {
            _lastName = value?.ToUpperInvariant();
        }
    }
}

このように FirstName, LastName プロパティで設定時に ToUpper しているオブジェクトを用意して

<Window
    x:Class="WpfApp5.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:WpfApp5"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="800"
    Height="450"
    mc:Ignorable="d">
    <Window.DataContext>
        <local:Data />
    </Window.DataContext>
    <StackPanel>
        <TextBox Text="{Binding FirstName, UpdateSourceTrigger=PropertyChanged}" />
        <ComboBox IsEditable="True" Text="{Binding LastName, UpdateSourceTrigger=PropertyChanged}" />
    </StackPanel>
</Window>

このように TextBox と ComboBox にバインドしても同様に動くので WPF の ComboBox がそのような実装になっているということだと思います。

robustPatchrobustPatch

ComboBox の仕様の可能性があるのですね。
reactivePropertyと直接関係ないにも関わらず、
即座のご回答、ありがとうございます。