破綻するMVVMを、流麗な「リアクティブ・ストリーム」へ。VB.NETによる極限のWPF設計論
「なぜ、このコードは動いているのに、触るのが怖いのか?」
もし君が、複雑な業務アプリケーションの構築において、`PropertyChanged` の連鎖(イベントのスパゲッティ)や、非同期処理(Async/Await)の戻り値がUIスレッドを破壊する恐怖に直面しているなら、君の設計はすでに限界を迎えている。
従来のMVVMパターンは、状態の変化を「命令的(Imperative)」に伝播させる。プロパティAが変わったら、プロパティBを更新し、その結果を見てプロパティCのバリデーションを行う……。この「手続き的な連鎖」こそが、バグの温床であり、保守性を著しく低下させる元凶だ。
本稿では、VB.NETを用いたWPF開発において、ReactiveProperty (RP) を核とした「宣言的(Declarative)」な設計手法を伝授する。状態を「値」ではなく「ストリーム(流れ)」として捉え、非同期イベントとUIを完璧に同期させる、プロフェッショナルのための設計思想を書き記そう。
1. 命令的設計から宣言的設計へのパラダイムシフト
多くの開発者が陥る罠は、「状態の変化を、いつ、誰が、どうやって通知するか」をすべてコードで記述しようとすることだ。
従来の「脆弱な」設計(命令型)
' 典型的な、破綻しやすい実装例
Public Sub UpdateSearch()
If String.IsNullOrEmpty(SearchText) Then
IsSearchEnabled = False
Return
End If
IsLoading = True
' 非同期処理の開始。ここでUIスレッドの制御を誤るとフリーズする
Task.Run(Async Function()
Dim results = Await _dataService.FetchAsync(SearchText)
' ここでUIスレッドに戻る処理を忘れると、例外で死ぬ
Me.SearchResults = results
Me.IsLoading = False
End Function)
End Sub
この書き方では、入力のタイミング、非同期処理の完了順序、エラーハンドリングが絡み合い、次第に制御不能なスパゲッティへと変貌する。
ReactivePropertyによる「堅牢な」設計(宣言型)
ReactivePropertyを使えば、「SearchTextが変化したら、それをトリガーに、一定時間待機(Throttle)し、非同期で検索を実行し、結果を反映する」という一連の流れを、一本のパイプラインとして記述できる。
2. プロダクション・レディな実装例:非同期検索エンジン
ここでは、実務で即戦力となる「入力バリデーション」「入力遅延(Debounce)」「非同期DB連携」「スレッド制御」を統合したViewModelの極致を示す。
プロジェクトへの準備
NuGetから `ReactiveProperty` および `Microsoft.Reactive.Testing` をインストールしておくこと。
実装:MainViewModel.vb
Imports System.Reactive.Linq
Imports System.Reactive.Disposables
Imports Reactive.Bindings
Imports Reactive.Bindings.Extensions
''' <summary>
''' 非同期ストリームを制御する、極限のViewModel
''' </summary>
Public Class MainViewModel
Implements IDisposable
' --- サービス層(本来はDIで注入すべき) ---
Private ReadOnly _dataService As IDataService = New MockDataService()
' --- Reactive Properties (状態) ---
' ユーザーの入力値。バリデーション(空文字不可)を組み込む
Public ReadOnly SearchText As ReactiveProperty(Of String)
' 検索結果のリスト。読み取り専用として公開するのが設計の定石
Public ReadOnly SearchResults As ReadOnlyReactiveProperty(Of List(Of String))
' ローディング状態
Public ReadOnly IsLoading As ReadOnlyReactiveProperty(Of Boolean)
' 検索実行ボタンの活性・非活性
Public ReadOnly CanExecuteSearch As ReadOnlyReactiveProperty(Of Boolean)
' --- Reactive Commands (アクション) ---
Public ReadOnly SearchCommand As ReactiveCommand(Of Unit)
' メモリリークを防ぐための破棄用コンテナ
Private ReadOnly _disposables As New CompositeDisposable()
Public Sub New()
' 1. SearchTextの初期化とバリデーション設定
SearchText = New ReactiveProperty(Of String)"" _
.AddTo(_disposables)
' 2. ローディング状態の初期化
IsLoading = New ReactiveProperty(Of Boolean)(False).AddTo(_disposables)
' 3. ボタンの活性化ロジック (SearchTextがnullでなく、かつ長さ3以上の場合)
CanExecuteSearch = SearchText._
.Select(Function(s) Not String.IsNullOrWhiteSpace(s) AndAlso s.Length >= 3).
ToReadOnlyReactiveProperty().
AddTo(_disposables)
' 4. 検索コマンドの定義
SearchCommand = New ReactiveCommand(Of Unit)(
Function() Task.CompletedTask, ' 基本は非同期実行
CanExecuteSearch
).AddTo(_disposables)
' 5. 【核心】非同期検索パイプラインの構築
' SearchTextの変化を監視し、ストリームとして処理する
SearchResults = SearchText._
.Throttle(TimeSpan.FromMilliseconds(500)) _ ' 入力が止まってから500ms待機(連打防止)
.DistinctUntilChanged() _ ' 前と同じ値なら無視
.Select(Function(text) _
' 非同期処理をストリームに組み込む
' Switchを使用することで、古い検索リクエストを破棄し、最新の結果のみを拾う
_dataService.FetchAsync(text).
ToObservable()
).
Switch() _ ' 重要:非同期ストリームの競合(Race Condition)を解決
.ObserveOn(System.Reactive.Concurrency.DispatcherScheduler.Current) _ ' UIスレッドへ戻す
.StartWith(New List(Of String)()) _ ' 初期値
.ToReadOnlyReactiveProperty().
AddTo(_disposables)
' 6. コマンド実行時の副作用(ローディング表示など)
SearchCommand._
.Do(Function() IsLoading.Value = True).
Select(Function(_) _dataService.FetchAsync(SearchText.Value)).
SelectMany(Function(task) task.ToObservable()).
Subscribe(
Function(res)
' ここでの結果反映は、上記SearchResultsのパイプラインと重複させないよう設計に注意
' 今回はSearchResultsのストリームに集約しているため、ここは副作用の管理に留める
End Function,
Function(ex)
' エラーハンドリング:ここでアプリケーションを落とさない
Debug.WriteLine($"Error: {ex.Message}")
End Function
).
Do(Function() IsLoading.Value = False).
AddTo(_disposables)
End Sub
Public Sub Dispose() Implements IDisposable.Dispose
_disposables.Dispose()
End Sub
End Class
''' <summary>
''' データアクセス層のインターフェース
''' </summary>
Public Interface IDataService
Function FetchAsync(keyword As String) As Task(Of List(Of String))
End Interface
''' <summary>
''' モック実装(DB/ファイルアクセスをシミュレート)
''' </summary>
Public Class MockDataService
Implements IDataService
Public Async Function FetchAsync(keyword As String) As Task(Of List(Of String)) Implements IDataService.FetchAsync
' I/O負荷をシミュレート
Await Task.Delay(1000)
Return New List(Of String) From {
$"{keyword} の結果 1",
$"{keyword} の結果 2",
$"{keyword} の結果 3"
}
End Function
End Class
3. アーキテクトが教える、絶対に外せない3つの鉄則
このコードをただコピペするだけでは、君はまだ「使い手」に過ぎない。設計者として以下の3点を魂に刻んでおけ。
① `Switch()` による競合状態(Race Condition)の完全排除
非同期処理において最も恐ろしいのは、「古いリクエストの結果が、新しいリクエストの結果を上書きする」現象だ。
ユーザーが「A」と入力し、次に「AB」と入力したとき、DBの応答速度によっては「AB」の検索が先に終わり、「A」の検索が後から返ってくることがある。
`Select().Switch()` のパターンを使えば、新しい値が流れてきた瞬間に、前の非同期処理の購読を破棄してくれる。これは、業務ツールにおけるデータの整合性を守るための「防波堤」だ。
② `ObserveOn` によるスレッド・アフィニティの制御
WPFのUI要素は、作成されたスレッド(メインスレッド)以外から操作しようとすると、無慈悲な `InvalidOperationException` を投げつけてくる。
ReactivePropertyのストリームは、デフォルトではバックグラウンドスレッドで動くことが多い。必ず `.ObserveOn(DispatcherScheduler.Current)` を使い、「データの計算はバックグラウンドで、UIへの反映はメインスレッドで」を徹底せよ。
③ `CompositeDisposable` によるメモリリークの封じ込め
Rxの世界では、購読(Subscribe)したものは、明示的に解除しない限り、オブジェクトが生き続ける。ViewModelが破棄された後もイベントが走り続け、メモリを食いつぶし、最悪の場合、意図しない挙動を引き起こす。
`AddTo(_disposables)` を使い、ViewModelの `Dispose` メソッドで全てのストリームを確実に断ち切る。これが、プロフェッショナルの作法だ。
結論:設計とは「予測可能性」を高める行為である
ReactivePropertyを導入することは、単にコードを短くすることではない。「データの変化が、どのようなルールで、どのような順序で流れるか」を、コードの構造そのものに埋め込むことだ。
命令的なコードは、書いた瞬間に「動く」かもしれない。しかし、リアクティブな設計は、書いた瞬間に「将来のバグを予測し、封じ込める」ことができる。
次なるプロジェクトでは、`PropertyChanged` の地獄から脱却し、この流麗なストリーム設計を採用せよ。それが、君のコードを「動くゴミ」から「堅牢な資産」へと昇華させる唯一の道だ。
