前两年做一套组装产线的视觉检测上位机,项目初期架构写得很简单:主页面直接引用海康相机SDK、S7-1200通信类、Modbus传感器采集代码。所有业务逻辑揉在同一个WPF ViewModel里。
前期调试速度很快,但到二期扩容时噩梦就来了。新增一个扫码枪设备,需要修改主ViewModel,增加通信实例、数据接收回调、UI绑定代码;替换PLC品牌,要大面积修改读写逻辑;两个产线并行部署,复制一套工程后,要到处注释、删除代码。最头疼的是回归测试,改一处硬件代码,要全流程复测,偶发回调冲突、资源释放遗漏,上线后偶尔出现相机句柄泄漏、PLC连接没有正常断开。
当时意识到问题根源:硬件驱动、数据采集、业务逻辑、UI耦合死在了一起。硬件模块没有统一抽象,新增设备必须侵入核心业务代码。于是重构一套插件化硬件抽象层,把相机、PLC、传感器封装成独立模块,像乐高积木一样插拔,业务层只依赖抽象接口,不关心底层是海康相机还是Basler、西门子还是国产PLC。
重构完成之后,新增同类硬件,只需要新增模块实现,不需要修改上位机主框架代码。设备热插拔、实例生命周期统一管理,资源泄漏问题大幅减少。下面从抽象模型、架构、可运行代码、工程陷阱、选型边界完整拆解。
模块化硬件数据流
整个架构核心是硬件抽象层(HAL),所有硬件设备实现统一IDevice基础接口,再根据设备类型扩展专用接口。业务层、UI层只依赖接口,不依赖具体硬件实现类。新增硬件,只需要新增IDevice实现,完全符合开闭原则。
抽象接口底层设计与时间复杂度
核心接口设计,分成基础设备接口与设备专用接口。IDevice负责统一生命周期:初始化、连接、断开、释放资源、状态上报。IDevice内部保存设备元信息(设备ID、类型、连接字符串),设备管理器DeviceManager维护一个并发安全字典存储所有设备实例。
底层数据结构选择:ConcurrentDictionary<string, IDevice>存储设备实例。
- 读取、新增、删除设备:O(1),哈希表查找。
- 遍历所有设备:O(N),N为当前已加载设备数量。
工控场景设备数量一般不会超过30个,这个复杂度完全满足需求。不使用List<T>,因为高频查询设备时List查找O(N),设备增多后性能会逐步下滑。
消息事件使用弱引用事件管理器。硬件采集事件高频推送,如果直接强引用,很容易造成ViewModel无法GC,内存泄漏。弱引用事件可以在订阅对象销毁时自动解绑回调。
using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; // 基础硬件设备统一抽象,所有硬件都必须实现 public interface IDevice { string DeviceId { get; } DeviceType DeviceType { get; } DeviceState State { get; } Task<bool> ConnectAsync(CancellationToken token = default); Task DisconnectAsync(CancellationToken token = default); Task ReleaseAsync(); } // 相机专用接口,继承IDevice public interface ICameraDevice : IDevice { event Action<ImageData> OnFrameReceived; Task<bool> TriggerCaptureAsync(CancellationToken token = default); } // PLC专用接口 public interface IPlcDevice : IDevice { Task<ushort[]> ReadRegistersAsync(string address, int count, CancellationToken token = default); Task WriteRegistersAsync(string address, ushort[] data, CancellationToken token = default); } // 传感器专用接口 public interface ISensorDevice : IDevice { event Action<SensorData> OnValueUpdated; Task StartPollingAsync(int intervalMs, CancellationToken token = default); Task StopPollingAsync(); } public enum DeviceType { Camera, Plc, Sensor } public enum DeviceState { UnInitialized, Connected, Disconnected, Error } // 数据模型 public class ImageData { public byte[] Buffer { get; set; } public int Width { get; set; } public int Height { get; set; } public DateTime CaptureTime { get; set; } } public class SensorData { public double Value { get; set; } public DateTime Timestamp { get; set; } }设备管理器实现(模块容器)
DeviceManager作为积木收纳盒,负责注册、查找、销毁所有硬件实例。全局唯一,业务层通过管理器获取设备,不直接new硬件实例。
public sealed class DeviceManager { private readonly ConcurrentDictionary<string, IDevice> _deviceDict = new(); // 注册设备实例 public bool RegisterDevice(IDevice device) { if (device == null) throw new ArgumentNullException(nameof(device)); return _deviceDict.TryAdd(device.DeviceId, device); } // 获取设备,泛型约束,强制类型转换 public T? GetDevice<T>(string deviceId) where T : class, IDevice { if (_deviceDict.TryGetValue(deviceId, out var dev)) { return dev as T; } return null; } public IDevice[] GetAllDevices() { return _deviceDict.Values.ToArray(); } public async Task RemoveAndReleaseDeviceAsync(string deviceId) { if (_deviceDict.TryRemove(deviceId, out var device)) { await device.DisconnectAsync(); await device.ReleaseAsync(); } } public async Task ReleaseAllAsync() { foreach (var dev in _deviceDict.Values) { await dev.DisconnectAsync(); await dev.ReleaseAsync(); } _deviceDict.Clear(); } }硬件模块实现示例:Modbus传感器模块
using System.IO.Ports; using System.Threading; using System.Threading.Tasks; public class ModbusSensorDevice : ISensorDevice { public string DeviceId { get; } public DeviceType DeviceType => DeviceType.Sensor; public DeviceState State { get; private set; } private readonly string _portName; private SerialPort? _serialPort; private CancellationTokenSource? _pollCts; private bool _isPolling; public event Action<SensorData>? OnValueUpdated; public ModbusSensorDevice(string deviceId, string portName) { DeviceId = deviceId; _portName = portName; State = DeviceState.UnInitialized; } public async Task<bool> ConnectAsync(CancellationToken token = default) { try { _serialPort = new SerialPort(_portName, 9600); _serialPort.Open(); State = DeviceState.Connected; return true; } catch { State = DeviceState.Error; return false; } } public async Task DisconnectAsync(CancellationToken token = default) { await StopPollingAsync(); _serialPort?.Close(); State = DeviceState.Disconnected; } public async Task ReleaseAsync() { await DisconnectAsync(); _serialPort?.Dispose(); _serialPort = null; } public async Task StartPollingAsync(int intervalMs, CancellationToken token = default) { if (_isPolling) return; _pollCts = CancellationTokenSource.CreateLinkedTokenSource(token); _isPolling = true; _ = Task.Run(async () => { while (!_pollCts.Token.IsCancellationRequested) { var data = await ReadSensorRawValue(); OnValueUpdated?.Invoke(data); await Task.Delay(intervalMs, _pollCts.Token); } }, _pollCts.Token); } public async Task StopPollingAsync() { if (!_isPolling) return; _pollCts?.Cancel(); _pollCts?.Dispose(); _isPolling = false; } private async Task<SensorData> ReadSensorRawValue() { // 模拟Modbus读取,替换为真实报文收发 await Task.Delay(2); return new SensorData { Value = Random.Shared.NextDouble() * 100, Timestamp = DateTime.UtcNow }; } }业务层调用示例(业务完全不依赖具体硬件类)
业务代码只依赖接口,不需要引用Modbus、相机SDK。新增设备时业务代码一行不用改动。
// 上位机初始化 var deviceManager = new DeviceManager(); // 实例化传感器模块,注册到管理器 var sensor = new ModbusSensorDevice("sensor_01", "COM3"); deviceManager.RegisterDevice(sensor); // 业务服务获取设备 var sensorDev = deviceManager.GetDevice<ISensorDevice>("sensor_01"); if (sensorDev != null) { bool connected = await sensorDev.ConnectAsync(); if (connected) { sensorDev.OnValueUpdated += data => { // 推送数据到UI,使用Dispatcher }; await sensorDev.StartPollingAsync(50); } }工程踩坑、性能陷阱
1. 事件回调强引用导致内存泄漏
硬件模块的采集事件OnFrameReceived、OnValueUpdated是高频坑。ViewModel订阅硬件事件,如果硬件实例生命周期比ViewModel长,会形成强引用链,ViewModel无法GC。
- ❌ 错误:直接
device.OnValueUpdated += viewModel.OnData - ✅ 方案:使用弱引用事件订阅;或者在ViewModel销毁时,手动
-=解绑事件。
在工控场景,设备长期常驻内存,这个问题会缓慢累积内存,上线几个小时才显现,很难定位。
2. 异步资源释放不全,句柄泄漏
很多硬件SDK句柄、串口、网络流,必须严格调用释放。IDevice.ReleaseAsync必须保证Dispose。
如果只写DisconnectAsync不写Release,串口句柄、相机SDK句柄残留,重复插拔设备会出现端口占用,重启上位机才能恢复。
规则:
DisconnectAsync负责断开通信链路;ReleaseAsync负责释放非托管资源、SDK句柄。两个职责分开。
3. 接口粒度设计失衡
第一种错误:接口设计太粗,IDevice塞一堆相机、PLC、传感器专属方法,违背接口隔离原则。PLC不需要拍照方法,相机不需要寄存器读写。
第二种错误:接口拆的太碎,每个硬件单独一套接口,失去抽象价值,新增设备依然要改业务代码。
选型标准:基础IDevice统一生命周期;设备类型扩展专用接口,遵循接口隔离。
4. 并发回调阻塞主线程
相机帧回调、传感器事件回调通常来自SDK底层线程,不能直接在回调内部执行耗时逻辑或者UI渲染。回调内部只做数据转发,业务处理丢到单独线程池,否则会阻塞SDK接收线程,出现丢帧、采集中断。
5. 不要把设备实例直接注入UI ViewModel
ViewModel只从DeviceManager拿接口,不要直接依赖具体硬件实现类。一旦硬件更换,ViewModel不需要修改。
选型边界与真实项目取舍
这套乐高模块化方案不是所有项目都适合。
- ✅ 适合场景:多台设备混合(相机+PLC+多传感器)、项目分期迭代、设备型号可能替换、多产线复用同一套上位机框架。
- ❌ 不适合:极简小工具,只有单个硬件,一次性交付,后期不会迭代。过度抽象会增加代码量与学习成本。
测试环境:.NET 8 WPF,同时运行1台工业相机、1个S7-1200 PLC、4路Modbus传感器。设备动态注册、注销,连续运行72小时。重构前每24小时内存上涨约120MB;重构后内存稳定,资源句柄无泄漏。
说明:架构本身不会提升硬件通信速度,它解决的是代码可维护性、资源管理、扩展能力,不会减少底层SDK读写耗时。
收尾
这套模块化架构本质是把硬件和业务解耦,把每一个硬件封装成独立可插拔组件。业务层只和抽象契约打交道,不用关心底层SDK细节。
在项目迭代过程中,更换相机品牌、替换PLC型号,只需要新增对应的ICameraDevice/IPlcDevice实现,插拔到设备管理器,核心业务代码完全不动。
但模块化抽象是有成本的,需要提前定义接口契约,严格管理生命周期和事件订阅。简单项目强行套用,反而增加复杂度。是否使用,要看项目迭代周期、硬件种类数量。