ReactiveProperty v9.0.0 のプレリリース版を出してみました
リリースしました
NuGet から取得可能です。
リリースしようと思った経緯
2023年1月3日から風邪気味なので今日は仕事はじめだったのですがリモートで必須の打ち合わせに参加して、それ以外は仕事をせずにお休みにしました。基本休み。
柔軟にお仕事出来るのって素晴らしいなぁと思ってました。
そこで、先日しばやん雑記の以下の記事を見て、前々から思っていたのですが「ReactiveProperty っぽいものは欲しいけど Rx の機能ってそんなにフルで使わなくてもいいよなぁ…」と改めて思い「ReactiveProperty にも Rx への依存を排除している ReactiveProperty.Core パッケージがあるけど、あれじゃぁコマンドすらないし、ちょっと物足りないよね」と思いました。
ということで主要クラスの大部分を 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>
を追加 (上述のものです) -
CompositeDisposable
をReactive.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()
のようにして常時実行可能なコマンドも作成可能です。
また、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 の基本のキの Select
と Where
を追加しました。Delay
とかみたいに処理を遅らせるようなものは見栄えはいいのですが、実際には Select
と Where
があれば軽いものであれば大体書けてしまうと思います。
また ReactiveCommand
系を生成するために CombineLatest
で IObservable<bool>
を束ねて ToAsyncReactiveCommand()
や ToReactiveCommandSlim()
をしたくなったので IEnumerable<IObservable<bool>>
に対しての拡張メソッドとして CombineLatest
を追加しています。
とりあえずのやっつけ実装なので本家と比べると遅いし効率もそんなに良くないと思いますが…。もし、それ以上が欲しい場合は追加で System.Reactive を追加するか ReactiveProperty の方のパッケージを追加して使ってくださいというコンセプトです。
まとめ
ということで風邪で仕事を休んで ReactiveProperty のコードを書いていました。体調も割とよくなってきたと感じています。 (薬のせいかも?)
とりあえず思い立ったので実装してみた感じです。何かご意見や感想があれば Twitter で @okazuki にメンションしたり GitHub のリポジトリの Issue にコメントをください。この記事に対するコメントでも大丈夫です。
本当に、とりあえず思い立ったのでやってみたという感じなので取り下げる可能性もあります…。ということで皆さん体調には気を付けて楽しいコーディングをしましょう!
Discussion
reactivepropertyについて質問・要望があります。
記事、いつも参考にさせていただいております。
さて、reactivepropertyを使うにあたって、3つ困っていることがあります。
kazuki Otaさんのほうでも現象が確認できたならば、バージョンアップなどの念頭に入れていただければ幸いです。
編集可能なcomboBoxとバインドした
reactivepropertyについて、
ToReactivePropertyAsSynchronized関数を使って、
ViewからModelへToUpperなど整形しながら同期させた時、
Modelは整形されるが、
Viewが更新されない。
(・TextBoxとのバインドでは、viewも更新される。
・reactivepropertyでModelとViewを橋渡ししている間に0秒のDelayを入れると、viewも更新される。)
編集可能なcomboBoxとバインドしたreactivepropertyについて
ToReactivePropertyAsSynchronized関数で、
ViewからModelへToUpper関数で整形しながら同期させた時、
Viewに「s」を代入すると、
Viewに「system.controls.separator」が表示される。
SetValidateAttribute関数では、
自身のプロパティにセットする使い方が
多いと思うが、プロパティの数が多くなると人的ミスが起きやすい部分だと思っています。
SetValidateAttribute(()=>self)や
SetValidateAttribute()のように
予測してくれる機能は難しいでしょうか。
◽️動作環境
.Net Framework 4.8
ReactiveProperty 8.1.2
@robustPatch さん
フィードバックありがとうございます。それぞれについて回答を記載しますね。
ComboBox の Binding を
<ComboBox IsEditable="True" Text="{Binding Name1.Value, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
のようにUpdateSourceTrigger
を指定するとどうでしょうか?お手数をおかけしますが、簡単にはこちらで再現できなかったので再現プロジェクトを提供いただけませんか?
GitHub から clone してソリューション開いて実行すれば動作確認できる形が有難いです。
こちらは残念ながら、どのプロパティの属性を取得すればいいのかを指定する何らかの方法が必要なのでご要望の機能は実現できないと思います。実現可能な方法があればご提案いただければ実装にチャレンジすることは出来ます。
ご回答、ありがとうございます。
ご提案のプロパティにしたところ、同期されました。
ご提案のプロパティからUpdateSourceTrigger=PropertyChangedにすると同期されないのですが、
仕様でしょうか。
kazukiさんの報告を受けて、
新規プロジェクトで、作成したところ私の方でも不具合は再現しませんでした。
そのため、他に使用しているライブラリの方を疑おうと思います。
Textプロパティを扱っているのがreactivepropertyのみだったので思い込んでいましたが、確認不足で、
お手数をおかけいたしまして、申し訳ございません。
(system.controls.separatorではなく、
system.Windows.controls.separatorでした。)
質問先が違いそうなので、私の方でデバッグします。
確かに難しそうですね。
納得しました。
恐らく ComboBox の仕様だと思います。以下のような普通のオブジェクトでも同様の動きをします。
このように FirstName, LastName プロパティで設定時に ToUpper しているオブジェクトを用意して
このように TextBox と ComboBox にバインドしても同様に動くので WPF の ComboBox がそのような実装になっているということだと思います。
ComboBox の仕様の可能性があるのですね。
reactivePropertyと直接関係ないにも関わらず、
即座のご回答、ありがとうございます。