
上位机与普通桌面应用的差别在于:硬件型号会换、协议会改、采样频率会变,而 UI 必须始终稳定地反映设备状态。传统 WPF 上位机把串口句柄、轮询定时器和 UI 更新揉进一个 ViewModel,换一个 PLC 品牌就要重写界面层。模块化解决“结构耦合”,反应式解决“时序与线程耦合”,两者结合,才能让上位机在扩展性与响应性上同时达标。以下基于 Prism + ReactiveUI。
每个设备模块只暴露契约,Shell 对具体驱动一无所知。模块在注册阶段完成服务绑定,在初始化阶段把视图注入 Region:
public sealed class PlcModule : IModule
{
public void RegisterTypes(IContainerRegistry c)
{
c.RegisterSingleton<IDeviceDriver, ModbusTcpDriver>();
c.Register<DeviceViewModel>();
c.Register<Views.DeviceView>();
}
public void OnInitialized(IContainerProvider p)
{
p.Resolve<IRegionManager>()
.RegisterViewWithRegion("MainRegion", typeof(Views.DeviceView));
}
}IDeviceDriver 抽象“连接、采样、下发”三个语义,并把采样暴露为冷/热流:
public interface IDeviceDriver
{
string DeviceId { get; }
IObservable<DeviceSample> Samples { get; } // 反应式的天然入口
Task ConnectAsync(CancellationToken ct);
Task DisconnectAsync();
Task WriteAsync(DeviceCommand cmd, CancellationToken ct);
}更换 PLC 品牌时,只需新增一个实现并在模块中替换注册行,ViewModel 与 Shell 零改动。跨模块禁止直接引用,一律通过 IEventAggregator 或共享事件流通信。
上位机的本质是对持续变化的数据流做出反应。WhenAnyValue 把属性变化暴露为 IObservable<T>,ReactiveCommand 把异步操作与可用性绑定,调度器把后台采样切回 UI 线程:
public class DeviceViewModel : ReactiveObject, IDisposable
{
private readonly IDeviceDriver _driver;
private readonly IEventAggregator _events;
private readonly CompositeDisposable _subs = new();
[Reactive] public double Value { get; private set; }
[Reactive] public DeviceState State { get; private set; }
[Reactive] public bool IsAlarm { get; private set; }
public ReactiveCommand<Unit, Unit> ConnectCommand { get; }
public ReactiveCommand<Unit, Unit> DisconnectCommand { get; }
public DeviceViewModel(IDeviceDriver driver, IEventAggregator events)
{
_driver = driver;
_events = events;
var canConnect = this.WhenAnyValue(x => x.State)
.Select(s => s == DeviceState.Disconnected);
var canDisconnect = this.WhenAnyValue(x => x.State)
.Select(s => s is DeviceState.Running or DeviceState.Alarm);
ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync, canConnect);
DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync, canDisconnect);
// 采样流:Sample 限频,ObserveOn 切回 UI 线程,避免高频刷新卡死界面
_driver.Samples
.Sample(TimeSpan.FromMilliseconds(100))
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(s => { Value = s.Value; State = s.State; })
.DisposeWith(_subs);
// 报警流:滑动窗口,1 秒内持续超限才触发,抑制瞬时抖动
_driver.Samples
.Select(s => s.Value)
.Buffer(TimeSpan.FromSeconds(1), 10)
.Where(b => b.Count > 0 && b.All(v => v > 85.0))
.Throttle(TimeSpan.FromSeconds(2))
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(_ => IsAlarm = true)
.DisposeWith(_subs);
}
private async Task ConnectAsync()
{
await _driver.ConnectAsync(CancellationToken.None);
State = DeviceState.Running;
_events.GetEvent<DeviceConnectedEvent>().Publish(_driver.DeviceId);
}
public void Dispose() => _subs.Dispose(); // 视图销毁时解除全部订阅
}四个工程要点:Sample 抑制高频采样造成的 UI 刷新压力;Buffer + Where 用滑动窗口实现“持续超限才报警”,比单点阈值更抗抖动;ObserveOn 保证属性通知在 UI 线程执行;DisposeWith + Dispose 确保订阅随视图生命周期回收——上位机长期运行,订阅泄漏是最隐蔽的生产事故。
假设新增“温度超限自动停机”策略:把它做成独立 SafetyModule,订阅共享采样事件流,满足条件后发布急停事件:
public sealed class SafetyModule : IModule
{
public void RegisterTypes(IContainerRegistry c) { }
public void OnInitialized(IContainerProvider p)
{
var bus = p.Resolve<IEventAggregator>();
var lifetime = p.Resolve<IDisposable>(); // 模块生命周期
bus.GetEvent<DeviceSampleEvent>().GetEventStream()
.Where(e => e.Value > 85)
.Throttle(TimeSpan.FromSeconds(1))
.SelectMany(e => Observable.FromAsync(
ct => p.Resolve<ISafetyService>().EmergencyStopAsync(e.DeviceId, ct)))
.Subscribe(_ => bus.GetEvent<EmergencyStopEvent>().Publish(),
ex => p.Resolve<ILogger>().LogError(ex, "safety stop failed"))
.DisposeWith(lifetime);
}
}采集模块与 UI 模块一行不改。模块边界由 IModule 守住,数据边界由 Observable 流守住。新增能力 = 新增模块 + 订阅已有事件流,这正是模块化与反应式协同的兑现点。
上位机架构的价值不在于用了多少框架,而在于变更发生时需要触碰多少文件。当更换硬件只需改一个模块类、调整报警逻辑只需改一条流操作符、订阅生命周期自动随模块回收时,这套组合才真正兑现了工程承诺。模块化定结构,反应式定数据流,二者缺一,上位机都只是“能跑”,而非“可演进”。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。