问题背景
许多 WPF HMI 在早期会让 ViewModel 直接创建串口、Socket 和设备驱动。这样做很快,但页面一多,就会出现多个对象重复连接、切换页面后通信被释放、后台异常无人观察等问题。
DeviceHost 的目的不是增加一个“万能管理器”,而是为设备建立独立于 UI 的生命周期边界。它拥有连接、能力、状态和停止顺序,ViewModel 只消费稳定的应用接口。
现象
当设备生命周期混入页面时,常见现象包括:
- 打开两个页面后同一串口被创建两次;
- 导航离开页面触发
Dispose,全局设备随之断开; - ViewModel 中充满连接、重试、Dispatcher 和日志代码;
- 单元测试必须依赖真实硬件;
- 应用退出时后台任务仍在访问已经释放的资源。
这些问题都指向同一个根因:界面对象拥有了不属于它的资源生命周期。
最小复现
下面的 ViewModel 同时承担展示、通信和资源释放:
public sealed class DeviceViewModel : ObservableObject, IDisposable
{
private readonly TcpClient _client = new();
public async Task ConnectAsync()
{
await _client.ConnectAsync("192.0.2.10", 502);
_ = PollAsync();
}
public void Dispose() => _client.Dispose();
}
只要 ViewModel 被重新创建,就可能产生新的连接;PollAsync 没有取消令牌,也没有被宿主等待。页面关闭和应用关闭的语义混在一起。
原理
生命周期应与资源边界一致
如果一个设备连接服务于整个应用,它的生命周期就应该由应用宿主管理,而不是由某个页面管理。页面可以订阅和取消订阅状态,但不能决定物理连接何时销毁。
能力比设备型号更稳定
业务页面通常关心“能否移动”“能否读 IO”“能否下发配方”,而不是设备的具体类名。用小型能力接口表达这些行为,可以让不同设备组合不同能力,并在测试中单独替换。
状态快照比事件拼图可靠
只发布 Connected、AlarmRaised 等零散事件,订阅者必须自己推导当前状态,稍有丢失就会不一致。DeviceHost 应维护权威快照,每次变化发布完整或版本化状态,页面首次进入时也能立即获得当前值。
设计接口
一个最小宿主接口可以这样表达:
public interface IDeviceHost : IAsyncDisposable
{
string DeviceId { get; }
DeviceSnapshot Current { get; }
IObservable<DeviceSnapshot> States { get; }
TCapability GetCapability<TCapability>() where TCapability : notnull;
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
public sealed record DeviceSnapshot(
long Version,
ConnectionState Connection,
OperatingState Operating,
IReadOnlyList<AlarmSnapshot> Alarms,
DateTimeOffset CapturedAt);
GetCapability 找不到能力时应抛出清晰的配置异常,或者同时提供 TryGetCapability。不要返回 null 后让每个页面自行猜测。
排查过程
一、画出对象所有权
列出谁创建设备、谁启动轮询、谁可以停止以及应用关闭时谁等待任务结束。如果同一个问题有多个答案,生命周期就不清晰。
二、记录后台任务
所有长期任务都应保存引用,并在完成时观察异常。禁止用未跟踪的 fire-and-forget 启动设备循环。日志至少包含设备 ID、宿主状态和取消原因。
三、模拟失败
不连接真实硬件,使用模拟传输层主动制造:连接拒绝、半帧、持续超时、取消和重连。只要 ViewModel 仍需要知道协议细节,就说明边界还没有切干净。
根因
根因通常不是缺少依赖注入,而是依赖注入只负责“拿到对象”,却没有定义谁拥有对象。把设备注册成 Singleton 只能暂时避免重复创建;如果启动、停止和后台任务仍由页面控制,问题依旧存在。
解决方案
由应用宿主管理设备
在应用启动时创建所有 DeviceHost,完成配置校验后统一启动。窗口关闭时先阻止新命令,再取消业务任务、停止设备循环,最后释放传输层。
public sealed class DeviceRuntime : IHostedService
{
private readonly IReadOnlyList<IDeviceHost> _hosts;
public DeviceRuntime(IEnumerable<IDeviceHost> hosts)
=> _hosts = hosts.ToArray();
public async Task StartAsync(CancellationToken cancellationToken)
{
foreach (var host in _hosts)
await host.StartAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
foreach (var host in _hosts.Reverse())
await host.StopAsync(cancellationToken);
}
}
真实系统可以根据设备依赖关系并行或分阶段启动,但顺序必须显式定义。
ViewModel 只调用用例
ViewModel 不直接获取 TcpClient 或 SerialPort。例如“设备复位”由应用服务完成权限检查、状态检查、调用能力和记录操作日志。ViewModel 只维护按钮是否可用、执行中状态和返回给用户的结果。
统一线程切换
设备状态在后台线程产生,展示适配器负责合并与切换到 UI 调度器。领域层不引用 WPF 的 Dispatcher,从而能够在控制台测试程序中复用。
为什么这个方案有效
资源生命周期从易变的页面提升到稳定的应用宿主;具体协议被限制在传输层;页面使用能力与状态契约。每一层都可以替换,关闭过程也有明确顺序。
当设备发生异常时,日志能够回答宿主是否启动、连接处于什么状态、哪个命令在执行以及取消来自哪里,而不是只留下一个 ViewModel 的异常堆栈。
最终结果
完成重构后,切换页面不会影响设备连接;多个页面可以观察同一状态快照;模拟设备能够覆盖主要 UI 流程;应用关闭会等待后台任务退出,不再出现释放后访问。
总结
DeviceHost 是设备资源与应用生命周期之间的边界,不应成为包含所有业务逻辑的巨型类。通过能力接口、权威状态快照、宿主管理和全链路取消,可以让 WPF HMI 更容易测试、恢复和长期维护。