公司場景 Pattern
ReactiveCommand 生命週期總覽
下方所有 Pattern 大量使用 ReactiveCommand。在進入個別場景之前,先用一張 sequenceDiagram 把 Command 的完整生命週期釐清:canExecute 何時更新、Execute 怎麼觸發、IsExecuting / ThrownExceptions / 結果 Observable 如何被訂閱。
各 Observable 的職責:
| Observable | 元素型別 | 觸發時機 | 典型 Subscribe 對象 |
|---|---|---|---|
canExecute (建構參數) | bool | 上游(如 WhenAnyValue)發出新值時 | ReactiveCommand 內部,用來控制 Button enable 狀態 |
IsExecuting | bool | Execute 開始時 OnNext(true),結束時 OnNext(false) | ProgressRing.IsActive、Cursor、暫停其他 Command |
ThrownExceptions | Exception | Execute body 拋例外時 | MessageBox、log、Sentry 上報 |
| Command 本體 (Subscribe) | TResult(Unit 若無回傳值) | Execute 成功時 | 後續處理(更新清單、跳轉頁面、呼叫下一個 Command) |
關鍵不變式:
canExecute必須是 Observable — 給靜態bool不會自動更新;要用this.WhenAnyValue(x => x.IsValid, x => x.IsConnected, (v, c) => v && c)之類的組合。Execute()是冷的 Observable — 沒人Subscribe就不會跑。BindCommand與直接Subscribe都會觸發訂閱;單純把 Command exposed 在 ViewModel 上不會自動執行。ThrownExceptions必須有 subscriber — 否則 Execute body 中拋出的例外會被OnError終止 Command Observable,並進一步 crash app(與Subscribe(...)沒提供onErrorhandler 相同)。IsExecuting是熱的 — 即使沒人 subscribe,ReactiveCommand內部仍會更新狀態,因此可以安全地在 Execute 流程中讀取它。
Pattern 1:設備監控 Dashboard
場景
多個感測器數據即時更新到儀表板。每個數據來自不同的 Observable 流,需要統一綁定到 UI。
實作
public class DashboardViewModel : ReactiveObject, IActivatableViewModel
{
public ViewModelActivator Activator { get; } = new();
[ObservableAsProperty] public double Temperature { get; }
[ObservableAsProperty] public double Pressure { get; }
[ObservableAsProperty] public int SpindleSpeed { get; }
[ObservableAsProperty] public bool IsAlarm { get; }
[ObservableAsProperty] public string AlarmMessage { get; }
public DashboardViewModel(IDeviceService deviceService)
{
this.WhenActivated(d =>
{
var stream = deviceService.GetSensorStream()
.ObserveOn(RxApp.MainThreadScheduler)
.Publish()
.RefCount();
stream.Select(s => s.Temperature)
.ToPropertyEx(this, x => x.Temperature)
.DisposeWith(d);
stream.Select(s => s.Pressure)
.ToPropertyEx(this, x => x.Pressure)
.DisposeWith(d);
stream.Select(s => s.SpindleSpeed)
.ToPropertyEx(this, x => x.SpindleSpeed)
.DisposeWith(d);
// 警報邏輯
stream.Select(s => s.Temperature > 80 || s.Pressure > 5.0)
.DistinctUntilChanged()
.ToPropertyEx(this, x => x.IsAlarm)
.DisposeWith(d);
stream.Where(s => s.Temperature > 80 || s.Pressure > 5.0)
.Select(s =>
{
if (s.Temperature > 80) return $"溫度過高: {s.Temperature:F1}°C";
return $"壓力過高: {s.Pressure:F2} MPa";
})
.ToPropertyEx(this, x => x.AlarmMessage)
.DisposeWith(d);
});
}
}
Pattern 2:Recipe Editor(表單驗證 + 儲存/取消)
場景
Recipe 編輯器需要:名稱不為空、溫度在範圍內、有修改才能儲存。
實作
public class RecipeEditorViewModel : ReactiveObject
{
[Reactive] public string RecipeName { get; set; } = "";
[Reactive] public double TargetTemperature { get; set; }
[Reactive] public double TargetPressure { get; set; }
[Reactive] public int HoldDuration { get; set; } = 60;
[ObservableAsProperty] public bool HasChanges { get; }
[ObservableAsProperty] public string ValidationError { get; }
public ReactiveCommand<Unit, Unit> SaveCommand { get; }
public ReactiveCommand<Unit, Unit> CancelCommand { get; }
private readonly Recipe _original;
public RecipeEditorViewModel(Recipe original, IRecipeService recipeService)
{
_original = original;
RecipeName = original.Name;
TargetTemperature = original.TargetTemperature;
TargetPressure = original.TargetPressure;
HoldDuration = original.HoldDuration;
// 驗證邏輯
var validation = this.WhenAnyValue(
x => x.RecipeName,
x => x.TargetTemperature,
x => x.TargetPressure,
(name, temp, pressure) =>
{
if (string.IsNullOrWhiteSpace(name)) return "名稱不可為空";
if (temp < 0 || temp > 500) return "溫度必須在 0-500°C 之間";
if (pressure < 0 || pressure > 10) return "壓力必須在 0-10 MPa 之間";
return "";
});
validation.ToPropertyEx(this, x => x.ValidationError);
// 偵測有無修改
this.WhenAnyValue(
x => x.RecipeName,
x => x.TargetTemperature,
x => x.TargetPressure,
x => x.HoldDuration,
(name, temp, press, dur) =>
name != _original.Name ||
temp != _original.TargetTemperature ||
press != _original.TargetPressure ||
dur != _original.HoldDuration)
.ToPropertyEx(this, x => x.HasChanges);
// 儲存:有修改 + 驗證通過才能按
var canSave = this.WhenAnyValue(
x => x.HasChanges,
x => x.ValidationError,
(changed, error) => changed && string.IsNullOrEmpty(error));
SaveCommand = ReactiveCommand.CreateFromTask(async () =>
{
var updated = _original with
{
Name = RecipeName,
TargetTemperature = TargetTemperature,
TargetPressure = TargetPressure,
HoldDuration = HoldDuration,
};
await recipeService.SaveAsync(updated);
}, canSave);
CancelCommand = ReactiveCommand.Create(() =>
{
RecipeName = _original.Name;
TargetTemperature = _original.TargetTemperature;
TargetPressure = _original.TargetPressure;
HoldDuration = _original.HoldDuration;
});
SaveCommand.ThrownExceptions
.Subscribe(ex => Logger.Error($"儲存失敗: {ex.Message}"));
}
}
Pattern 3:設備連線狀態管理
場景
多台設備各自有連線狀態,需要組合判斷「全部就緒」才能啟動製程。
實作
public class ProcessControlViewModel : ReactiveObject
{
[Reactive] public bool IsPlcConnected { get; set; }
[Reactive] public bool IsSensorConnected { get; set; }
[Reactive] public bool IsSecsConnected { get; set; }
[ObservableAsProperty] public bool AllDevicesReady { get; }
[ObservableAsProperty] public string StatusSummary { get; }
public ReactiveCommand<Unit, Unit> StartProcessCommand { get; }
public ProcessControlViewModel()
{
// 組合三個設備狀態
var allReady = this.WhenAnyValue(
x => x.IsPlcConnected,
x => x.IsSensorConnected,
x => x.IsSecsConnected,
(plc, sensor, secs) => plc && sensor && secs);
allReady.ToPropertyEx(this, x => x.AllDevicesReady);
// 狀態摘要
this.WhenAnyValue(
x => x.IsPlcConnected,
x => x.IsSensorConnected,
x => x.IsSecsConnected,
(plc, sensor, secs) =>
{
var missing = new List<string>();
if (!plc) missing.Add("PLC");
if (!sensor) missing.Add("感測器");
if (!secs) missing.Add("SECS/GEM");
return missing.Count == 0
? "所有設備就緒"
: $"等待連線: {string.Join(", ", missing)}";
})
.ToPropertyEx(this, x => x.StatusSummary);
// 全部就緒才能啟動
StartProcessCommand = ReactiveCommand.CreateFromTask(
StartProcessAsync, allReady);
}
}
Pattern 4:搜尋/過濾
場景
在設備清單中搜尋,需要去抖動避免每次按鍵都觸發。
實作
public class DeviceListViewModel : ReactiveObject, IActivatableViewModel
{
public ViewModelActivator Activator { get; } = new();
[Reactive] public string SearchText { get; set; } = "";
[ObservableAsProperty] public IReadOnlyList<DeviceInfo> FilteredDevices { get; }
[ObservableAsProperty] public bool IsSearching { get; }
public DeviceListViewModel(IDeviceRepository repository)
{
var search = this.WhenAnyValue(x => x.SearchText)
.Throttle(TimeSpan.FromMilliseconds(300))
.DistinctUntilChanged()
.SelectMany(async text =>
{
if (string.IsNullOrWhiteSpace(text))
return await repository.GetAllAsync();
return await repository.SearchAsync(text);
})
.ObserveOn(RxApp.MainThreadScheduler);
this.WhenActivated(d =>
{
search.ToPropertyEx(this, x => x.FilteredDevices)
.DisposeWith(d);
// 搜尋中指示
this.WhenAnyValue(x => x.SearchText)
.Select(_ => true)
.Merge(search.Select(_ => false))
.ToPropertyEx(this, x => x.IsSearching)
.DisposeWith(d);
});
}
}
Pattern 5:與 Polly 搭配
場景
ReactiveCommand 內的設備操作需要韌性策略(重試、逾時)。
實作
public class DeviceViewModel : ReactiveObject
{
public ReactiveCommand<Unit, DeviceData> ReadCommand { get; }
public DeviceViewModel(
IDeviceClient client,
ResiliencePipeline<DeviceData> pipeline)
{
ReadCommand = ReactiveCommand.CreateFromTask(async () =>
await pipeline.ExecuteAsync(async ct =>
await client.ReadDataAsync(ct)));
ReadCommand.ThrownExceptions
.Subscribe(ex => Logger.Error($"讀取失敗(含重試): {ex.Message}"));
}
}
更複雜的場景——Polly ResiliencePipeline 包裝在 Observable 管道中:
this.WhenActivated(d =>
{
Observable.Interval(TimeSpan.FromSeconds(1))
.SelectMany(_ => Observable.FromAsync(ct =>
pipeline.ExecuteAsync(
async token => await client.ReadAsync(token), ct)))
.DistinctUntilChanged()
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(data => CurrentReading = data)
.DisposeWith(d);
});
Pattern 6:與 FlaUI 測試整合
場景
測試 ReactiveUI ViewModel 不需要啟動 UI——直接測試 Observable 管道邏輯。
單元測試 ViewModel
[TestMethod]
public async Task ConnectCommand_ShouldUpdateStatus()
{
var mockClient = Substitute.For<IDeviceClient>();
mockClient.ConnectAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.CompletedTask);
var vm = new DeviceMonitorViewModel(mockClient)
{
DeviceIp = "192.168.1.100",
DevicePort = 502
};
await vm.ConnectCommand.Execute();
Assert.AreEqual("已連線", vm.ConnectionStatus);
}
[TestMethod]
public void CanStart_ShouldBeFalse_WhenNotConnected()
{
var vm = new ProcessControlViewModel
{
IsPlcConnected = true,
IsSensorConnected = false, // 未連線
IsSecsConnected = true,
};
Assert.IsFalse(vm.AllDevicesReady);
Assert.IsFalse(vm.StartProcessCommand.CanExecute.FirstAsync().Wait());
}
FlaUI 端到端測試
搭配 FlaUI 做完整的 UI 測試:
[Test]
public void Dashboard_ShouldShowSensorData()
{
var mainPage = new MainPage(_mainWindow);
mainPage.ConnectToDevice("192.168.1.100");
// 等待 ReactiveUI OAPH 綁定更新 UI
var tempLabel = WaitHelper.WaitForElement(
_mainWindow, "TemperatureLabel", TimeSpan.FromSeconds(10));
// 驗證有數值顯示
var text = tempLabel.AsLabel().Text;
Assert.That(text, Does.Contain("°C"));
}
延伸閱讀:
- 核心概念 — WhenAnyValue、ReactiveCommand 詳解
- 遷移指南 — 漸進式導入策略
- Rx.NET 工業場景 Pattern — 底層資料流 Pattern
- Polly 場景 Pattern — 韌性策略整合