Skip to main content

公司場景 Pattern


ReactiveCommand 生命週期總覽

下方所有 Pattern 大量使用 ReactiveCommand。在進入個別場景之前,先用一張 sequenceDiagram 把 Command 的完整生命週期釐清:canExecute 何時更新、Execute 怎麼觸發、IsExecuting / ThrownExceptions / 結果 Observable 如何被訂閱。

各 Observable 的職責:

Observable元素型別觸發時機典型 Subscribe 對象
canExecute (建構參數)bool上游(如 WhenAnyValue)發出新值時ReactiveCommand 內部,用來控制 Button enable 狀態
IsExecutingboolExecute 開始時 OnNext(true),結束時 OnNext(false)ProgressRing.IsActiveCursor、暫停其他 Command
ThrownExceptionsExceptionExecute body 拋例外時MessageBox、log、Sentry 上報
Command 本體 (Subscribe)TResultUnit 若無回傳值)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(...) 沒提供 onError handler 相同)。
  • 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"));
}

延伸閱讀