0.この記事を書いた経緯
ReactivePropertyを使う場面があり、かなりハマったので今後のために備忘録として残します。また当時は考えられなかった代替手段を調査してまとめました。
※本記事のコードは使用可能ですが、使用したことによる損害の責任は負いません。コードが間違っている可能性もあるので注意して使用してください。
1. (基礎)ReactivePropertyとは何か
ReactivePropertyは、.NETのMVVMでよく使われる
リアクティブな(値の変化に応じて自動で)プロパティ実装。
例えば画面(UI)から入力された値が
- 数値かどうか
- 範囲内かどうか
- 他の項目との整合性が取れているか
ReactivePropertyはこのバリデーション(# 補足1)を リアクティブに行えるのが強み。
ReactivePropertyの特徴
- 値の変更をIObservable(# 補足2)として扱える
- UIと双方向バインディングできる
- バリデーションを組み込める(SetValidateNotifyError)
- そして重要なのは、ReactivePropertyは .NET MAUIでもそのまま使えるという点
ReactivePropertyはINotifyDataErrorInfoを実装しており、MAUIのValidationMessageと相性が良い
※ReactivePropertyがMVVMで属する場所はViewModel
ReactivePropertyはUI(View)とModelの間で「状態を保持し、変更を通知し、バリデーションを行う」ための仕組み。
ViewModelの役割
- UIに表示する値を保持する
- UIからの入力を受け取る
- Modelに渡す前に整形・検証する
- 値が変わったらViewに通知する(INotifyPropertyChanged/INotifyDataErrorInfo)
ReactivePropertyはこれらを全部まとめて提供するライブラリ。
3. (実践)ReactivePropertyのバリデーション
var Age = new ReactiveProperty<string>()
.SetValidateNotifyError(value =>
{
if (!int.TryParse(value, out var age)) return "数値を入力してください";
return (0 <= age && age <= 120) ? null : "年齢が不正です";
});
- null → OK
- 文字列 → エラーメッセージ
値が変わるたびに自動で検証される
例①:1つの値だけで完結するバリデーション
OneTerminalは0〜19の数値であること
var OneTerminal = new ReactiveProperty<string>()
.SetValidateNotifyError(str =>
{
if (!int.TryParse(str, out var value))
return "数値を入力してください";
return (0 <= value && value <= 19)
? null
: "0〜19の範囲で入力してください";
});
例②:他の値に依存するバリデーション
TerminalPointはOneTerminalとhogeに依存して変わる
// アンチパターン
var TerminalPointValue = m.ToReactivePropertyAsSynchronized(x => x.TerminalPoint)
.SetValidateNotifyError(tpStr =>
{
// OneTerminal と hoge に依存したチェック
});
問題点
- SetValidateNotifyErrorはそのプロパティ自身が変わったときだけ検証が走る
- OneTerminalやhogeが変わっても再検証されない
- 古いエラー状態がUIに残る
応急処置
ForceValidate()を使った例(# 補足3)
// 他の値が入力されるたときにサブスクライブで
// ForceValidate()を実行
hogeValue.Subscribe(_ => TerminalPointValue.ForceValidate());
OneTerminalValue.Subscribe(_ => TerminalPointValue.ForceValidate());
他の値が変わることで値はチェックされるが非推奨
Subscribe(# 補足5)
デメリット
- 配線漏れが起きやすい
- 過剰に再検証が走る
- テストしづらい
- 保守性が低い
正攻法①:外部トリガーを渡す
var revalidateTrigger = Observable.Merge(
hogeValue.Select(_ => Unit.Default),
OneTerminalValue.Select(_ => Unit.Default)
);
var TerminalPointValue = m.ToReactivePropertyAsSynchronized(x => x.TerminalPoint)
.SetValidateNotifyError(tpStr =>
{
// TerminalPoint の検証ロジック
}, revalidateTrigger);
- hogeが変わっても再検証
- OneTerminalが変わっても再検証
- Select(# 補足4)
正攻法②:CombineLatest で依存込みのエラーストリームを作る
var terminalPoint$ = m.ObserveProperty(x => x.TerminalPoint);
var oneTerminal$ = m.ObserveProperty(x => x.OneTerminal);
var hoge$ = m.ObserveProperty(x => x.hoge);
var terminalPointErrors =
terminalPoint$.CombineLatest(oneTerminal$, hoge$,
(tpStr, otStr, hogeKind) =>
{
// 3つの値を使ったバリデーション
});
var TerminalPointValue = m.ToReactivePropertyAsSynchronized(x => x.TerminalPoint)
.SetValidateNotifyError(terminalPointErrors);
- 依存関係が明確
- テストしやすい
- 配線漏れが起きない
CombineLatest(# 補足6)
4. .NET MAUIでの実務Tips
MAUIのバインディング例
<Entry Text="{Binding OneTerminalValue.Value}" />
<Label Text="{Binding OneTerminal}" TextColor="Red" />
MAUIでのポイント
- ReactivePropertyはINotifyDataErrorInfoを実装
- ValidationMessageと組み合わせてエラー表示が可能
- 購読にはAddTo(Disposable)を付ける
- 初期検証を抑えるにはIgnoreInitialValidationError
5. まとめ
ReactivePropertyのバリデーションは強力だが、依存プロパティの変更では再検証が走らないという落とし穴がある。方法はいろいろあるが、場面に応じて適切なものを使う。
補足1
アプリケーションにおけるバリデーション(Validation) は、入力された値が正しいかどうかをチェックする仕組み。
補足2
IObservableは「値の変化を“流れ(ストリーム)”として扱うためのインターフェース」で、ReactivePropertyやRx(Reactive Extensions)の中心にある概念。
MVVMのViewModelの中で「値が変わったら自動で何かしたい」というときに使われる。
IObservable の正体
IObservableは“イベントの連続”を表す型
- 値が変わる
- ボタンが押される
- ネットワークからデータが届く
などの「時間とともに発生する出来事」を1本のストリームとして扱える。
例えるなら
- IEnumerable:過去のデータを順番に読む
- IObservable:未来に起きるデータを順番に受け取る
ReactivePropertyは内部でIObservableを使っているから、値が変わるたびに自動で通知が飛ぶ。
IObservableの3つの重要ポイント
1.値が“Push”される
- IEnumerableはpull(自分で取りに行く)
- IObservableはpush(向こうから届く)
2.Subscribeして初めて動く
observable.Subscribe(x => Console.WriteLine(x));
このsubscribeが「イベントを受け取る契約」。
- 組み合わせができる
- CombineLatest
- Merge
- Select
- Where
- Throttle
- DistinctUntilChanged
などを使って、複数のIObservableを合成して新しいストリームを作れる。
ReactivePropertyとIObservableの関係
ReactivePropertyは次の2つの顔を持つ
- 値を保持するプロパティ(Value)
- 値の変化を流すIObservable(AsObservable())
つまり ReactivePropertyは
「プロパティ」+「IObservable」のハイブリッド。
var rp = new ReactiveProperty<int>();
rp.Subscribe(x => Console.WriteLine($"変わった: {x}"));
rpの値が変わるたびに通知される。
MAUI での IObservable の使いどころ
MAUIのViewModelでは、次のような場面でIObservableが役立つ。
- 入力値の変化に応じて自動でバリデーション
- 複数の入力値から計算した結果を自動更新
- ボタン連打をthrottleして1回に抑える
- API呼び出しの状態管理(Loading → Success → Error)
ReactivePropertyを使うと、これらが自然に書ける。
補足3
ForceValidate()はReactivePropertyが内部に持っている「現在の値をもう一度バリデーションにかけ直す」ための強制再検証メソッド
ForceValidate()が実際にやっていること
ReactivePropertyの内部では、次の2つの処理が行われる。
1. 現在の値をバリデーション関数に渡して再評価する
SetValidateNotifyErrorに渡した関数がもう一度実行される。
SetValidateNotifyError(x => x == "" ? "必須です" : null);
ForceValidate()を呼ぶと、この関数が再度呼ばれる。
2. エラー状態(INotifyDataErrorInfo)を更新する
- エラーメッセージが変わればErrorsChangedが発火
- UI(MAUI の ValidationMessage など)が更新される
つまり、UIに最新のエラー状態を反映させるための強制更新。
なぜForceValidate() が必要になるのか
ReactivePropertyのバリデーションはそのプロパティ自身が変わったときだけ自動で走る。
だから、次のようなケースで問題が起きる。
- TerminalPointのバリデーションがOneTerminalに依存している
でもOneTerminalが変わってもTerminalPointの検証は走らない
- UIに古いエラーが残る
- このとき、OneTerminalの変更を購読して ForceValidate()を呼ぶと、
依存プロパティの変更でも再検証が走るようになる。
ForceValidate()の典型的な使い方(応急処置)
OneTerminal.Subscribe(_ => TerminalPoint.ForceValidate());
hoge.Subscribe(_ => TerminalPoint.ForceValidate());
これで依存プロパティが変わるたびに再検証される。
ForceValidate()の問題点(なぜ正攻法ではないか)
依存が増えるたびに購読を追加する必要がある
- 配線漏れが起きやすい
- 過剰に再検証が走る
- テストしづらい
- ViewModelが肥大化する
だからReactivePropertyの作者も、外部トリガーを渡すオーバーロードやCombineLatestを推奨している。
ForceValidate()を使うべき場面
- とりあえず動かしたいとき
- 依存が少なく、すぐに捨てるコード
- プロトタイプや検証用コード
外部トリガー版SetValidateNotifyErrorが正攻法。
補足4
Selectはストリームの「変換」を行う
var number = new ReactiveProperty<int>(1);
var doubled = number.Select(x => x * 2);
doubledは「numberの値×2のストリーム」。
- バリデーションのトリガーに変換する(Unit.Default にする)
- 文字列 → 数値に変換する
- モデル → ViewModel の変換
補足5
SubscribeはIObservableを観察して反応するためのメソッド。
- ストリームに「リスナー」を登録する
- 値が流れてくるたびに処理を実行する
number.Subscribe(x => Console.WriteLine($"変わった: {x}"));
これを呼ばないとIObservableは動かない(遅延実行)
補足6
CombineLatestは「複数のIObservable(ストリーム)をまとめて、最新の値セットが揃ったときに新しい値を流す」 ための演算子
CombineLatest の基本動作
複数のストリームを監視し、どれか1つが変わるたびに、全ストリームの最新値をまとめて通知する。
CombineLatest が何を解決するのか
- 依存バリデーションの問題は、TerminalPointの検証が OneTerminalとhogeに依存している
- でもSetValidateNotifyErrorはTerminalPointが変わらないと動かない
というギャップ。
CombineLatestを使うと、
- OneTerminalが変わった
- hogeが変わった
- TerminalPointが変わった
どれでも 最新の3つの値をまとめて検証できる。
var terminalPoint$ = m.ObserveProperty(x => x.TerminalPoint);
var oneTerminal$ = m.ObserveProperty(x => x.OneTerminal);
var hoge$ = m.ObserveProperty(x => x.hoge);
var terminalPointErrors =
terminalPoint$.CombineLatest(oneTerminal$, hoge$,
(tpStr, otStr, hogeKind) =>
{
// 3つの値を使ったバリデーション
if (!int.TryParse(tpStr, out var tp)) return "数値を入力してください";
if (hogeKind == Hoge.A)
{
return tp <= int.Parse(otStr) ? null : "OneTerminal 以下で入力してください";
}
if (hogeKind == Hoge.B)
{
return tp % 2 == 0 ? null : "偶数で入力してください";
}
return null;
});
var TerminalPointValue = m.ToReactivePropertyAsSynchronized(x => x.TerminalPoint)
.SetValidateNotifyError(terminalPointErrors);
6. 出典
ReactiveProperty 公式 GitHub
Reactive Extensions (Rx)
.NET MAUI 公式ドキュメント
ObservableExtensions クラス(Subscribeの説明)