☰
WPF工业上位机架构实战:UI适配、事务调度与多相机处理
2026/10/2 14:24:28 网站建设 项目流程

做工业级 WPF 上位机这几年,我踩过最多的坑其实不在功能本身,而在界面适配、设备通信时序和视觉采集线程这几块。很多项目一开始用 WinForms 写得很顺手,但到了 3D 视觉、多相机实时显示、大屏 UI 适配这些环节,WinForms 的布局和渲染机制就开始拖后腿。WPF 虽然学习曲线陡一些,但它的 XAML 布局、数据绑定、渲染管线和异步模型,确实更适合做复杂度高的上位机项目。这篇文章围绕 WPF 工控架构展开,重点拆解 UI 适配方案、底层事务设计、C# 上位机通用框架、机器视觉软件中的多相机处理,以及工业级上位机落地时常见的坑和工程建议。适合从 WinForms 转 WPF 的开发者,也适合刚开始做上位机视觉项目、对 MVVM 和线程模型不太熟悉的新手。

1. WPF 工控上位机为什么需要架构设计

1.1 上位机软件的复杂度来源

很多人觉得上位机就是"界面 + 串口 + 按钮",但这种想法只适合 Demo 级的工具。真正的工业级上位机至少包含以下内容:

  • 设备层:PLC、运动控制卡、工业相机、扫码枪、传感器,每种设备的通信协议都可能不同。
  • 业务层:配方管理、参数下发、生产流程控制、报警联动、数据追溯。
  • 界面层:监控大屏、参数设置、历史曲线、日志查询、多相机画面拼接。
  • 数据层:检测结果入库、报表导出、与 MES/ERP 对接。

这四个层次如果全部耦合在窗体代码里,项目规模稍大就会变成"改一处崩三处"。WPF 的优势在于它天生适合分层:XAML 负责界面描述,数据绑定负责界面和数据解耦,命令系统把事件处理变成可测试的逻辑。

1.2 UI 适配为什么是工控项目的老大难

工控现场最常见的显示器分辨率是 1920x1080,但也有 1366x768 的老触摸屏、2560x1440 的显示器、4K 大屏甚至竖屏。如果界面按固定像素硬编码,换一台显示器就出问题:

  • 控件位置偏移、按钮被截断。
  • 高 DPI 屏幕下文字模糊。
  • 全屏运行时布局拉伸变形。

更麻烦的是,工控软件经常需要全屏运行,操作员不允许随意调整窗口。这就要求界面在启动时根据屏幕分辨率做适配,而不是等用户手动拖拽。

1.3 底层事务在设备通信中的意义

"事务"这个词通常出现在数据库里,但在上位机开发中同样存在。比如设置相机参数,往往需要依次执行"停止取流 → 写入曝光值 → 写入增益值 → 恢复取流",这组动作必须连续执行,不能被其他线程插入一句"修改触发模式",否则相机状态就会错乱。

再比如 PLC 写参数,上位机界面可能有多个窗口同时向同一个寄存器写值,如果没有串行机制,最终写入的值取决于线程调度顺序,这是很危险的。底层事务的本质就是:把一组设备操作封装成原子单元,同一设备上的操作严格排队执行。

2. 环境准备与工程结构设计

2.1 开发环境说明

下面的示例基于常见工控开发环境,具体版本请以你本机安装为准,本文重点是配置思路和代码结构:

  • 操作系统:Windows 10/11 或 Windows 7 工控机(需要注意 .NET 版本兼容性)。
  • 开发工具:Visual Studio 2022 或 Rider。
  • 框架:.NET 6 或 .NET 8(建议 LTS 版本,工控场景求稳定)。
  • UI 技术:WPF。
  • 视觉库:OpenCvSharp4.Windows(用于相机采集和图像处理演示)。
  • 数据库访问:SqlBulkCopy(System.Data.SqlClient 或 Microsoft.Data.SqlClient)。

示例代码为了减少依赖,会手写简单的 Mvvm 基类和命令类,实际项目中你可以换成 CommunityToolkit.Mvvm 或 Prism,思路完全一致。

2.2 推荐的项目分层

IndustrialHmi/ ├─ IndustrialHmi.App // 启动程序,MainWindow、App.xaml ├─ IndustrialHmi.Core // 核心接口、领域模型、事务抽象 ├─ IndustrialHmi.Hardware // 相机、PLC、串口等设备驱动 ├─ IndustrialHmi.ViewModels // ViewModel、业务流程编排 └─ IndustrialHmi.Data // 数据库访问、数据同步

这样做的好处是:

  • UI 层只依赖 ViewModel,不直接引用硬件 DLL。
  • 硬件驱动可以替换为模拟器,方便在没有真实设备的现场开发调试。
  • 视觉算法、设备通信和界面显示互不干扰,以后加功能不用大改。

3. UI 适配:从高分屏到多分辨率的完整方案

3.1 先解决 DPI 感知问题

WPF 默认是系统 DPI 感知的,在高分屏上如果不做处理,界面会被系统强行放大,出现文字模糊、控件错位。工控机外接 4K 屏时尤其明显。

正确做法是在app.manifest中声明 PerMonitorV2 DPI 感知。Visual Studio 创建 WPF 项目时默认会生成app.manifest,打开后加入以下内容:

<?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> </assembly>

声明之后,WPF 会在每个显示器上按真实 DPI 重新布局。这里要注意,部分老型号工控机显卡驱动不支持 PerMonitorV2,实际项目中需要在"清晰显示"和"老设备兼容"之间做取舍,必要时可以只声明系统 DPI Aware。

为了让文字在高分屏上也清晰,可以在主窗口上设置渲染质量:

<Window ... TextOptions.TextFormattingMode="Display" TextOptions.TextRenderingMode="ClearType" RenderOptions.BitmapScalingMode="HighQuality"> </Window>

TextFormattingMode="Display"会让小字号文字更清晰,配合 ClearType 在工控屏幕上效果更好。

3.2 Viewbox 整体缩放方案

如果项目是面向固定设计分辨率的,比如领导指定"只做 1920x1080,其他屏幕等比缩放",最简单有效的方案是用 Viewbox 包一层 Canvas 或 Grid。

<Viewbox Stretch="Uniform" StretchDirection="Both"> <Grid Width="1920" Height="1080"> <Grid.RowDefinitions> <RowDefinition Height="60"/> <RowDefinition Height="*"/> <RowDefinition Height="160"/> </Grid.RowDefinitions> <Border Grid.Row="0" Background="#2C3E50"> <StackPanel Orientation="Horizontal" Margin="12,0" VerticalAlignment="Center"> <TextBlock Text="工业视觉检测系统" FontSize="20" Foreground="White"/> <TextBlock Text="{Binding CurrentUser}" Margin="24,0,0,0" FontSize="14" Foreground="#BDC3C7"/> </StackPanel> </Border> <Grid Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="260"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="360"/> </Grid.ColumnDefinitions> <Border Grid.Column="0" Background="#ECF0F1" Padding="8"> <TreeView ItemsSource="{Binding DeviceTree}"/> </Border> <Grid Grid.Column="1"> <Image Source="{Binding CurrentFrame}" Stretch="Uniform" Margin="8"/> </Grid> <Border Grid.Column="2" Background="#F8F9FA" Padding="8"> <StackPanel> <TextBlock Text="参数设置" FontSize="16" FontWeight="Bold"/> <TextBox Text="{Binding ExposureValue}"/> <Button Content="写入相机参数" Command="{Binding WriteCameraParamsCommand}" Margin="0,12,0,0" Height="36"/> </StackPanel> </Border> </Grid> <Border Grid.Row="2" Background="#34495E" Padding="8"> <TextBox Text="{Binding RuntimeLog}" Foreground="White" FontFamily="Consolas" AcceptsReturn="True" IsReadOnly="True"/> </Border> </Grid> </Viewbox>

Viewbox 会把内部 1920x1080 的布局缩放到窗口实际大小。它的优点是一行代码解决问题,缺点是内部控件拿到的坐标都是设计尺寸坐标,如果需要在 Canvas 上做鼠标点选、框选 ROI,必须做坐标转换。

坐标转换方法如下,把鼠标点击的窗口坐标转换到 Canvas 设计坐标:

public static Point TransformToCanvas(Point screenPoint, Viewbox viewbox, Canvas canvas) { double scaleX = canvas.Width / viewbox.ActualWidth; double scaleY = canvas.Height / viewbox.ActualHeight; return new Point(screenPoint.X * scaleX, screenPoint.Y * scaleY); }

3.3 自适应布局思路

Viewbox 适合"内容固定、整体缩放"的场景,但遇到数据表格、可折叠面板这类需要动态分配空间的控件,单纯缩放会浪费屏幕空间。更通用的做法是 Grid 混合布局:

  • 固定区域用固定像素,例如左侧树形菜单 260。
  • 伸缩区域用星号(*),例如中间图像区、底部日志区。
  • 文本字号不写死,而是基于一个全局缩放系数绑定。

全局缩放系数可以放在主 ViewModel 中:

public class MainViewModel : ObservableObject { private double _scaleFactor; public double ScaleFactor { get => _scaleFactor; private set => SetProperty(ref _scaleFactor, value); } public void UpdateScale(double actualWidth, double designWidth) { ScaleFactor = Math.Max(0.6, Math.Min(actualWidth / designWidth, 2.0)); } }

XAML 中可以这样用:

<TextBlock Text="产品名称" FontSize="{Binding ScaleFactor, Converter={StaticResource MultiplyConverter}, ConverterParameter=14}"/>

不过这种写法需要写一个乘法转换器,实际项目里可以用FontSize="{Binding ScaledFontSize}"的方式在 ViewModel 中直接计算好。

我的经验是:工控软件优先保证核心操作区域清晰可用,不要把每个控件都做成动态缩放。"适合缩放的缩放,适合自适应的自适应",两种方案混合使用比吊死在一个方案上更靠谱。

4. 底层事务:设备协作的核心机制

4.1 为什么设备参数写入需要排队

在多线程上位机中,UI 线程、采集线程、通信线程同时存在。假设相机采集线程正在执行"设置参数进行下一轮拍照",而此时用户点击按钮触发"修改曝光时间",两条指令就会在相机驱动内部打架,轻则参数无效,重则相机连接断开。

底层的解决办法不是加锁,而是把设备操作抽象成事务,并给每台设备准备一个独立的串行调度器。所有写操作通过调度器排队执行,读操作可以并行,但写操作必须严格串行。

4.2 事务接口设计

先定义两个基础接口,一个描述"单个事务",另一个描述"事务调度器":

// IndustrialHmi.Core/Transactions/IDeviceTransaction.cs public interface IDeviceTransaction<TResult> { string TransactionName { get; } Task<TResult> ExecuteAsync(CancellationToken ct); } // IndustrialHmi.Core/Transactions/ITransactionScheduler.cs public interface ITransactionScheduler { Task<TResult> EnqueueAsync<TResult>( IDeviceTransaction<TResult> transaction, CancellationToken ct = default); }

ExecuteAsync方法内部可以包含多个设备操作。调度器只负责保证同一个设备上的事务不会并发执行。

4.3 串行事务调度器实现

使用SemaphoreSlim实现一个简单的串行调度器:

// IndustrialHmi.Core/Transactions/SerialTransactionScheduler.cs public sealed class SerialTransactionScheduler : ITransactionScheduler { private readonly SemaphoreSlim _gate = new(1, 1); private readonly string _deviceKey; public SerialTransactionScheduler(string deviceKey) { _deviceKey = deviceKey; } public async Task<TResult> EnqueueAsync<TResult>( IDeviceTransaction<TResult> transaction, CancellationToken ct = default) { // 同一设备上的事务必须串行,这里用 SemaphoreSlim 限流 await _gate.WaitAsync(ct); try { Logger.Information("设备 {DeviceKey} 开始事务 {Name}", _deviceKey, transaction.TransactionName); var result = await transaction.ExecuteAsync(ct); Logger.Information("设备 {DeviceKey} 完成事务 {Name}", _deviceKey, transaction.TransactionName); return result; } catch (OperationCanceledException) { Logger.Warning("设备 {DeviceKey} 事务 {Name} 被取消", _deviceKey, transaction.TransactionName); throw; } catch (Exception ex) { Logger.Error(ex, "设备 {DeviceKey} 事务 {Name} 执行失败", _deviceKey, transaction.TransactionName); throw; } finally { _gate.Release(); } } }

实际项目中建议用工厂类管理调度器:

public interface ITransactionSchedulerFactory { ITransactionScheduler GetScheduler(string deviceKey); }

每台相机、每个 PLC 通道各持有一个SerialTransactionScheduler,这样相机参数写入和 PLC 数据下发互不阻塞,但同一个设备上的指令顺序有保障。

4.4 组合事务与回滚

下面以相机参数写入为例,演示"停止取流 → 写曝光 → 写增益 → 恢复取流"的完整事务:

// IndustrialHmi.Hardware/Camera/SetCameraParamsTransaction.cs public sealed class SetCameraParamsTransaction : IDeviceTransaction<bool> { private readonly CameraDevice _camera; private readonly int _exposure; private readonly double _gain; public string TransactionName => "设置相机曝光与增益"; public SetCameraParamsTransaction(CameraDevice camera, int exposure, double gain) { _camera = camera; _exposure = exposure; _gain = gain; } public async Task<bool> ExecuteAsync(CancellationToken ct) { // 先读取旧值,用于失败回滚 int oldExposure = await _camera.GetExposureAsync(ct); double oldGain = await _camera.GetGainAsync(ct); // 第一步:停止取流,防止写入过程中触发拍照 await _camera.StopStreamingAsync(ct); try { // 第二步:写入曝光值 await _camera.SetExposureAsync(_exposure, ct); // 第三步:写入增益值 await _camera.SetGainAsync(_gain, ct); // 第四步:恢复取流 await _camera.StartStreamingAsync(ct); return true; } catch (Exception ex) { // 写入失败时恢复旧参数,避免相机停留在未知状态 Logger.Error(ex, "相机参数写入失败,开始回滚"); try { await _camera.SetExposureAsync(oldExposure, ct); await _camera.SetGainAsync(oldGain, ct); await _camera.StartStreamingAsync(ct); } catch (Exception rollbackEx) { Logger.Error(rollbackEx, "相机参数回滚失败"); } throw; } } }

这种"记录旧值 → 执行新值 → 失败回滚"的模式,本质上是事务的补偿机制。它不依赖数据库,也不需要分布式事务中间件,纯粹靠业务代码保证设备状态一致性,在工控场景中非常实用。

5. 视觉采集:多相机回调区分与实时显示

5.1 工业相机回调里的 cameraKey 设计

机器视觉软件一定会遇到多相机场景。工业相机 SDK 通常采用回调机制:相机采集到一帧后,SDK 调用统一的回调函数。多个相机会共享同一个回调入口,如果不区分相机来源,图像数据就会串。

常见的解决方案是"回调注册时携带用户数据"。以 UVC 相机为例,可以自己封装一个CameraSession,每个实例持有独立的相机索引和 cameraKey,回调中始终携带这个 key:

// IndustrialHmi.Hardware/Camera/CameraSession.cs public sealed class CameraSession : IDisposable { private readonly int _cameraIndex; private readonly string _cameraKey; private readonly Action<Mat, string> _onFrame; private CancellationTokenSource _cts; private Thread _thread; public CameraSession(int cameraIndex, string cameraKey, Action<Mat, string> onFrame) { _cameraIndex = cameraIndex; _cameraKey = cameraKey; _onFrame = onFrame; } public void Start() { _cts = new CancellationTokenSource(); _thread = new Thread(Worker) { IsBackground = true, Name = $"Camera-{_cameraKey}" }; _thread.Start(); } private void Worker() { using var capture = new VideoCapture(_cameraIndex); if (!capture.IsOpened()) { Logger.Error("相机 {Key} (索引 {Index}) 打开失败", _cameraKey, _cameraIndex); return; } using var frame = new Mat(); while (!_cts.IsCancellationRequested) { if (capture.Read(frame) && !frame.Empty()) { // 这里把 cameraKey 作为第二个参数传出,外层不需要猜来源 _onFrame(frame.Clone(), _cameraKey); } } } public void Dispose() { _cts?.Cancel(); _cts?.Dispose(); } }

核心点在_onFrame(frame.Clone(), _cameraKey):相机索引和 cameraKey 在CameraSession初始化时绑定,回调外层拿到的是明确的标识,不会把 1 号相机的画面显示到 2 号窗口。

在 MainViewModel 中创建多个会话:

private void StartCameras() { var cam1 = new CameraSession(0, "Camera_1", HandleFrame); var cam2 = new CameraSession(1, "Camera_2", HandleFrame); _sessions.Add(cam1); _sessions.Add(cam2); cam1.Start(); cam2.Start(); }

5.2 Mat 转 BitmapSource 并更新界面

采集线程不能直接访问 UI 控件,需要通过 Dispatcher 切换到 UI 线程。这里要注意 Mat 的生命周期:OpenCV 的 Mat 在复用模式下,下一帧读取会覆盖上一帧内容,所以跨线程传递前必须 Clone。

private void HandleFrame(Mat frame, string cameraKey) { // frame 是由 CameraSession 克隆后的独立对象,这里负责释放 using (frame) { // 帧率控制:限制界面刷新频率,避免 Dispatcher 被图片更新占满 var now = DateTime.UtcNow; if ((now - _lastFrameTime) < TimeSpan.FromMilliseconds(50)) { return; } _lastFrameTime = now; // 跨线程投递到 UI 线程 var cloned = frame.Clone(); _dispatcher.BeginInvoke(new Action(() => { var bitmap = MatToBitmapSource(cloned); Cameras[cameraKey].FrameImage = bitmap; cloned.Dispose(); })); } }

Mat 转 WriteableBitmap 的工具方法:

// IndustrialHmi.Hardware/Imaging/BitmapConvert.cs public static BitmapSource MatToBitmapSource(Mat mat) { if (mat == null || mat.Empty()) { return null; } var rgbMat = new Mat(); if (mat.Channels() == 3) { Cv2.CvtColor(mat, rgbMat, ColorConversionCodes.BGR2RGB); } else { rgbMat = mat.Clone(); } try { int stride = (int)rgbMat.Step; int bufferSize = stride * rgbMat.Rows; var buffer = new byte[bufferSize]; Marshal.Copy(rgbMat.Data, buffer, 0, bufferSize); var bitmap = new WriteableBitmap( rgbMat.Width, rgbMat.Height, 96, 96, rgbMat.Channels() == 3 ? PixelFormats.Rgb24 : PixelFormats.Gray8, null); bitmap.WritePixels( new Int32Rect(0, 0, rgbMat.Width, rgbMat.Height), buffer, stride, 0); return bitmap; } finally { rgbMat.Dispose(); } }

关于 3D 视觉:如果项目涉及 3D 点云显示,WPF 可以通过 HelixToolkit 或 OpenGL 控件承接点云渲染,采集层仍然沿用这套"设备会话 + 事务调度"的框架,只是把 Mat 换成点云数据结构。架构上不用推倒重来。

5.3 异步命令与取消

上位机软件的点名"启动"“停止”,不应该卡住 UI。用简单的 AsyncCommand 封装异步操作:

// IndustrialHmi.Core/Mvvm/AsyncRelayCommand.cs public sealed class AsyncRelayCommand : ICommand { private readonly Func<CancellationToken, Task> _execute; public AsyncRelayCommand(Func<CancellationToken, Task> execute) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); } public event EventHandler CanExecuteChanged { add { CommandManager.RequerySuggested += value; } remove { CommandManager.RequerySuggested -= value; } } public bool CanExecute(object parameter) => true; public async void Execute(object parameter) { using var cts = new CancellationTokenSource(); try { await _execute(cts.Token); } catch (OperationCanceledException) { } } }

在 ViewModel 中的用法:

public class MainViewModel : ObservableObject { public ICommand StartCommand { get; } public ICommand StopCommand { get; } public IAsyncRelayCommand WriteCameraParamsCommand { get; } public MainViewModel() { StartCommand = new RelayCommand(_ => StartProduction()); StopCommand = new RelayCommand(_ => StopProduction()); WriteCameraParamsCommand = new AsyncRelayCommand(async ct => { var scheduler = _schedulerFactory.GetScheduler("Camera_1"); var transaction = new SetCameraParamsTransaction(_camera1, Exposure, Gain); await scheduler.EnqueueAsync(transaction, ct); }); } }

这里WriteCameraParamsCommand通过调度器排队,保证和采集线程、其他界面操作之间不冲突。

6. 数据持久化:SqlBulkCopy 批量入库

检测结果通常需要批量写入数据库。逐条 Insert 在数据量大时性能很差,SqlBulkCopy 是常见方案。

// IndustrialHmi.Data/InspectionRecordRepository.cs public sealed class InspectionRecordRepository { private readonly string _connectionString; public InspectionRecordRepository(string connectionString) { _connectionString = connectionString; } public async Task BulkInsertAsync( IReadOnlyList<InspectionRecord> records, CancellationToken ct = default) { if (records == null || records.Count == 0) { return; } using var table = new DataTable(); table.Columns.Add("Id", typeof(Guid)); table.Columns.Add("ProductCode", typeof(string)); table.Columns.Add("CameraKey", typeof(string)); table.Columns.Add("Result", typeof(int)); table.Columns.Add("DefectType", typeof(string)); table.Columns.Add("CreatedAt", typeof(DateTime)); foreach (var record in records) { var row = table.NewRow(); row["Id"] = record.Id; row["ProductCode"] = record.ProductCode; row["CameraKey"] = record.CameraKey; row["Result"] = (int)record.Result; row["DefectType"] = record.DefectType; row["CreatedAt"] = record.CreatedAt; table.Rows.Add(row); } using var bulk = new SqlBulkCopy( _connectionString, SqlBulkCopyOptions.UseInternalTransaction) { DestinationTableName = "dbo.InspectionRecord", BatchSize = 1000, BulkCopyTimeout = 60 }; foreach (DataColumn column in table.Columns) { bulk.ColumnMappings.Add(column.ColumnName, column.ColumnName); } await bulk.WriteToServerAsync(table, ct); } }

需要注意两点:UseInternalTransaction表示每次批量写入内部使用事务,一批失败不影响之前已提交的数据;ColumnMappings必须显式指定,否则 SqlBulkCopy 按列顺序匹配,一旦表结构调整就报错。

还有一个容易踩的坑:目标表结构变动。如果dbo.InspectionRecord表中新增了 NOT NULL 且无默认值的列、删除了列、或者修改了列类型,SqlBulkCopy 很可能抛异常。生产环境如果有 DBA 或同事直接改表结构,代码不会自动适配。建议在程序启动时做一次表结构校验,或者在数据库变更流程中加入对程序版本影响的评估。

7. 常见问题与排查思路

问题现象常见原因解决思路
WPF 界面在高分屏模糊未开启 DPI 感知或使用默认缩放在 app.manifest 中声明 PerMonitorV2,并设置 TextFormattingMode
Viewbox 中点击位置不准确鼠标坐标是屏幕坐标,未换算到设计坐标根据 Viewbox 实际尺寸和设计尺寸计算转换比例
相机打开失败相机被其他程序占用、USB 带宽不足、权限问题先关闭其他取流软件,检查设备管理器,确认权限
多相机画面串帧回调中未区分相机来源每次回调都携带 cameraKey,禁止使用静态共享变量
UI 严重卡顿高帧率图片频繁 Dispatcher 调用限制刷新频率,例如 20fps 上限,使用 WriteableBitmap 复用
参数写入后相机掉线写入过程中被其他线程打断将多步写入封装为事务,放入串行调度器
SqlBulkCopy 报 Schema 错误目标表结构被修改检查表结构差异,更新 ColumnMappings,做好版本管理

排查思路可以按这个顺序:先看日志,确认是"界面问题"“通信问题”还是"数据库问题";再复现,工控项目最怕偶现,复现时要带相机索引、PLC 状态、线程快照;最后修改,修改前拍照记录界面和各设备参数状态,便于回滚验证。

8. 最佳实践与工程建议

8.1 分层与命名规范

工控项目建议按功能分包,最少四层:界面、业务、硬件、数据。层之间通过接口依赖,避免跨层引用。例如 ViewModel 不直接 new 一个相机对象,而是通过ICameraFactory或依赖注入获得。

命名上建议带约束:

  • 相机:Camera_1、Camera_2,不叫cam1。
  • 事务:SetCameraParamsTransaction,一眼能看出作用。
  • 命令:WriteCameraParamsCommand,不要省略核心动词。

8.2 日志与异常处理

上位机跑在生产现场,没有日志等于裸奔。建议引入结构化日志,至少记录:

  • 设备连接和断开。
  • 每笔事务的开始、结束、失败回滚。
  • 相机启停、参数修改。
  • 报警触发和恢复。

日志文件要按大小或日期滚动,保留至少 30 天。不要直接 catch 后吞异常,也不要用Console.WriteLine输出到控制台,因为工控机通常没人盯着控制台窗口。

8.3 安全边界与生产环境变更

涉及 PLC 写值、数据库删除、相机固件升级这类操作时,必须遵循最小权限原则:上位机程序使用专用账号,不要用管理员账号跑业务;数据库连接账号只授予业务所需权限;删除或批量变更前先备份数据,最好在测试环境完整模拟一次。

生产环境变更参数时,先写历史值再写新值,变更后通知操作员确认,这一点和"事务回滚"是同一个理念。

8.4 硬件模拟器

开发初期没有真实 PLC 和相机时,建议为每个设备接口写一个Simulator实现。这样 UI 开发、流程调试完全可以在普通电脑上完成。使用模拟器不是自欺欺人,而是让硬件问题只在硬件联调阶段集中爆发,避免"界面 Bug 和硬件 Bug 混在一起"。

8.5 性能要点

  • 图像显示用 WriteableBitmap 而不是反复创建 BitmapImage,后者容易引起内存抖动。
  • 相机回调里不要做图像处理,只做拷贝和转发,算法处理放到独立线程池。
  • 不要每帧都刷新整棵树或整个表格,界面更新尽量按需触发。
  • SqlBulkCopy 的 BatchSize 根据行大小调整,建议现场压测,不要套用网上固定值。

如果你正准备从 WinForms 转 WPF、或者已经在写上位机但被 UI 适配和多相机问题反复折腾,可以先把这套"设备会话 + 事务调度 + ViewModel 刷新"的框架跑通。下一个项目不管是用 3D 视觉点云,还是接多台施耐德变频器,底层思路都是通用的。后面我还会继续拆解 MVVM 模式下 WPF 的通信层设计和 PLC 多设备调度实战,有实际项目经验的朋友欢迎一起交流踩坑心得。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询