1. WLAN Profile Viewer
先に簡単にアプリの紹介を。無線LANプロファイルを管理するためのWindowsデスクトップアプリです。ストレートにWLAN Profile Viewerという名前にしました。
Windows 8以降、OS標準の無線LANのUIは現在電波の入っている無線LANを入口にしたものになりました。これは初めて接続するときだけ接続先の無線LANを選んで、後は自動的に接続するようOSにお任せにしてしまう前提であればシンプルでいいですが、ユーザーが自分でコントロールしようとすると必ずしも使い勝手のいいものではないです。今時コーヒーショップで無線LANを見ると20も30も電波が飛んでますし。
このアプリは既に作成された無線LANプロファイルを電波状態を含めて一覧表示し、そこから接続と切断ができます。これにプロファイルの順番の変更(ただしWindows 10では意味なし)と削除も併せて最低限一通りの管理ができるので、OS標準のUIよりは多少使い勝手がいいと思います。
正直いえばReactivePropertyの練習用に作っていたものですが、意外と実用性がありそうなのでアプリに仕立てました。詳しくはレポジトリで。
2. ReactivePropertyのプロパティ
ReactiveProperty自体については開発者の@neueccさん、@xin9leさん、@okazukiさんがたくさん書かれてますので、そちらを読んでいただければいいのですが、「そもそもReactivePropertyのプロパティって何?」という点について、「そこからか?」と言われそうですが、自分は何だろうと思ったので簡単に書きます。
これは最初に@neueccさんが書かれてます。正確にはそちらを。
ざっくりと自分のイメージでいえば以下のような感じです。
まずプロパティはデータを入れておく入れ物といっていいと思いますが、そのためには内部に値を保持していて、それに随時アクセスできる必要があります。一方、IObservableのチェーンではイベントなどで値が流れていくわけですが、常に値があるとは限らないので、そのままではデータの入れ物にはなりません。
そこで内部のlatestValueフィールドに値を常に保持するようにし、それにValueプロパティを通してアクセスできるようにしたのがReactiveProperty、のベースなのだと思います。したがって、プロパティを利用する立場からは、相手は一義的にはValueプロパティで、latestValueフィールドはそのバッキングストア、それらを提供するコンテナがReactivePropertyと見なすことができ、それをIObservableのチェーンに挟み込むことで(発端でも終端でも構いませんが)、両者をうまく融合させたと。
というわけで、ReactiveProperty自体はValueプロパティのコンテナなので、基本的にReadonlyなプロパティとして生成すればいいわけです。なお、ReadOnlyReactivePropertyはそのValueプロパティがReadonlyという意味で、Readonlyの対象が違うのですが、初めは混同してました。
3. ObserveElementXXXXX
ObserveElementXXXXXはコレクション要素のプロパティ変化をIObservableにする拡張メソッド群です。自分がコレクション要素のプロパティ変化をReactivePropertyで捉えるにはどうすればいいかと書いてたら、@xin9leさんと@okazukiさんがあれよあれよという間に作り上げて、すげーと思いました。
この使用例として、要素になるものとして以下のMemberViewModelがあるとします。この中のIsLongとIsSelectedはModelと同期、あるいはViewにバインドされて随時変わるものと考えてください。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class MemberViewModel : BindableBase | |
{ | |
public string Name { get; } | |
public bool IsLong | |
{ | |
get { return _isLong; } | |
set { SetProperty(ref _isLong, value); } | |
} | |
private bool _isLong; | |
public ReactiveProperty<bool> IsSelected { get; } | |
public MemberViewModel(string name, bool isSelected = false) | |
{ | |
this.Name = name; | |
IsSelected = new ReactiveProperty<bool>(isSelected); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class MainWindowViewModel : BindableBase | |
{ | |
public ObservableCollection<MemberViewModel> Members { get; } = new ObservableCollection<MemberViewModel>(); | |
public MainWindowViewModel() | |
{ | |
// Observe CLR property of MemberViewModel. | |
Members | |
.ObserveElementProperty(x => x.IsLong) // Select CLR property to be observed. | |
.Where(x => x.Value) // Filter out false case. | |
.Subscribe(x => ShowName(x.Instance)); | |
// Observe ReactiveProperty of MemberViewModel. | |
Members | |
.ObserveElementObservableProperty(x => x.IsSelected) // Select ReactiveProperty to be observed. | |
.Where(x => x.Value) // Filter out false case. | |
.Subscribe(x => ShowName(x.Instance)); | |
} | |
private void ShowName(MemberViewModel member) | |
{ | |
Debug.WriteLine($"{member.Name} is changed."); | |
} | |
} |
コレクション要素の変化を追うのは割と面倒ですが、それがこれだけでできます。なお、同じ結果は、適当なタイミングでコレクションをループして走査することでも得られますが、そうすると当然ループのコストがかかります。
また、プロパティ変化をある条件でフィルターした要素を集計したいときは、FilteredReadOnlyObservableCollectionが利用できます。この例は以下のようなものです。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class MainWindowViewModel : BindableBase | |
{ | |
public ObservableCollection<MemberViewModel> Members { get; } = new ObservableCollection<MemberViewModel>(); | |
public ReactiveProperty<bool> IsAllLong { get; } | |
public MainWindowViewModel() | |
{ | |
// Observe if all MemberViewModels pass filter delegate for CLR property. | |
IsAllLong = Members | |
.ObserveElementProperty(x => x.IsLong) // Select CLR property to be observed. | |
.Select(_ => Members.All(x => x.IsLong)) // Go through MemberViewModels by filter delegate. | |
.ToReactiveProperty(); | |
// Alternative | |
IFilteredReadOnlyObservableCollection<MemberViewModel> membersNotLong = Members | |
.ToFilteredReadOnlyObservableCollection(x => !x.IsLong); // Provide filter delegate. | |
IsAllLong = membersNotLong | |
.CollectionChangedAsObservable() | |
.Select(_ => Members.Any() && (0 == membersNotLong.Count)) | |
.ToReactiveProperty(); | |
} | |
} |
ただ、これが使えるのは対象の、変化するプロパティがCLRプロパティの場合で、ReactivePropertyを対象とするときは別の方法を考える必要があります。
単純には、ObserveElementObservablePropertyを繋げ、そこから拾い出した要素を外のコレクションに保持し、その要素数を使えばいい気がしますが、これだけだと元のコレクションから要素が削除された場合に追い切れません(ObserveElementObservablePropertyは現在コレクションにある要素を対象とするので)。これを考慮した場合は以下のようなものが考えられます。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class MainWindowViewModel : BindableBase | |
{ | |
public ObservableCollection<MemberViewModel> Members { get; } = new ObservableCollection<MemberViewModel>(); | |
public ReactiveProperty<bool> IsAnySelected { get; } | |
public MainWindowViewModel() | |
{ | |
// Observe if any MemberViewModel passes filter delegate for ReactiveProperty. | |
IsAnySelected = Members | |
.ObserveElementObservableProperty(x => x.IsSelected) // Select ReactiveProperty to be observed. | |
.Select(_ => Members.Any(x => x.IsSelected.Value)) // Go through MemberViewModels by filter delegate. | |
.ToReactiveProperty(); | |
// Alternative | |
List<MemberViewModel> membersSelected = new List<MemberViewModel>(); | |
IObservable<bool> elementPropertyChanged = Members | |
.ObserveElementObservableProperty(x => x.IsSelected) | |
.Do(x => | |
{ | |
if (!x.Value) | |
{ | |
membersSelected.Remove(x.Instance); | |
} | |
else if (!membersSelected.Contains(x.Instance)) | |
{ | |
membersSelected.Add(x.Instance); | |
} | |
}) | |
.Select(_ => 0 < membersSelected.Count); | |
IObservable<bool> collectionChanged = Members | |
.CollectionChangedAsObservable() | |
.Where(x => x.Action != NotifyCollectionChangedAction.Move) | |
.Do(x => | |
{ | |
switch (x.Action) | |
{ | |
case NotifyCollectionChangedAction.Add: | |
case NotifyCollectionChangedAction.Remove: | |
case NotifyCollectionChangedAction.Replace: | |
if (x.OldItems != null) | |
{ | |
foreach (var instance in x.OldItems.Cast<MemberViewModel>()) | |
{ | |
membersSelected.Remove(instance); | |
} | |
} | |
if (x.NewItems != null) | |
{ | |
foreach (var instance in x.NewItems.Cast<MemberViewModel>()) | |
{ | |
if (membersSelected.Contains(instance)) | |
continue; | |
if (newInstance.IsSelected.Value) | |
membersSelected.Add(newInstance); | |
} | |
} | |
break; | |
case NotifyCollectionChangedAction.Reset: | |
membersSelected.Clear(); | |
break; | |
} | |
}) | |
.Select(_ => 0 < membersSelected.Count); | |
IsAnySelected = Observable.Merge(elementPropertyChanged, collectionChanged) | |
.ToReactiveProperty(); | |
} | |
} |
一応これを汎用の拡張メソッドにしてみたものが、他の例と併せてReactivePropertyTestプロジェクトにあるので、興味があれば見てください。ちなみに、NewItemsをチェックしているルートは実際はObserveElementObservablePropertyが先に捉えるので不要ということに後から気づきました。
しかし、変化の頻度が少なく要素数も特に多くなければループさせる方法でも十分なので、実際ここまでやることはないかもしれません。
4. まとめ
このアプリではM-V-VM間を専らRxとReactivePropertyで結ぶようにした結果、その部分のコードがとても少なく、すっきりとなりました。
したがって、Rxをガンガン使うようなアプリであれば、ReactivePropertyも併用すればその威力はさらに増すと思います。Rx自体の学習コストが決して低くない問題はありますが。ReactivePropertyのソースにはRxの高等テクニックが詰まっているので、時々見ると発見があります。
最後に一つ。RxとReactivePropertyを駆使すればアプリ内を融通無碍に繋ぐことができますが、これと非同期が組み合わさると実行コンテキスト(スレッド)がよく分からない状態になることがあります。何か動作が妙……というときは実行コンテキストを確かめてみるのも手です。