ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WPF工业上位机架构实战:UI适配、事务调度与多相机处理

WPF工业上位机架构实战:UI适配、事务调度与多相机处理 做工业级 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用于相机采集和图像处理演示。数据库访问SqlBulkCopySystem.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 version1.0 encodingutf-8? assembly manifestVersion1.0 xmlnsurn:schemas-microsoft-com:asm.v1 application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness /windowsSettings /application /assembly声明之后WPF 会在每个显示器上按真实 DPI 重新布局。这里要注意部分老型号工控机显卡驱动不支持 PerMonitorV2实际项目中需要在清晰显示和老设备兼容之间做取舍必要时可以只声明系统 DPI Aware。为了让文字在高分屏上也清晰可以在主窗口上设置渲染质量Window ... TextOptions.TextFormattingModeDisplay TextOptions.TextRenderingModeClearType RenderOptions.BitmapScalingModeHighQuality /WindowTextFormattingModeDisplay会让小字号文字更清晰配合 ClearType 在工控屏幕上效果更好。3.2 Viewbox 整体缩放方案如果项目是面向固定设计分辨率的比如领导指定只做 1920x1080其他屏幕等比缩放最简单有效的方案是用 Viewbox 包一层 Canvas 或 Grid。Viewbox StretchUniform StretchDirectionBoth Grid Width1920 Height1080 Grid.RowDefinitions RowDefinition Height60/ RowDefinition Height*/ RowDefinition Height160/ /Grid.RowDefinitions Border Grid.Row0 Background#2C3E50 StackPanel OrientationHorizontal Margin12,0 VerticalAlignmentCenter TextBlock Text工业视觉检测系统 FontSize20 ForegroundWhite/ TextBlock Text{Binding CurrentUser} Margin24,0,0,0 FontSize14 Foreground#BDC3C7/ /StackPanel /Border Grid Grid.Row1 Grid.ColumnDefinitions ColumnDefinition Width260/ ColumnDefinition Width*/ ColumnDefinition Width360/ /Grid.ColumnDefinitions Border Grid.Column0 Background#ECF0F1 Padding8 TreeView ItemsSource{Binding DeviceTree}/ /Border Grid Grid.Column1 Image Source{Binding CurrentFrame} StretchUniform Margin8/ /Grid Border Grid.Column2 Background#F8F9FA Padding8 StackPanel TextBlock Text参数设置 FontSize16 FontWeightBold/ TextBox Text{Binding ExposureValue}/ Button Content写入相机参数 Command{Binding WriteCameraParamsCommand} Margin0,12,0,0 Height36/ /StackPanel /Border /Grid Border Grid.Row2 Background#34495E Padding8 TextBox Text{Binding RuntimeLog} ForegroundWhite FontFamilyConsolas AcceptsReturnTrue IsReadOnlyTrue/ /Border /Grid /ViewboxViewbox 会把内部 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}, ConverterParameter14}/不过这种写法需要写一个乘法转换器实际项目里可以用FontSize{Binding ScaledFontSize}的方式在 ViewModel 中直接计算好。我的经验是工控软件优先保证核心操作区域清晰可用不要把每个控件都做成动态缩放。适合缩放的缩放适合自适应的自适应两种方案混合使用比吊死在一个方案上更靠谱。4. 底层事务设备协作的核心机制4.1 为什么设备参数写入需要排队在多线程上位机中UI 线程、采集线程、通信线程同时存在。假设相机采集线程正在执行设置参数进行下一轮拍照而此时用户点击按钮触发修改曝光时间两条指令就会在相机驱动内部打架轻则参数无效重则相机连接断开。底层的解决办法不是加锁而是把设备操作抽象成事务并给每台设备准备一个独立的串行调度器。所有写操作通过调度器排队执行读操作可以并行但写操作必须严格串行。4.2 事务接口设计先定义两个基础接口一个描述单个事务另一个描述事务调度器// IndustrialHmi.Core/Transactions/IDeviceTransaction.cs public interface IDeviceTransactionTResult { string TransactionName { get; } TaskTResult ExecuteAsync(CancellationToken ct); } // IndustrialHmi.Core/Transactions/ITransactionScheduler.cs public interface ITransactionScheduler { TaskTResult EnqueueAsyncTResult( IDeviceTransactionTResult 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 TaskTResult EnqueueAsyncTResult( IDeviceTransactionTResult 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 : IDeviceTransactionbool { 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 Taskbool 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 ActionMat, string _onFrame; private CancellationTokenSource _cts; private Thread _thread; public CameraSession(int cameraIndex, string cameraKey, ActionMat, 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 FuncCancellationToken, Task _execute; public AsyncRelayCommand(FuncCancellationToken, 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( IReadOnlyListInspectionRecord 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并设置 TextFormattingModeViewbox 中点击位置不准确鼠标坐标是屏幕坐标未换算到设计坐标根据 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 多设备调度实战有实际项目经验的朋友欢迎一起交流踩坑心得。
返回列表