先日、こんな質問を頂きました。
@okazuki かずきさん、相談に乗ってください。以下のコードを実行してみたところ、初回起動時においては、TestCommandが実行可能になってしまいます。46行目の★だと、正しく実行不可な状態で立ち上がってきます。何が理由かわかりますか?RPは、6.2.0の最新です。 pic.twitter.com/FO58CfvZXP
— adonis (@adonis31726124) 2020年1月17日
とりあえず値が来てないシーケンスで作ってそうに見えたので回答したのですが、確かに慣れてないと理解に時間がかかるなぁと思ったので、ついでにブログネタにしてしまおうという魂胆です。
単純化してみよう
ということで、ReactiveProperty や ReactiveCommand は IObservable<T>
から ToReactiveProperty や ToReactiveCommand メソッドを使って生成できます。
例えば…
using Reactive.Bindings; using System; using System.Reactive.Linq; namespace ConsoleApp9 { class Program { static void Main(string[] args) { // Subscribe すると false を返す IObservalbe<bool> var seq = Observable.Return(false); // ReactiveProperty と ReactiveCommand を生成 var rp = seq.ToReactiveProperty(); var rc = seq.ToReactiveCommand(); Console.WriteLine(rp.Value); // False Console.WriteLine(rc.CanExecute()); // False } } }
こんな風に false を返すシーケンスから作ると、どちらも false になります。
では、空のシーケンスで試してみましょう。以下のような感じになります。
using Reactive.Bindings; using System; using System.Reactive.Linq; namespace ConsoleApp9 { class Program { static void Main(string[] args) { // Subscribe しても何も返さないシーケンス var seq = Observable.Never<bool>(); // ReactiveProperty と ReactiveCommand を生成 var rp = seq.ToReactiveProperty(); var rc = seq.ToReactiveCommand(); Console.WriteLine(rp.Value); // False Console.WriteLine(rc.CanExecute()); // True } } }
ReactiveProperty のケース
ReactiveProperty はシーケンスから値が発行されていない状態でも、何らかの値は Value プロパティに保持していないといけません。値を返さずに例外を投げる(例えば独自例外で、ReactiveProperty に値がないよ!っていうのを表すものを作って、それをスローするとか)というのも考えられますが、ReactiveProperty の使われ方的に Value にアクセスしたら例外が出るケースがあるというのは使いにくさの割にメリットがないです。
ということで、ReactiveProperty は初期値を持ってます。初期値なんて設定した覚えないんだけど??となるかもしれませんが、ToReactiveProperty 拡張メソッドの定義を見てみると第二引数にあります。
/// <summary> /// <para>Convert to two-way bindable IObservable<T></para> /// <para>PropertyChanged raise on ReactivePropertyScheduler</para> /// </summary> public static ReactiveProperty<T> ToReactiveProperty<T>(this IObservable<T> source, T initialValue = default(T), ReactivePropertyMode mode = ReactivePropertyMode.DistinctUntilChanged | ReactivePropertyMode.RaiseLatestValueOnSubscribe, IEqualityComparer<T> equalityComparer = null) => new ReactiveProperty<T>(source, initialValue, mode, equalityComparer);
T initialValue = default(T)
の部分です。この initialValue が ReactiveProperty のコンストラクターに渡されて、最終的に何も値が来てないときの初期値として設定されます。
そのため、空のシーケンスに対して ToReactiveProperty をすると bool 型の場合は default(bool) の結果である false が初期値として設定されます。結果として Console.WriteLine(rp.Value)
は False と表示されます。
ReactiveCommand のケース
ReactiveCommand は IObservable<bool>
の結果を CanExecute として返します。ReactiveCommand は、空のシーケンスから作られたときの初期値として default(bool) ではなく true を使っています。
これは ReactiveCommand が使われるケースで、普通に new ReactiveCommand()
したときに実行できないコマンドよりも実行可能なコマンドが求められるケースのほうが圧倒的に多いという理由で、CanExecute の戻り値は初期値が true になっています。
ToReactiveCommand の定義を見てみても明示的に initialValue は true になっています。
/// <summary> /// CanExecuteChanged is called from canExecute sequence on UIDispatcherScheduler. /// </summary> public static ReactiveCommand ToReactiveCommand(this IObservable<bool> canExecuteSource, bool initialValue = true) => new ReactiveCommand(canExecuteSource, initialValue);
そのため、空のシーケンスに対して ToReactiveCommand を呼び出すと、実行可能なコマンド(CanExecute の結果が true) が出来上がります。
今回のケースに当てはめてみよう
再現コードを GitHub に上げてもらうようにお願いしたら上げてもらえました!
GitHub - AdonisLeavis/WpfApp1: ReactiveCommandの動きを確認するプロジェクト
該当部分は ViewModels フォルダーの中の MainWindowViewModel クラスになります。
<feff>using Reactive.Bindings; using System.Reactive.Linq; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Reactive.Bindings.Extensions; namespace WpfApp1.ViewModels { public class MainWindowViewModel : Bases.ViewModelBase { public ReactiveProperty<CellViewModel> CurrentCell { get; set; } = new ReactiveProperty<CellViewModel>(); public ReactiveCommand SetCurrentCellCommand { get; set; } = new ReactiveCommand(); public ReactiveCommand ClearCurrentCellCommand { get; set; } = new ReactiveCommand(); public ReactiveCommand TestCommand { get; set; } public MainWindowViewModel() { SetCurrentCellCommand.Subscribe(_ => { var flag = false; if(CurrentCell.Value != null) { flag = !CurrentCell.Value.IsFirstFlag; } var curCell = new CellViewModel() { IsFirstFlag = flag }; CurrentCell.Value = curCell; }); ClearCurrentCellCommand.Subscribe(_ => CurrentCell.Value = null); var testProp = new[] { CurrentCell.Select(a => a!=null), CurrentCell.Where(a => a != null).Select(a => a.IsFirstFlag), // CurrentCell.Select(a => a != null && a.IsFirstFlag), ★ こっちだと正しく動作する }.CombineLatestValuesAreAllTrue().ToReactiveProperty(); testProp.Subscribe(a => System.Console.WriteLine($"来たよ{a}")); TestCommand = new[] { CurrentCell.Select(a => a!=null), CurrentCell.Where(a => a != null).Select(a => a.IsFirstFlag), // CurrentCell.Select(a => a != null && a.IsFirstFlag), ★ こっちだと正しく動作する }.CombineLatestValuesAreAllTrue().ToReactiveCommand(); TestCommand.Subscribe(_ => System.Console.WriteLine(CurrentCell.Value.IsFirstFlag)); } } }
TestCommand が今回の質問の現象が起きているクラスになります。CombineLatestValuesAreAllTrue メソッドを呼び出した結果に対して ToReactiveCommand をしてます。 CombineLatestValuesAreAllTrue 拡張メソッドは、CombineLatest で全てが true かどうかをチェックしているだけのメソッドです。
以下のように定義されています。
/// <summary> /// Lastest values of each sequence are all true. /// </summary> public static IObservable<bool> CombineLatestValuesAreAllTrue( this IEnumerable<IObservable<bool>> sources) => sources.CombineLatest(xs => xs.All(x => x));
もとになっている CombineLatest メソッドとはどんなメソッドかというと、sources
の全ての IObservable<T>
の最後の値に対して、処理をした結果を後続に流す IObservable<T>
を返すメソッドになります。
試しに以下のようなコードを書いてみます。
// Subscrie すると true を返すシーケンス var source1 = Observable.Return(true); var source2 = Observable.Return(true); // CombineLatest で and をとった結果を返すシーケンスにする var result = new[] { source1, source2 }.CombineLatest(xs => xs[0] & xs[1]); // 表示 result.Subscribe(x => Console.WriteLine(x)); // True
CombineLatest で and をとってます。今回は True を返すシーケンスを 2 つ合成しているので True & True
をして True になりますね。どちらかでも Observable.Return(false)
にすると false になります。
時間の経過とともに値を発行していくと…?
固定の値を返す IObservable<T>
の時の動作はわかりました。次は時間の経過とともに値が発行されるもので試してみます。
// bool を発行する IObservable<bool> を 2 つ用意 var source1 = new Subject<bool>(); var source2 = new Subject<bool>(); // CombineLatest で and をとった結果を返すシーケンスにする var result = new[] { source1, source2 }.CombineLatest(xs => xs[0] & xs[1]); // 表示 result.Subscribe(x => Console.WriteLine($"Result = {x}")); // 実際に値を発行していきます Console.WriteLine("source1.OnNext(true)"); source1.OnNext(true); Console.WriteLine("source2.OnNext(true)"); source2.OnNext(true); Console.WriteLine("source1.OnNext(false)"); source1.OnNext(false); Console.WriteLine("source1.OnNext(true)"); source1.OnNext(true);
実行するとどうなるでしょう?Subject 型は OnNext で値を発行しない限り値は発行されません。そのため Subscribe した時点では、まだ何も値が来てないので、Subscribe しただけでは Console.WriteLine は実行されません。コードの公判の OnNext をしているところで source1 で True、source2 で True を発行したタイミングで初めて CombineLatest の結果を Subscribe したところに処理が来ます。後は、source1 に対して false、true と値の発行をしてるので、そのたびに CombineLatest が実行されます。
つまり結果はこうなります。
source1.OnNext(true) source2.OnNext(true) Result = True source1.OnNext(false) Result = False source1.OnNext(true) Result = True
両方の値がそろって初めて CombineLatest の後ろに値が流れます。試しに source1 からだけ値を何回も発行するようにコードを書き換えて…
// bool を発行する IObservable<bool> を 2 つ用意 var source1 = new Subject<bool>(); var source2 = new Subject<bool>(); // CombineLatest で and をとった結果を返すシーケンスにする var result = new[] { source1, source2 }.CombineLatest(xs => xs[0] & xs[1]); // 表示 result.Subscribe(x => Console.WriteLine($"Result = {x}")); // 実際に値を発行していきます Console.WriteLine("source1.OnNext(true)"); source1.OnNext(true); Console.WriteLine("source1.OnNext(false)"); source1.OnNext(false); Console.WriteLine("source1.OnNext(true)"); source1.OnNext(true); Console.WriteLine("source1.OnNext(false)"); source1.OnNext(false); Console.WriteLine("source1.OnNext(true)"); source1.OnNext(true); Console.WriteLine("source1.OnNext(false)"); source1.OnNext(false);
実行しても CombineLatest の結果の Subscribe の処理は実行されません。以下のような実行結果になります
source1.OnNext(true) source1.OnNext(false) source1.OnNext(true) source1.OnNext(false) source1.OnNext(true) source1.OnNext(false)
これを踏まえてコードを見てみよう
TestCommand の生成は以下のように行われています。
TestCommand = new[] { CurrentCell.Select(a => a!=null), CurrentCell.Where(a => a != null).Select(a => a.IsFirstFlag), // CurrentCell.Select(a => a != null && a.IsFirstFlag), ★ こっちだと正しく動作する }.CombineLatestValuesAreAllTrue().ToReactiveCommand();
CurrentCell は ReactiveProperty<CellViewModel>
型になります。この CurrentCell プロパティは初期値が設定されていないのでデフォルトで null が入っています。
一つ目の CurrentCell.Select(a => a != null)
は初期状態だと null が流れてくるので true が返されています。しかし、二つ目のほうは CurrentCell.Where(a => a != null).Select(a => a.IsFirstFlag)
としてるため、初期値の null は Where 句で弾かれてしまい後ろの Select は実行されないので、初期状態では 何も値を発行しないシーケンス になります。
CombineLatest は、すべてのシーケンスの値が出そろわないと何もしないので、初期状態では空の IObservable<bool>
になります。そのため TestCommand の CanExecute メソッドはデフォルト値の true を返します。そのため、初期状態では実行可能なコマンドになっています。
「★ こっちだと正しく動作する」のコメントに書かれている方は、CurrentCell の初期値の null がわたってきたときには false を返します。そのため初期状態では false を返すシーケンスに対して ToReactiveCommand をするので CanExecute メソッドの戻り値は false になります。そのため実行不可能なコマンドが出来上がります。
今回したかったことは?
具体的な仕様はわかりませんが、IsFirstFlag は false > true > false > true > false > ... のように変わっていく値のように見えます。SetCurrentCellCommand で、そのような制御が行われています。
SetCurrentCellCommand.Subscribe(_ => { var flag = false; if(CurrentCell.Value != null) { flag = !CurrentCell.Value.IsFirstFlag; } var curCell = new CellViewModel() { IsFirstFlag = flag }; CurrentCell.Value = curCell; });
このプログラムは、今回の問題調査用に最小限に絞られたプロジェクトなので、全体最適化という観点で見たら違う実装になるかもしれませんが、今回与えれらた情報だけで自分で作るなら MainWindowViewModel クラスは以下のようになるかな…と思いました。まぁ、基本的に★コメントで示された、こっちだと正しく動作するというのと同じです。C# の新しめの書きかた使った方がシンプルかな?って感じ。
using Reactive.Bindings; using System.Reactive.Linq; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Reactive.Bindings.Extensions; using Reactive.Bindings.Notifiers; using System.Diagnostics; namespace WpfApp1.ViewModels { public class MainWindowViewModel : Bases.ViewModelBase { public ReadOnlyReactiveProperty<CellViewModel> CurrentCell { get; } public ReactiveCommand SetCurrentCellCommand { get; } = new ReactiveCommand(); public ReactiveCommand ClearCurrentCellCommand { get; } = new ReactiveCommand(); public ReactiveCommand TestCommand { get; } public MainWindowViewModel() { CurrentCell = Observable.Merge( SetCurrentCellCommand .Select(_ => new CellViewModel { IsFirstFlag = (!CurrentCell.Value?.IsFirstFlag) ?? false }), ClearCurrentCellCommand .Select(_ => default(CellViewModel)) ).ToReadOnlyReactiveProperty(); TestCommand = CurrentCell.Select(x => x?.IsFirstFlag ?? false) .ToReactiveCommand() .WithSubscribe(() => Debug.WriteLine(CurrentCell.Value.IsFirstFlag)); } } }
オリジナルリポジトリーを Fork して書き換えました。
GitHub - runceel/WpfApp1: ReactiveCommandの動きを確認するプロジェクト
コードスメル
とりあえずせっかくソースコードもらったので、思ったことをつらつらと書いておきます。
- 今回の IsFirstFlag は、名前からして最初の要素だということを表してるのかな?と思ったら違ったので名前は違う方がよさそう
- もし、IsFirstFlag が裏の処理にも密接にかかわってくるなら IsFirstFlag の値の制御は、ViewModel よりは Model でやっといたほうがいいのでは…?と思った。もしくは、この画面だけの都合なら、CellViewModel のステートにせずに MainWindowViewModel のステートってだけでもいいのではと思った。
- C# の新しい書きかたは使えるところではガンガン使おう。
まとめ
ということで、今回質問を受けて、確かにわかりにくいなぁと思ったのでまとめました。 それでは楽しいリアクティブプログラミングを!