简介面向物联网工控可视化开发者的 C# WPF 大屏看板源代码围绕 Modbus TCP 协议实现设备数据采集、实时刷新与图表化展示适合具备一定 WPF、C# 基础并希望掌握工控大屏落地实现的中级开发者参考。压缩包共 453 个文件、约 3.06MB以 cs 源码、xaml 界面、dll 依赖库、json 配置及 cache 构建缓存为主同时含有 exe、sln 和项目说明文档便于直接打开工程梳理模块关系与编译流程。已有 1557 人学习下载。代码涉及 Modbus TCP 通信封装、数据清洗转换、数据绑定、MVVM 分层、仪表盘和看板布局、定时刷新、异常处理与性能测试等环节可对照学习设备数据接入、界面组织、运行调试和问题排查。其样式模板、Storyboard 动画及 Dispatcher 更新机制也可为完善大屏交互与实时性提供可复用思路项目结构、通信参数配置、数据模型与视图模型的组织方式亦有参考价值。1. 从一条卡死的产线看板说起凌晨两点班组群里弹出一条消息大屏上的产能数字停在 1280 不动了现场以为设备停机。远程连上去翻日志Modbus TCP 那一侧全是读超时UI 线程被同步等待拖住整个看板进入事实上的假死状态。这个场景基本能概括 C# WPF 做物联网工控大数据大屏看板最容易翻车的地方数据可视化只是最后一公里前面还得有一条扛得住断线、错帧和字节序的 Modbus TCP 采集链路。这套源代码把两层捏在一个工程里WPF 负责大屏渲染与交互后台服务按固定节拍轮询 PLC 和仪表适合做产线看板、能耗监控、设备状态墙的上位机开发者也适合刚接手工控项目、需要一份能跑起来的骨架的人。2. 工程骨架与目录结构MVVM 把采集和界面彻底拆开2.1 分层方式与目录长什么样工控上位机最常见的坏味道是把 Modbus 读写直接写在窗体的按钮事件里一开始只有三个点位时看不出问题等到点位涨到两百个、还要加历史曲线和报警代码就会变成一团没法测的东西。这套工程的拆法是按依赖方向切的Core 层只依赖 Modbus 通讯库和 JSON 序列化一行 WPF 的引用都没有App 层才引入 WPF负责窗口、样式和绑定。好处很直接现场怀疑某个寄存器解析不对时可以把 Core 单独拎出来写个控制台程序跑一遍不用启动整个大屏。IotDashboard/ ├─ IotDashboard.sln ├─ src/ │ ├─ IotDashboard.App/ # WPF 主程序 │ │ ├─ App.xaml / App.xaml.cs # 启动装配 │ │ ├─ Views/ │ │ │ ├─ MainWindow.xaml │ │ │ └─ DashboardView.xaml # 1920x1080 设计分辨率的大屏主体 │ │ ├─ ViewModels/ │ │ │ ├─ DashboardViewModel.cs │ │ │ └─ DeviceCardViewModel.cs │ │ ├─ Controls/ # 仪表盘、状态灯、数码管等自绘控件 │ │ ├─ Themes/ # 深色主题与报警色阶 │ │ └─ Assets/ │ ├─ IotDashboard.Core/ # 采集与业务无 UI 依赖 │ │ ├─ Modbus/ │ │ │ ├─ ModbusTcpClient.cs # 连接、超时、重连 │ │ │ ├─ ModbusPoller.cs # 轮询调度与结果分发 │ │ │ └─ ByteOrder.cs # 字节序/字序重组 │ │ ├─ Models/ │ │ │ ├─ PointConfig.cs │ │ │ └─ Sample.cs │ │ ├─ Services/ │ │ │ ├─ PointConfigLoader.cs │ │ │ └─ AlarmEvaluator.cs │ │ └─ Storage/ │ │ └─ SampleRepository.cs │ └─ IotDashboard.Tests/ └─ config/ └─ points.json # 点位表现场改这个文件即可这个结构里有两个约束值得说明。第一IotDashboard.Core的 csproj 里不要出现任何UseWPF为 true 的引用否则很快就会有Dispatcher混进采集逻辑后面想把这套采集搬到 Windows 服务里就搬不动了。第二config/points.json放在可执行文件旁边的 config 目录而不是打进程序集资源现场工程师改点位不需要重新编译这是工控项目里省掉大量返工的一条血泪经验。2.2 启动装配与 MVVM 绑定骨架很多 WPF 项目一上来就挂 Prism 或者完整的 DI 容器对小规模看板其实是负担。这套代码用的是手工装配入口在App.xaml.cs装配顺序是配置 → 通讯客户端 → 轮询器 → ViewModel → 窗口整个过程一目了然出问题的时候断点打在哪很清楚。// App.xaml.cs —— 手工装配避免为一个看板引入完整 DI 框架 protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 1. 读点位表主机、端口、超时、点位列表全在这里 var config PointConfigLoader.Load(config/points.json); // 2. 通讯客户端只负责连接与单次读写不含调度 var client new ModbusTcpClient(config.Host, config.Port, config.TimeoutMs); // 3. 轮询器按 intervalMs 的节拍调度读取结果通过事件抛出 var poller new ModbusPoller(client, config.Points, intervalMs: 200); // 4. ViewModel 订阅轮询结果窗口只认 DataContext var vm new DashboardViewModel(poller, new AlarmEvaluator(config.Points)); var window new MainWindow { DataContext vm }; window.Show(); // 5. 采集异步启动UI 线程不等它避免启动时白屏 _ poller.StartAsync(); }这里intervalMs: 200意味着 5Hz 轮询是工控大屏里比较稳妥的取值刷新肉眼够快同时对 PLC 的通讯负载可控。config.TimeoutMs一般设 1000 到 3000 之间设太小会在网络抖动时疯狂超时设太大则断线后要等很久才能感知。第 5 步用_ 丢弃 Task 是刻意的采集循环内部自己兜异常不让一个未观察的 Task 异常把进程带崩。ViewModel 侧用 CommunityToolkit.Mvvm 的源生成器写法属性声明短绑定路径清晰// DashboardViewModel.cs 片段 public partial class DashboardViewModel : ObservableObject { [ObservableProperty] private double _spindleSpeed; // 绑定到仪表盘指针 [ObservableProperty] private int _outputCount; // 绑定到数码管 [ObservableProperty] private bool _isOnline; // 绑定到通讯状态灯 private readonly ModbusPoller _poller; public DashboardViewModel(ModbusPoller poller, AlarmEvaluator alarm) { _poller poller; // 注意这个事件在采集线程上触发不能直接改绑定属性 _poller.SamplesReady OnSamplesReady; } private void OnSamplesReady(object? sender, IReadOnlyListSample samples) { // 交给 UI 线程后再更新属性Dispatcher 部分见第 3 章 Application.Current.Dispatcher.Invoke(() { foreach (var s in samples) { switch (s.Key) { case spindleSpeed: SpindleSpeed s.Value; break; case outputCount: OutputCount (int)s.Value; break; } } IsOnline _poller.IsConnected; }); } }[ObservableProperty]会在编译期生成SpindleSpeed属性和PropertyChanged通知XAML 里直接写{Binding SpindleSpeed}就行。Sample是一个极简结构包含Key、Value、Timestamp三个字段Key与点位表里的key一一对应这层映射做完之后 UI 就完全不关心寄存器地址了。2.3 点位配置化把寄存器地址从代码里赶出去现场最常见的需求变更不是改界面而是加一个点位。如果地址写死在 ViewModel 里每加一个传感器就要重新发版产线还不一定允许你停。把这部分抽成 JSON 之后加点位就是编辑文本文件再重启看板。{ host: 192.168.1.10, port: 502, timeoutMs: 1500, points: [ { key: spindleSpeed, name: 主轴转速, slaveId: 1, address: 0, type: UInt16, scale: 1, unit: rpm, alarmHigh: 8000, alarmLow: 0 }, { key: totalPower, name: 总功率, slaveId: 1, address: 10, type: Float32, wordOrder: CDAB, scale: 0.1, unit: kW, alarmHigh: 120 }, { key: outputCount, name: 累计产量, slaveId: 1, address: 20, type: UInt32, wordOrder: ABCD, scale: 1, unit: pcs } ] }几个字段的实际含义要交代清楚address填的是协议地址不是手册上常见的 40001 那种编号两者的差值 1 是新手最容易踩的坑下一章会细说。scale是工程量系数寄存器里存的是放大十倍的整数、显示时要除以十的情况在仪表里非常普遍这里做成乘法因子0.1就表示原值乘 0.1。wordOrder只对 32 位类型生效ABCD是高字在前CDAB是低字在前不同品牌的 PLC 默认值不一样试错的时候先改这个字段比改代码快。alarmHigh和alarmLow交给AlarmEvaluator消费越限时把对应的卡片状态改成告警色这块逻辑和采集解耦后面加声光报警或者推送都从这里挂。配置加载用一个带默认值的反序列化缺字段时不要抛异常宁可让看板带着默认值起来// PointConfigLoader.cs public static PointConfig Load(string path) { if (!File.Exists(path)) throw new FileNotFoundException($点位配置缺失{path}); var json File.ReadAllText(path); var cfg JsonSerializer.DeserializePointConfig(json, new JsonSerializerOptions { PropertyNameCaseInsensitive true }); // 兜底端口和超时给默认值现场写漏了也能起来 cfg ?? new PointConfig(); cfg.Port cfg.Port 0 ? 502 : cfg.Port; cfg.TimeoutMs cfg.TimeoutMs 0 ? 1500 : cfg.TimeoutMs; return cfg; }3. Modbus TCP 采集链路从建连到寄存器解析的完整实现3.1 连接管理、超时与断线重连工控现场的网络没有实验室那么听话交换机端口松动、设备重启、IP 冲突都会让连接断掉。通讯层如果只做一次Connect就再也不管看板会安静地停在最后一帧数据上这种“静默失效”比报错更难查。这套代码把连接状态显式暴露出来并且把超时压在通讯层不让它传导到 UI。// ModbusTcpClient.cs public sealed class ModbusTcpClient : IDisposable { private readonly string _ip; private readonly int _port; private readonly int _timeoutMs; private TcpClient? _tcp; private IModbusMaster? _master; public bool IsConnected _tcp?.Connected true _master is not null; public async Taskbool ConnectAsync(CancellationToken ct default) { try { _tcp new TcpClient(); // 连接本身也要超时否则拔网线时会挂很久 using var cts CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_timeoutMs); await _tcp.ConnectAsync(_ip, _port, cts.Token); _master ModbusIpMaster.CreateIp(_tcp); _master.Transport.ReadTimeout _timeoutMs; _master.Transport.WriteTimeout _timeoutMs; _master.Transport.Retries 1; // 重试次数别贪多会放大阻塞 return true; } catch (Exception) { DisposeConnection(); return false; } } }Retries 1是刻意的保守值。设成 3 的时候一次读失败要经历三轮超时按 1500ms 算就是 4.5 秒这期间如果轮询器还在排队任务会越堆越多。重连策略放在轮询器里做而不是在客户端内部做无限重试这样上层能感知到“这一轮没数据”把看板切到离线态而不是假装正常。3.2 批量读取与字节序解析Modbus 单次读保持寄存器的数量上限是 125 个这是协议规定的超了设备会直接回异常码。实际工程里点位往往分散在不同地址段正确做法是按地址排序后合并连续区间把多次小读取合并成少量大读取。合并逻辑本身不复杂但能显著降低 PLC 的通讯负担。// ModbusPoller.cs —— 把点位按地址排序后合并连续段减少请求次数 private static List(ushort Start, ushort Count) BuildBlocks( IEnumerablePointConfig points, ushort maxPerRead 120) { var blocks new List(ushort, ushort)(); // 只处理占 1 个寄存器的点位32 位类型单独按 2 个寄存器算 var ranges points .Select(p (Start: p.Address, Len: p.Type is Float32 or UInt32 ? 2 : 1)) .OrderBy(r r.Start) .ToList(); ushort curStart ranges[0].Start, curEnd (ushort)(ranges[0].Start ranges[0].Len); foreach (var r in ranges.Skip(1)) { // 地址相邻且合并后不超限就并进当前块 if (r.Start curEnd 2 (r.Start r.Len - curStart) maxPerRead) { curEnd (ushort)Math.Max(curEnd, r.Start r.Len); } else { blocks.Add((curStart, (ushort)(curEnd - curStart))); curStart r.Start; curEnd (ushort)(r.Start r.Len); } } blocks.Add((curStart, (ushort)(curEnd - curStart))); return blocks; }r.Start curEnd 2里的2是允许的空洞大小中间空一两个寄存器不值得再发一次请求读回来丢掉就行。maxPerRead取 120 而不是 125是给某些实现不严格的设备留点余量这个数字是我在几个现场被异常码教育之后固定下来的。读回来的原始寄存器是ushort[]32 位浮点要按字序重组这一步错了会出现1.7e38这种明显不合理的数值或者更隐蔽的、看起来像正常但差了一个数量级的值// ByteOrder.cs —— 32 位浮点的字序重组 public static float ToFloat32(ushort first, ushort second, string wordOrder) { // CDAB 表示低字在前先交换 ushort hi wordOrder CDAB ? second : first; ushort lo wordOrder CDAB ? first : second; // Modbus 传输时是高字节在前这里按大端拼字节再交给 BitConverter var bytes new[] { (byte)(hi 8), (byte)(hi 0xFF), (byte)(lo 8), (byte)(lo 0xFF) }; if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); } // UInt32 同理只是最后一步不同 public static uint ToUInt32(ushort first, ushort second, string wordOrder) { ushort hi wordOrder CDAB ? second : first; ushort lo wordOrder CDAB ? first : second; return ((uint)hi 16) | lo; }BitConverter.IsLittleEndian这个判断不能省虽然绝大多数工控机都是小端但代码里显式处理一次的成本很低。解析完的结果乘上配置里的scale再和key一起装进Sample这一轮采集就算完成。3.3 采集节拍与 UI 线程的数据交接采集线程和 UI 线程的交接是整个链路里最容易出事的地方。采集频率 5HzUI 只需要 2 到 3Hz 的有效刷新中间差出来的数据全部丢掉也不影响看板观感。所以中间应该有一个只保留最新值的缓冲而不是把每一帧都往ObservableCollection里塞。// ModbusPoller.cs —— 固定节拍轮询异常不中断循环 public async Task StartAsync(CancellationToken ct default) { while (!ct.IsCancellationRequested) { var sw Stopwatch.StartNew(); try { if (!_client.IsConnected !await _client.ConnectAsync(ct)) { RaiseOffline(); await Task.Delay(2000, ct); // 重连退避避免重连风暴 continue; } var blocks BuildBlocks(_points); var result new ListSample(_points.Count); foreach (var (start, count) in blocks) { // 同步 API 放到线程池避免阻塞采集循环的调度精度 var regs await Task.Run(() _client.ReadHolding(_slaveId, start, count), ct); foreach (var p in _points.Where(p p.Address start p.Address start count)) { result.Add(Parse(p, regs, start)); } } SamplesReady?.Invoke(this, result); } catch (Exception ex) { _logger.Warn($采集异常{ex.Message}); _client.Dispose(); // 出错就断开下一轮重连 } // 补齐节拍本轮耗时从间隔里扣掉保持稳定频率 var delay _intervalMs - (int)sw.ElapsedMilliseconds; if (delay 0) await Task.Delay(delay, ct); } }Stopwatch扣减耗时这一步很关键。如果只写await Task.Delay(200)实际周期会变成 200ms 加本轮读取耗时点位多的时候周期会漂到 500ms 以上曲线看起来就会一卡一卡的。SamplesReady事件在采集线程触发ViewModel 里必须通过Dispatcher.Invoke或Dispatcher.BeginInvoke切回 UI 线程否则改绑定属性时会抛跨线程访问异常。这里用BeginInvoke比Invoke更合适异步投递不会让采集线程等 UI 画完。4. 大屏可视化与自适应布局数据怎么稳稳地刷到界面上4.1 设计分辨率与 Viewbox 等比缩放工控大屏的物理分辨率五花八门1920×1080、2560×1440、3840×2160 都见过还有拼接屏做的超宽比例。如果按百分比布局去适配每换一块屏就要调一次间距和字号维护成本很高。这套代码用的是固定设计分辨率加整体缩放所有控件按 1920×1080 摆放外层套一个Viewbox宿主窗口多大就缩放到多大。!-- DashboardView.xaml 根节点 -- Viewbox StretchUniform StretchDirectionBoth Grid Width1920 Height1080 Background#0B1220 Grid.RowDefinitions RowDefinition Height120/ RowDefinition Height*/ RowDefinition Height220/ /Grid.RowDefinitions !-- 顶部标题、时间、通讯状态灯 -- local:HeaderBar Grid.Row0/ !-- 中部四个设备卡片 一条趋势曲线 -- UniformGrid Grid.Row1 Columns4 Margin24 local:DeviceCard DataContext{Binding Cards[0]}/ local:DeviceCard DataContext{Binding Cards[1]}/ local:DeviceCard DataContext{Binding Cards[2]}/ local:DeviceCard DataContext{Binding Cards[3]}/ /UniformGrid !-- 底部实时趋势图 -- local:TrendChart Grid.Row2 Margin24 Series{Binding TrendSeries}/ /Grid /ViewboxStretchUniform保证不变形StretchDirectionBoth允许在小窗口里也缩小预览方便开发时不用全屏调试。这套方案的代价是文字会跟着缩放在 4K 屏上如果系统缩放不是 100%字体边缘会发虚。缓解办法是在App.xaml.cs里声明 DPI 感知模式让 WPF 按物理像素渲染而不是让系统做位图拉伸// App.xaml.cs —— 声明 Per-Monitor DPI 感知避免 4K 屏文字发虚 protected override void OnStartup(StartupEventArgs e) { // 必须在任何窗口创建之前调用 RenderOptions.ProcessRenderMode RenderMode.Default; base.OnStartup(e); }严格来说 DPI 感知是通过应用程序清单声明的代码里的ProcessRenderMode只是关掉软件渲染回退。清单里加dpiAwareness为PerMonitorV2之后Viewbox的缩放才会落在矢量层字才不会糊。4.2 趋势图与实时刷新的节流趋势图用 LiveCharts2 的LineSeriesDateTimePoint它是 SkiaSharp 渲染的几万点以内性能都够看。真正要控制的是点数一个 5Hz 的采集跑 8 小时就是 14 万个点不裁剪的话内存和渲染都会出问题。// DashboardViewModel.cs —— 环形窗口只保留最近 300 个点 private const int MaxPoints 300; private void AppendPoint(DateTime time, double value) { // ObservableCollection 的增删必须回到 UI 线程 Points.Add(new DateTimePoint(time, value)); // 超出窗口就从头削保证集合长度恒定 while (Points.Count MaxPoints) Points.RemoveAt(0); } // 曲线配置里关掉动画5Hz 刷新开动画会明显掉帧 public ISeries[] TrendSeries { get; } new ISeries[] { new LineSeriesDateTimePoint { Values Points, GeometrySize 0, LineSmoothness 0.3, AnimationsSpeed TimeSpan.Zero, Fill null } };GeometrySize 0去掉数据点标记只留线视觉更干净也更快。AnimationsSpeed TimeSpan.Zero是必须的LiveCharts2 默认每次更新都做插值动画高频刷新时动画队列会积压表现出来就是曲线越跑越慢、最后像卡住一样。LineSmoothness给 0.3 左右太高会让曲线看起来过度平滑反而不像真实的采样数据。节流放在 ViewModel 这一侧采集侧给多少帧都先存进一个字段UI 定时器按 300ms 的间隔取最新值刷新图表// 用一个 DispatcherTimer 控制 UI 刷新率与采集频率解耦 private readonly DispatcherTimer _uiTimer new() { Interval TimeSpan.FromMilliseconds(300) }; private void OnUiTick(object? sender, EventArgs e) { var latest _latestSamples; // 采集线程写入的最新一帧 if (latest is null) return; AppendPoint(DateTime.Now, latest.SpindleSpeed); IsOnline _poller.IsConnected; }这种“采集快、渲染慢”的分离在大屏项目里基本是标配采集频率决定数据精度UI 频率决定观感两者没有必要一致。4.3 状态灯、仪表盘与报警色阶看板上的颜色不是装饰是信息层级。这套代码把颜色收敛成三档正常用青色、预警用琥珀、告警用红色背景统一深色避免车间强光下反光看不清。状态灯的绑定直接吃IsOnline和AlarmEvaluator的输出!-- 状态灯用 DataTrigger 切换填充色不用转换器 -- Ellipse Width16 Height16 Ellipse.Style Style TargetTypeEllipse Setter PropertyFill Value#3DDC97/ !-- 正常 -- Style.Triggers DataTrigger Binding{Binding IsOnline} ValueFalse Setter PropertyFill Value#FF5C5C/ !-- 离线 -- /DataTrigger DataTrigger Binding{Binding IsWarning} ValueTrue Setter PropertyFill Value#FFB020/ !-- 预警 -- /DataTrigger /Style.Triggers /Style /Ellipse.Style /Ellipse用DataTrigger而不是值转换器原因是现场调试时在 XAML 里能直接看到条件改色值不用翻 C# 代码。仪表盘这类控件建议自己用Path加RotateTransform画第三方仪表库的样式往往太重而且缩放之后刻度文字容易重叠自绘的可控性更好。5. 联调排查五个把新手卡住的现场问题5.1 从零跑起来的最小步骤先把环境对齐再谈调试。这套代码用的是 .NET 6 以上的 WPF需要 Visual Studio 2022 或者dotnetCLI 配合 Windows SDK。通讯库在IotDashboard.Core.csproj里以 NuGet 包形式引入还原之后就能编译。# 1. 还原依赖 dotnet restore src/IotDashboard.App/IotDashboard.App.csproj # 2. 编译WPF 项目需要指定 Windows 目标框架 dotnet build src/IotDashboard.App/IotDashboard.App.csproj -c Debug # 3. 先改 config/points.json 里的 host 和 port指向你的设备或模拟器 # 没有真机时可以在本机起一个 Modbus 从站模拟器从站地址填 1 # 4. 启动 dotnet run --project src/IotDashboard.App/IotDashboard.App.csproj启动后先看左上角的通讯状态灯。绿灯说明 TCP 连上了并且至少读成功过一轮红灯或者黄灯就要按下面的顺序排查先确认ping通不通再用 Modbus 调试工具单独读一次目标地址最后才怀疑代码。这个顺序能省掉大量时间因为大部分问题出在网络和地址配置上不在程序本身。5.2 五个高频坑的现象、原因与解决坑一地址差 1读到的永远是隔壁寄存器。现象是数值看着像正常范围但和设备的本地显示对不上或者读出来是个恒定值。原因是设备手册上标的40001是保持寄存器的人为编号协议里的实际地址是 0两者差 1。解决方式是在points.json里统一填协议地址配置项注释里写清楚“此处为协议地址非手册编号”避免下一个人再踩。坑二32 位浮点读出来是 1.7e38 或者 0。现象是功率、温度这类浮点量全是离谱数值。原因基本是字序不对设备是高字在前ABCD配置里写了 CDAB或者反过来。解决方式是先临时把wordOrder两个值都试一遍看哪个落在合理区间更稳的办法是拿设备本地显示的值反推比如本地显示 12.5读到的原始寄存器是0x0000和0x41A4那就说明低字是0x0000、高字是0x41A4字序是 CDAB。坑三跨线程更新集合直接抛异常。现象是运行几秒后崩溃异常信息里带This type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread。原因是采集线程直接往绑定的ObservableCollection里Add。解决办法是统一在OnSamplesReady里Dispatcher.BeginInvoke或者在采集侧只写一个volatile字段由 UI 定时器去读后者对大屏场景更合适。坑四单次读太多寄存器被设备拒绝。现象是某些地址段读失败异常码是Illegal Data Address或者直接超时。原因是合并后的块超过了 125 个寄存器或者跨过了设备不存在的地址区。解决办法是把maxPerRead降到 120 以下并且在合并逻辑里加一条规则地址间隔超过 16 个寄存器的不要合并宁可多发一次请求。坑五断线后看板停在最后一帧没人发现。现象是网络恢复后数据不刷新必须重启程序。原因是重连逻辑写在ConnectAsync里但轮询循环没有捕获断线状态IsConnected一直返回缓存值。解决办法是每轮读取前检查连接失败时先Dispose掉旧连接再走退避重连同时把IsOnline置为 false 让状态灯变红。这条是最值得做的一件事看板的价值在于“出问题能被看见”静默失效比崩溃危害更大。6. 二次开发加点位、换协议、接历史库的三个切入点拿到这套代码之后改动量最大的三个方向基本都集中在 Core 层UI 层反而很少动这也是前面坚持分层的回报。加点位是最轻的改造只动points.json。但要注意两点新增点位的address落在已有合并块的空洞里时不需要改代码BuildBlocks会自动把它并进去落在很远的地址段时会多一次请求这时候要评估 PLC 能不能扛住。加完之后跑一次看采集循环的单轮耗时如果超过intervalMs的一半就把间隔调大或者把点位拆到第二个从站。换协议是中等改造。Core 里的ModbusPoller只依赖IModbusClient这个接口的三个方法ConnectAsync、ReadHolding、IsConnected。要接 OPC UA 或者 MQTT 网关写一个实现类替换掉ModbusTcpClient就行ViewModel 和 XAML 一行不用改。我当时做这个抽象的时候犹豫过要不要加后来发现现场有一半设备是 Modbus另一半走网关这个接口省掉了大量复制粘贴。接历史库是重一点的改造。SampleRepository现在是个空壳实际项目里通常要落时序数据。写入策略建议按批攒不要一条一条写方案适用场景注意点SQLite 批量插入单机、数据量中等、需要离线可用用事务包住一批写入单条提交会慢一个数量级时序库如 InfluxDB多设备、需要按时间范围聚合查询写入走批量接口字段用 tag 区分设备只存变化点点位多、变化慢的状态量相邻两次值相同就跳过能省掉大量存储我一般会先上 SQLite跑一周看数据量再决定要不要换过早引入时序库往往是在给自己找运维负担。还有一个容易被忽略的改造点看板的“离线假数据”模式。演示或者培训时没有真机可以在IModbusClient上加一个FakeClient实现按正弦函数生成数据切换只改一行装配代码。这个技巧在做客户演示时特别省事也是我每次交付前都会留的一个后门。最后说个习惯。从那以后我每次交付工控看板都会强制走一遍“拔网线—等 30 秒—插回去”的流程确认状态灯变红再变绿、数据能自动恢复才敢把程序留在现场。这个动作花不到一分钟但能挡掉后面最容易被投诉的一类问题。希望帮到你。本文还有配套的精品资源点击获取