Avalonia + NModbus 构建跨平台工业设备监控面板:从选型到踩坑实录
做工业设备监控面板这事,我一开始其实是拒绝的——客户端用 WPF 已经很熟练,Modbus TCP 也写过好几个版本,按理说没必要折腾。但客户现场提了一个硬性要求:上位机必须能跑在 Linux 工控机上,开发期又要在 Windows 上调试。WPF 直接被否,Web 方案又嫌部署太重。最后我选了 Avalonia 搭 UI,配合 NModbus 库走 Modbus TCP 协议,前后折腾了两周,踩了一堆文档里根本不会写的坑。这篇文章按“选型 → 搭框架 → 写通信 → 踩坑 → 开发环境与部署”的顺序完整记录下来,给后面要做跨平台上位机的人留一份参考,也把离线安装、VSCode 开发环境、字节序、断线重连这类实际问题逐个讲透。
1. 为什么是 Avalonia:跨平台上位机的选型逻辑
1.1 工业现场的真实约束
先说我面对的实际场景:现场有三台 PLC 和一台变送器,走的都是 Modbus TCP,数据量不算大,但要求 7x24 小时连续运行。上位机这边,客户机房的工控机有两台是 Windows 10,另有一台是 Ubuntu 20.04,还有一台是国产化 Linux。让我用 WPF 做的话,Linux 那两台直接没法跑。用 Web 前端做,又得在工控机上装 Node 环境或者部署 Nginx 加后端服务,现场人员不一定能维护。
工业项目还有个特殊点:开发机和现场往往物理隔离,不能指望现场有外网。所有依赖包都得提前准备好,这直接影响了后续的 NuGet 离线源方案和 Avalonia 模板的离线安装方式。这一点很多人前期不考虑,真到了打包发布那天就慌了。
1.2 候选方案横向对比
我把当时考虑过的几个方案列了个表,按“跨平台能力”“UI 表现力”“技术栈迁移成本”“离线部署便利性”“长期维护”五个维度打分:
| 方案 | 跨平台 | UI 现代化程度 | 从 C#/WPF 迁移成本 | 离线部署 | 长期维护 |
|---|---|---|---|---|---|
| WPF | 仅 Windows | 中上 | 零成本 | 方便 | Win 平台没问题 |
| WinForms | 仅 Windows | 一般 | 低 | 方便 | 老程序多 |
| Qt (C++/PySide) | 好 | 好 | 高,语言都要换 | 需带上 Qt 运行库 | 需要 C++ 团队 |
| Electron / Web | 好 | 最好 | 中,前端栈 | 包体积大,Node 依赖重 | 依赖生态更新快 |
| Avalonia | 好 | 好 | 低,XAML 语法相似 | 自包含发布即可 | .NET 社区活跃 |
结论其实很明确:团队是 C# 背景,Avalonia 的 XAML 写法和 WPF 几乎一套思路,DataContext、Binding、样式、模板都是老熟人,迁移成本最低。而且 Avalonia 底层是 Skia 渲染,不依赖系统自带 UI 组件,所以 Linux 上表现很一致,不会出现“Windows 上好好的,Linux 上控件乱飞”的情况。
1.3 Avalonia 版本选择和生态现状
我用的是 Avalonia 11。Avalonia 10 和 11 的 API 有一些差异,网上很多老帖子还在用 0.10 的写法,看的时候注意版本。Avalonia 11 的 FluentTheme 默认风格已经能看,控件库比前几年丰富了不少。DataGrid 单独走 Avalonia.Controls.DataGrid 包,表格类界面要记得引用,不然 XAML 里写 <DataGrid> 会直接报“找不到类型”。
这里也提一句:Avalonia 没有像 WPF 那样的“可视化设计器拖控件”体验,至少在 VSCode 和 Linux 下没有。我基本是手写 XAML,然后直接 F5 看效果,习惯了效率也不低。要在 Visual Studio 里用设计器,Windows 下可以装官方扩展,但体验还是赶不上 WPF 的成熟度。
2. 工程骨架:MVVM 结构、依赖注入和 Modbus 服务注册
2.1 项目分层设计
我见过不少 Avalonia 新手把一切塞进 MainWindow.axaml.cs,这在小 Demo 里没问题,但工控界面功能一多就崩——Modbus 轮询、报警、历史曲线、参数设置全混在一起,改一个功能要翻几百行代码。工程上最好分层:
Panel.App:入口程序、App.axaml、启动流程、全局样式Panel.Views:所有窗口和 UserControl,放Views文件夹Panel.ViewModels:对应视图的 VM,放ViewModels文件夹Panel.Device:Modbus TCP 封装、设备寄存器模型、轮询服务Panel.Data:报警记录、配置持久化(JSON 或 SQLite)
这五个项目都引用同一个解决方案目录,依赖方向是:Views 引用 ViewModels,ViewModels 引用 Device 和 Data,Device 只引用协议相关包,不反向依赖 UI。这样以后要换协议(比如加一个 OPC UA)也只需要在 Device 层加实现。
2.2 NuGet 包选型
这个项目里我实际用到的核心包不多,但每一个都认真选过:
| 包名 | 用途 | 备注 |
|---|---|---|
| NModbus | Modbus 协议客户端 | 原 NModbus4 延续下来的库 |
| CommunityToolkit.Mvvm | ObservableObject、RelayCommand |
轻量,适合手写 VM |
| Serilog + Serilog.Sinks.File | 日志记录 | 现场排查问题必备 |
| FluentAvalonia | 额外 UI 控件 | 可选,看需求加 |
MVVM 框架我也纠结过是 ReactiveUI 还是 CommunityToolkit.Mvvm。ReactiveUI 功能强,但学习曲线陡,对大多数人不必要;CommunityToolkit.Mvvm 靠源生成器,写起来非常干净,[ObservableProperty] 一把梭,适合工控这种页面相对固定的场景。我还是选了后者。
2.3 依赖注入从启动开始
Avalonia 项目模板默认没有 DI,需要自己在 Program.cs 里搭一个 ServiceCollection,然后在 App 启动时把服务解析出来。我倾向于把启动流程集中到 App.axaml.cs 里:
为什么不用“直接在 VM 里 new”?因为 DevicePollingService 要管理多台设备,还要持有同一个 ILogger、同一个配置对象。用 DI 统一管理后,调试、替换实现、写单元测试都方便得多。工控项目最怕的是“跑一段时间后想加个功能却无从下手”,DI 能把这个风险降低很多。
2.4 视图与 ViewModel 的绑定
Avalonia 的绑定语法和 WPF 基本一致。MainWindow 里放几个 UserControl,每个 UserControl 的 DataContext 在 MainWindow.axaml 里声明:
然后用 ContentControl 加 DataTemplate 做页面切换也行,但工控面板通常不希望有太多跳转,一个主窗体里镶嵌多个区域更直观。小屏幕工控机上,弹窗反而容易遮挡关键数据。
3. Modbus TCP 客户端封装:从“能通”到“跑得稳”
3.1 NModbus 基础用法与连接模型
NModbus 的用法很直接:先建 TcpClient,再通过 ModbusFactory 创建 master,然后就有 ReadHoldingRegistersAsync、WriteSingleRegisterAsync 这些方法。核心代码骨架:
这段代码只是“能通”,但离“跑得稳”还差很远。工业现场经常出现设备重启、网络闪断,TCP 连接一旦断开,原来的 IModbusMaster 基本就废了。最稳妥的做法是:每次轮询发现异常后,主动释放旧连接,然后重新建立连接。不要试图复用一个已经异常断开的 TcpClient,NModbus 底层不会自动帮你重连。
3.2 轮询循环:周期控制与取消机制
我用一个后台 Task 做轮询,循环里用 PeriodicTimer 控制节奏。.NET 6 以后的 PeriodicTimer 比 Task.Delay 更准,而且不会出现“用 Task.Delay 时实际周期越跑越偏”的问题:
周期设多少?要看设备支持多少个连接和寄存器规模。我这边每台设备读 20 个寄存器左右,100ms 完全够用,CPU 占用很低。如果你要读上百个寄存器,或者设备性能差,建议把周期放宽到 500ms 甚至 1s,别盲目追求高频,Modbus TCP 不是高速总线,高频轮询只会让设备通信模块过载。
3.3 寄存器映射表和设备模型
Modbus 里所有数据都是寄存器地址,但人不能直接看寄存器号。我建了一个 DeviceRegistry 和 RegisterMap 来做地址翻译,把所有点位配置集中管理:
| 地址 | 数据类型 | 含义 | 读写 |
|---|---|---|---|
| 0x0000 | ushort | 设备状态字 | 读 |
| 0x0001 | ushort | 当前温度(扩大 10 倍) | 读 |
| 0x0002~0x0003 | float | 转速设定值 | 读写 |
| 0x0004 | ushort | 故障码 | 读 |
| 0x0005 | bool(bit0) | 运行/停止信号 | 读写 |
对应到代码,我会用 Dictionary<string, ushort> 加一个 DevicePoint 类管理每个点的地址、缩放系数、读写权限:
这样一来,界面要显示“温度”,VM 里只需要取 "temperature" 这个点,不关心 Modbus 协议细节。如果以后换了一台地址完全不同的设备,只需要改配置,不用动 UI。
4. 踩坑实录:UI 假死、异常风暴、字节序与刷新节流
真正花时间的都是这些看起来不起眼、一出现就让人抓狂的问题。下面按我踩的顺序逐一还原。
4.1 坑一:后台线程直接改了可观察属性,界面却没刷新
Avalonia 和 WPF 一样有 UI 线程概念。刚开始我图省事,直接在轮询线程里给 VM 的 Temperature 属性赋值,结果线上运行界面偶发性假死,调试时还出现“Call from invalid thread”这类异常。
原因是:ObservableObject 的属性变更通知 PropertyChanged 是在后台线程触发的,界面控件的绑定更新逻辑可能跨线程访问 UI 控件。正确的做法是把所有属性更新统一抛到 Dispatcher.UIThread 上:
注意用 Post 而不是 Invoke。Post 是非阻塞投递,轮询循环不会因为 UI 卡顿而被拖住;Invoke 会等待 UI 线程处理完,如果 UI 线程在忙,整条轮询链路就被堵死了。工业界面的轮询任务就是要轻量、不阻塞。
4.2 坑二:网络一抖,异常风暴比设备故障还可怕
Modbus TCP 是走 TCP 的,网络闪断、设备断电重启都会引发异常。一开始我在轮询里简单 try/catch,断线后每秒重试,结果产生了“异常风暴”:一直抛异常,一直重连,日志文件刷得飞快,CPU 也飙到 30%。
后来做了两类防护:
第一,异常分类处理。TimeoutException 说明设备没响应,IOException 说明连接断了,SlaveException 说明设备应用层返回错误。不同异常对应不同处理策略,不能一视同仁立刻重试。
第二,指数退避。连续两次失败后,重连间隔从 1s 涨到 2s、4s、8s,最多封顶 30s。设备断电恢复后,最多几十秒就能重新连上,没必要秒级重试。
这条经验真的救了我:现场一次临时断电,面板恢复后不到 40 秒自己连回来了,而 CPU 始终保持平稳,日志没有爆掉。
4.3 坑三:32 位数据的字节序,文档不说你就只能瞎猜
Modbus 寄存器基础单位是 16 位,所以 32 位浮点、32 位整数都要占用两个寄存器。NModbus 读出来的是 ushort[],两个寄存器拼成一个 32 位值的时候,字节序就是最大坑。
当时我读一个“转速”值,设备文档只写了“两个寄存器存一个 float”,没写字节序。我用 NModbus 默认顺序读出来,数值怎么看都是几千上万,明显不对。后来手工拼接才发现,寄存器顺序反了。
- 大端模式:高 16 位在前,低 16 位在后
- 小端模式:低 16 位在前,高 16 位在后
- 还有些设备在 32 位内部再做一次字节交换,类似“中端序”
我在代码里做了个透明转换工具:
建议把字节序作为设备配置项放进 RegisterMap,每台设备单独指定。因为你没法保证不同厂商设备遵循同一套规矩,现场调试时“换台设备数值就疯掉”多半是这原因。
4.4 坑四:100ms 轮询,UI 也跟着 100ms 刷新,CPU 直接被拉满
轮询周期我定的是 100ms,最初把每次轮询到的值都直接推给 UI,更新温度、转速、状态灯这些几十个绑定属性。运行一段时间 CPU 占用居高不下,GC 也非常频繁。
原因很简单:UI 其实不需要那么高的刷新频率。人眼在屏幕上看出数值变化,100ms 和 500ms 几乎没有区别;但 100ms 刷新会让整个 UI 线程忙个不停,尤其有图表控件时,每个点都要重新渲染。
我加了刷新节流:数据仍然 100ms 轮询,但只把结果存进缓存;界面统一每 500ms 从缓存读取一次最新的值更新绑定属性。这样 UI 刷新频率固定到了 500ms,CPU 立刻降下来,界面也没有任何迟滞感。
这个“数据采集快、UI 刷新慢”的分离思路,是我这次项目最核心的架构决策之一。建议所有做监控面板的人都把数据层和展示层周期解耦。
5. 开发环境和离线部署:VSCode、NuGet 本地源和 Linux 运行时
5.1 用 VSCode 写 Avalonia:扩展和真实体验
Avalonia 官方对 Visual Studio 的集成度最高,有可视化设计器;但我在 Linux 上开发,主力是 VSCode。VSCode 本身没有原生的 Avalonia XAML 设计器,所以别指望拖控件,老老实实手写 XAML 加 dotnet run 调试。
我实际安装的扩展:
C# Dev Kit(微软官方,提供 C# 项目识别、调试、智能感知)C#(老版 C# 扩展,配合 Dev Kit 使用更稳)XML Tools(.axaml 本质是 XML,格式化、标签闭合靠它)vscode-solution-explorer(在 VSCode 里显示解决方案视图,比文件夹视图直观)
如果你在公司内网,VSCode 扩展市场连不上,解决办法是找一台能上网的机器,从市场下载 .vsix 文件,再复制到内网执行:
Avalonia 的项目模板也同理。先用联网机器执行:
如果内网离线,就把模板包(.nupkg)复制到内网,再执行:
dotnet new 支持直接从本地 nupkg 文件安装模板,这点比 NuGet 还原还要简单,因为不涉及依赖树。
但说实话,VSCode 开发体验还是有短板:XAML 没有设计器,Binding 写错了只能靠运行时日志;调试时类型可视化也弱。如果不排斥商业 IDE,Rider 对 Avalonia 的支持更好,但那是另一个话题了。
5.2 离线 NuGet 还原:本地包源是最省心的方案
现场不能访问 nuget.org,这是个大问题。不是每个工控项目都有内网 NuGet 服务器,但我们可以用“本地文件夹作为 NuGet 源”这种轻量方案。
做法分三步:
第一步,在联网机器上建一个文件夹,把项目所需的所有 .nupkg 文件复制进去:
第二步,在项目根目录新建 nuget.config:
第三步,把项目目录整个复制到现场机器,然后:
这样所有依赖都从本地文件夹里找,不访问外网。要特别提醒:<clear /> 一定要写,否则现场机器如果配置过其他 NuGet 源,恢复时还是会去远程。
这里还有个小坑:离线恢复最容易缺的是“传递依赖”。有些包依赖别的包,而你又没有真正打包某个包的全部版本。所以最稳妥的做法不是复制你需要的几个 nupkg,而是直接复制 ~/.nuget/packages 下所有包。这里面的目录结构就是 NuGet 的全局包缓存,原样放过去最不容易出问题。
5.3 发布到 Linux 工控机:自包含发布和字体问题
.NET 应用发布到 Linux 最稳的是“自包含发布”,就是运行时也一起打进去,现场机器不需要提前装 .NET:
这样发布出来的目录有几 MB 到几十 MB,但换来了“拷过去就能跑”的确定性,不会出现“现场缺 .NET 运行时 8.0”这种尴尬。
发布完跑起来如果遇到“字体不对、中文显示成方块”,这是 Avalonia 在 Linux 下常见的坑。解决方案是提前把中文字体一起部署,并在应用里声明字体:
在 App.axaml 里引入字体资源:
然后在全局样式里设置:
Linux 工控机如果没装 Noto CJK 字体,界面就是一堆豆腐块。把字体文件放进发布目录,应用自己加载,受系统影响最小。
另外,如果现场不能连显示器、只有串口调试,建议在发布前把日志输出做成文件日志,用 Serilog 写入当前目录下 logs/ 文件夹,并做按天滚动。数据异常、重连事件、设备故障码都要有日志,否则你永远不知道面板在客户眼皮底下发生了什么。
6. 上线运行后的稳定性与性能表现
项目交付后,我在现场盯了两天,后面又远程观察了一周。CPU 占用稳定在 3~5%,内存 200MB 以内,连续运行一周没有出现内存泄漏或 UI 卡死。让我最意外的反而不是性能——性能本来就可控,最让我感慨的是“做好日志和断线重连”这两件事的价值:现场第二天凌晨设备断电重启了一次,早上我去看日志,发现系统在 40 秒内完成了重连,UI 界面自己恢复正常,没有人工干预。这要是放在以前单纯 try/catch 的版本里,估计早就在客户那边炸锅了。
还有个小细节值得分享:监控面板不要把所有设备当作完全相同的对象。不同设备、不同寄存器表、不同字节序,我都通过配置区分,这样做完一台设备接入,第二台设备只需要改配置文件和寄存器映射,代码完全不动。
最后要提醒想做类似项目的朋友:Avalonia 本身并不难,难的是把“工业级可靠性”这件事从架构层做进去。轮询周期与 UI 刷新解耦、异常退避重连、字节序可配置、本地日志、离线依赖管理——这些才是真正决定项目交付后是“省心”还是“24 小时在线背锅”的分水岭。别等设备闪断、界面假死、日志刷屏的时候才回头补课。