C#跨平台工业上位机方案:Avalonia实战与Linux部署踩坑经验
简介面向C#开发者的Avalonia跨平台上位机开发资源重点解决传统桌面界面无法在非Windows系统直接运行的问题适用于工控、物联网、桌面工具等需要客户端跨平台部署的场景也适合正在从经典框架向现代跨平台UI迁移的工程师。压缩包共270个文件大小约56.61MB文件类型以155个动态库、22个源码文件、5个界面布局文件、10个配置文件为主另有调试符号、本地库及工程配置等覆盖了构建、运行、调试Avalonia应用所需的完整素材。内容以AvaloniaDomo作为主框架示例并将BLL、DAL数据库模块作为子项目独立呈现直观演示数据访问层与界面层的分层集成方式同时兼容常见的控制台、窗体、Web等程序类型可帮助开发者复用既有分层设计降低跨平台改造门槛。目前已有1101人学习下载适合希望快速掌握Avalonia框架或需要参考完整工程结构进行二次开发的C#工程师。 我们这几年做产线设备上位机一直在Windows上转悠工控机装Win7、Win10一套WPF程序写到底。直到去年有个项目要求把监控端直接跑在Linux工控机上才正经开始研究跨平台方案最后选型落在Avalonia上。这篇文章就聊聊我们基于C#和.NET用Avalonia做上位机跨平台开发的那点事从框架选型、项目结构、通信封装到打包部署全是实测踩坑后的经验适合正在做上位机或准备迁往Linux平台的.NET工程师参考。1. 为什么选Avalonia做跨平台上位机先交代背景。我们手头有一套比较完整的设备控制软件早期是基于WinForms做的后来迁移到了WPF但业务代码和通信层其实已经和界面框架解耦得差不多了。新项目的核心诉求是一套代码同时跑Windows和Linux界面要接近WPF的开发体验不能重新学一门前端技术栈还要保证串口、TCP、Modbus这类工控通信稳定可靠。当时对比了Electron、Qt、MAUI和Avalonia最后一圈测试下来定了Avalonia。1.1 上位机跨平台需求到底从哪来的很多人觉得上位机就是跟着工控机走工控机基本都是Windows跨平台是个伪需求。但实际场景里这几种情况越来越多第一成本敏感的项目开始用国产化Linux工控机性能不错还能省一笔授权费第二数据采集站需要部署在边缘节点上现场运维人员只给一台装了普通发行版的小主机第三远程监控大屏跑在另外一套系统上你不可能单独给它写一套界面。这些场景里一套能编译成Linux可执行文件的上位机代码省掉的不只是开发时间还有后期维护和版本同步的精力。1.2 Avalonia和WPF在开发模式上的差异Avalonia在很多设计上借鉴了WPFXAML布局、数据绑定、样式和模板基本上WPF工程师过去能直接上手。它和WPF最大的区别是渲染管线是自绘的不依赖微软的DirectX而是走Skia渲染所以天然能跨Windows、Linux和macOS。用下来的感受是在Avalonia里写界面的思路和WPF非常接近但一些底层行为需要额外注意比如输入事件的处理顺序、焦点管理、以及一些标准控件的可用性。举个实际例子我们做设备状态页面需要显示大量的状态指示灯WPF里可以直接用Ellipse配合StyleAvalonia里也支持但Avalonia的样式选择器语法更接近CSS刚切换过来可能会在写Trigger的时候有点不习惯。这部分如果之前对WPF的Style和Template理解比较透学Avalonia基本零成本。2. Avalonia项目搭建与MVVM工程结构框架选定了以后最核心的就是搭一个好的工程结构把界面、业务、通信彻底分层。上位机软件最怕的就是界面代码和通信逻辑揉在一起一旦设备协议调整或者界面需要重做改动成本极高。下面是我们现在一直在用的结构。2.1 环境准备与项目初始化我建议直接用.NET 8或者.NET 6 LTS版本编译出来体积和性能都更友好。先安装好.NET SDK然后执行dotnet new install Avalonia.Templates dotnet new avalonia.mvvm -n IotDeviceHmi这个模板会生成一个带有MVVM基础的工程包含App.axaml、MainWindow.axaml和ViewModels目录。如果是从WPF转过来的可以把它理解成WPF的App.xaml和MainWindow.xaml。实际项目里我习惯把解决方案拆成四个工程Hmi.App启动入口和界面层引用Avalonia相关包。Hmi.Core领域模型、设备数据结构、协议解析接口。Hmi.Device具体通信实现包括串口、TCP客户端、Modbus协议封装。Hmi.Data数据持久化、日志记录、配置管理。这样拆的好处是Hmi.Core和Hmi.Device完全不依赖Avalonia可以直接拿来做单元测试以后即使要换一套UI框架这两层的代码还是原封不动复用。我们在迁移时把原有WPF项目里的通信库直接搬过来改掉几个命名空间就编译过了体验很好。2.2 Avalonia里MVVM绑定的几个关键点Avalonia的绑定底层是AvaloniaObject但使用方式几乎和WPF一样。我们用CommunityToolkit.Mvvm来管理ViewModel写起来非常清爽public partial class MainWindowViewModel : ObservableObject { [ObservableProperty] private string connectionState 未连接; [ObservableProperty] private double temperature; [ObservableProperty] private bool isRunning; [RelayCommand] private void Start() { IsRunning true; ConnectionState 运行中; } }用CommunityToolkit.Mvvm生成的源代码会帮我们自动实现INotifyPropertyChanged界面绑定直接用{Binding Temperature}就行。有一点要注意Avalonia的绑定默认是单向的如果想双向绑定输入框必须显式指定ModeTwoWay。比如设备IP地址输入框TextBox Text{Binding DeviceIp, ModeTwoWay}/这个坑我们刚开始踩过一次设备IP一旦改绑定了别的属性界面上怎么输入都不生效查了半天发现是没写Mode。2.3 依赖注入怎么组织Avalonia里集成了自己的DI容器在App.axaml.cs里可以按需注册服务public override void Initialize() { AvaloniaLocator.CurrentMutable.BindToSelfIClassicDesktopStyleApplicationLifetime(); base.Initialize(); } public override void OnFrameworkInitializationCompleted() { var collection new ServiceCollection(); collection.AddSingletonIDeviceService, TcpDeviceService(); collection.AddSingletonMainWindowViewModel(); var services collection.BuildServiceProvider(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow new MainWindow { DataContext services.GetRequiredServiceMainWindowViewModel(), }; } base.OnFrameworkInitializationCompleted(); }依赖注入在项目变大以后非常值钱。比如我们那个TcpDeviceService被调用了十几个地方后续想换成串口通信只要改注册的地方其他地方不用动一行代码。3. 通信层的跨平台实现与踩坑记录上位机软件的核心其实不在界面而在通信。你要和设备厂商的协议打交道要处理串口断开、TCP断线重连、数据粘包这些问题。跨平台以后原来在Windows上跑得好好的代码换到Linux上就可能有各种意外下面是我们实际走过的路。3.1 串口通信的跨平台注意事项.NET的SerialPort类在Windows和Linux上的行为有一定差异。首先是端口名称的问题Windows上是COM3、COM4这种格式Linux上则是/dev/ttyUSB0、/dev/ttyS1。代码里不能写死需要让用户能配置。我们做得比较简单在配置界面提供一个文本输入框默认值根据不同操作系统自动填充public static string GetDefaultPortName() { if (OperatingSystem.IsWindows()) return COM3; else if (OperatingSystem.IsLinux()) return /dev/ttyUSB0; return /dev/cu.usbserial; }其次是串口打开和读取的超时行为。Windows下SerialPort.ReadTimeout设成500毫秒基本能稳定触发超时异常。Linux下有时会不准我们就把超时判断改成了基于线程取消令牌的方式用异步循环去读缓冲区这样在两边行为都一致。还有一个容易被忽略的点Linux下要给当前用户加入dialout组否则没有权限访问串口设备sudo usermod -a -G dialout $USER3.2 TCP客户端与断线重连机制绝大多数设备都支持以太网通信协议一般走Modbus TCP或者厂商自定义协议。我们封装了一个TcpDeviceService内部维护一个TcpClient实例核心逻辑包括异步连接、心跳检测、断线重连。代码结构大概是这样的public class TcpDeviceService : IDeviceService, IDisposable { private TcpClient _client; private CancellationTokenSource _cts; private readonly object _lock new object(); public async Task ConnectAsync(string ip, int port) { _cts?.Cancel(); _cts new CancellationTokenSource(); _client new TcpClient(); await _client.ConnectAsync(ip, port); _ Task.Run(() ReceiveLoopAsync(_cts.Token)); } private async Task ReceiveLoopAsync(CancellationToken token) { var buffer new byte[1024]; while (!token.IsCancellationRequested) { try { var length await _client.GetStream().ReadAsync(buffer, token); if (length 0) { OnDisconnected(); break; } ProcessBuffer(buffer.Take(length).ToArray()); } catch (Exception) { OnDisconnected(); break; } } } private async void OnDisconnected() { for (int i 0; i 5; i) { await Task.Delay(1000 * (i 1)); try { await ConnectAsync(_ip, _port); return; } catch { } } StatusChanged?.Invoke(this, 连接失败请检查设备电源和网线); } }断线重连的细节是第一次连接失败时立刻重试一次防止开机时序导致的偶发问题随后按照1秒、2秒、3秒的退避策略递增五次全部失败后抛出一个可感知的状态事件通知界面显示红灯。这样既不会太快把日志刷爆也不会让操作员以为程序卡死了。3.3 粘包和半包处理设备通信中经常遇到粘包和半包的问题。比如设备每100毫秒发一组106字节的数据帧但TCP是流协议你读取数据时可能一次读到210字节或者只读到50字节数据边界完全不能依赖一次Read。我们的做法是维护一个接收缓冲队列先根据帧头、帧长度字段来截取完整的数据帧private byte[] _recvBuffer new byte[4096]; private int _recvCount 0; private void ProcessBuffer(byte[] data) { Buffer.BlockCopy(data, 0, _recvBuffer, _recvCount, data.Length); _recvCount data.Length; int offset 0; while (_recvCount - offset 6) { if (_recvBuffer[offset] ! 0xAA || _recvBuffer[offset 1] ! 0x55) { offset; continue; } int bodyLength (_recvBuffer[offset 2] 8) | _recvBuffer[offset 3]; int frameLength 4 bodyLength 2; if (_recvCount - offset frameLength) break; byte[] frame new byte[frameLength]; Array.Copy(_recvBuffer, offset, frame, 0, frameLength); ParseFrame(frame); offset frameLength; } if (offset 0) { Buffer.BlockCopy(_recvBuffer, offset, _recvBuffer, 0, _recvCount - offset); _recvCount - offset; } }这个代码的思路是先找帧头然后解析出bodyLength判断当前缓冲里有没有一整个完整的帧没有就等下一次读数据有就切出来处理掉。所有未处理完的残数据再拷贝到缓冲头部等下轮数据到达后继续拼接。这套逻辑在串口和TCP里通用协议不同就改改帧头、长度字段的解析位置。4. 数据展示与界面交互实践上位机界面通常要显示设备的实时数据、运行状态、历史曲线。Avalonia里实现这些功能需要引入第三方图表库并处理好数据更新频率和UI渲染效率之间的平衡。4.1 实时曲线用OxyPlot还是LiveCharts我们最终用了OxyPlot的Avalonia版本包名是OxyPlot.Avalonia。OxyPlot虽然上手比LiveCharts复杂一点但胜在稳定、文档全而且对时间序列数据的更新支持很成熟。一个简单的温度曲线页面大概是这样的public class ChartViewModel : ObservableObject { public PlotModel Plot { get; set; } private LineSeries _tempSeries; public ChartViewModel() { Plot new PlotModel { Title 设备温度 }; _tempSeries new LineSeries { Title 温度, StrokeThickness 2 }; Plot.Series.Add(_tempSeries); } public void AppendData(DateTime time, double value) { _tempSeries.Points.Add(new DataPoint(DateTimeAxis.ToDouble(time), value)); if (_tempSeries.Points.Count 500) _tempSeries.Points.RemoveAt(0); Plot.InvalidatePlot(true); } }注意曲线数据点不能无限增加否则内存和绘制时间都会失控。我们这里设置超过500个点就移除旧的相当于只显示最近一段时间的数据。还可以加上一个时长选项用户能切换5分钟、半小时或者1小时每次切换时重建曲线数据源。4.2 数据刷新频率与UI卡顿的平衡上位机最忌讳的就是整个界面因为数据刷新频繁而卡顿。高频设备数据比如每50毫秒采集一次如果直接把每个数据点都推给UI线程界面一定会感觉到卡。我们现在的处理方式是把数据刷新频率控在200毫秒左右也就是界面最多每秒刷新5次这个频率人眼看起来是实时且流畅的。实现上用了Channel作为生产者消费者队列。通信层接收数据后只做协议解析和缓存不直接操作UI界面层启动一个定时器每200毫秒从缓存中取最新值更新绑定属性private async Task StartUiRefreshLoop() { while (!_cts.Token.IsCancellationRequested) { await Task.Delay(200); var snapshot _deviceService.GetLatestSnapshot(); Temperature snapshot.Temperature; Pressure snapshot.Pressure; StatusMessage snapshot.StatusText; } }这样即使通信层接收再多的数据界面也只是按自己的节奏刷新UI线程不会被打爆。如果某个页面需要显示高精度时间序列曲线建议把曲线绘制的坐标点数量和刷新频率再调低用Timer而不是async loop避免频繁的任务调度开销。4.3 多语言与主题切换的备选方案如果设备将来要出口或者现场有外籍工程师界面需要考虑多语言。Avalonia官方提供了简单的资源字典方案也可以在启动时根据配置文件选择CultureInfo。我们目前的做法是提前把界面文本都提取到资源文件里默认中文后续要加英文版时只要翻译资源文件不需要动任何XAML。5. Linux部署实战与常见坑位排查这一部分是我们踩坑最多的地方。Windows上编译好的Avalonia程序复制到Linux机器上并不总是能直接跑起来。下面这几个问题几乎每次部署到新环境都会碰到。5.1 发布方式选择FDE还是framework-dependent如果目标Linux机器上已经安装好了.NET运行时可以发布framework-dependent版本体积小但环境依赖多。我们更多时候用的是.NET 8支持的单文件发布PublishSingleFile把运行时一起打进去目标机器零依赖dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFiletrue -p:IncludeNativeLibrariesForSelfExtracttrue这样发布出来的就是一个几MB到几十MB的二进制文件拷到目标机器上配好执行权限就能跑chmod x IotDeviceHmi ./IotDeviceHmi有一点要特别提醒第一遍发布在Windows上交叉编译Linux版本是可以的但如果是复杂项目需要用到某些原生库最稳妥的做法还是在一台Linux机器上直接发布。我们CI里配了一个Linux构建机用来出Linux版本。5.2 Linux上缺少字体导致界面显示异常Avalonia在Linux上渲染文字依赖系统的字体库如果目标机器没有安装中文字体界面上所有中文都显示成方块或者乱码。这是最典型的坑。解决方案是在部署脚本里检查并安装字体包sudo apt install fonts-wqy-microhei fonts-wqy-zenhei也可以把字体文件直接拷贝到 /usr/share/fonts 目录下然后运行sudo fc-cache -fv字体这块还牵涉到界面布局的错位问题同一个界面在Windows上正常在Linux上因为字体度量不同可能会文字截断。我们的做法是给关键文本控件设置固定尺寸或者最小宽度避免因为字体差异导致布局穿帮。5.3 libSkiaSharp.so加载失败问题如果项目用到SkiaSharp原生的图像解码或者特殊渲染Linux发布时有时会遇到找不到libSkiaSharp.so的错误。解决方法是把SkiaSharp.NativeAssets.Linux这个NuGet包显式引用进项目并把原生文件包含到发布目录PackageReference IncludeSkiaSharp Version2.88.8 / PackageReference IncludeSkiaSharp.NativeAssets.Linux Version2.88.8 /单文件发布时记得用IncludeNativeLibrariesForSelfExtracttrue把原生库解压到临时目录避免动态库加载不到。5.4 串口权限和udev规则配置Linux下非root用户访问串口常常碰到Permission denied。除了加入dialout组还可以通过udev规则给特定设备指定更宽裕的权限。我们在部署文档里写了这样一个文件/etc/udev/rules.d/99-serial.rulesKERNELttyUSB[0-9]*, MODE0666 KERNELttyS[0-9]*, MODE0666配置完运行sudo udevadm control --reload-rules然后重新插拔串口设备即可生效。这个配置在调试阶段尤其有用省得每次都要sudo运行程序。5.5 界面退出时进程不消失Avalonia程序在Linux上点击窗口关闭按钮后有时进程并不会完全退出特别是存在后台通信线程或者已启动的任务没有正确取消的情况下。我们一开始也遇到了窗口关了但串口还被占用重新启动程序时直接报地址占用。后来在MainWindow关闭事件里做统一清理protected override void OnClosed(EventArgs e) { _deviceService?.Dispose(); _cts?.Cancel(); base.OnClosed(e); }同时确保异步循环里所有Task都监听了CancellationToken并在finally块里释放资源。如果一个应用里使用了System.Timers.Timer也要记得Stop和Dispose否则Timer的回调会继续保持进程存活。6. 提升现场调试效率的几个实用功能生产环境跑起来以后光有基本通信和界面还不够几个面向调试的功能越早做越能省事。6.1 内置通信日志面板我们给软件加了一个实时通信日志面板所有接收和发送的数据都以HEX格式显示出来带时间戳和收发方向。别小看这个功能在现场排查传感器数据异常时非常有用不用再拿串口工具挂在线上去抓包了。日志用ObservableCollection绑定到ListBox限制最多显示2000条超过后自动移除最早的记录防止长时间运行内存膨胀。6.2 配置文件热加载设备IP、波特率、数据格式这些参数最好放在一个配置文件里界面改了配置后立即写入并重启通信。我们用JSON格式存配置启动时读取修改时整体序列化写入public void SaveConfig(AppConfig config) { var json JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented true }); File.WriteAllText(_configPath, json); _currentConfig config; }写配置时要注意先写入临时文件再替换防止程序异常退出导致配置文件损坏。这个细节我们也被坑过一次现场突然断电配置文件写了一半下次启动直接崩溃后来改成临时文件加原子替换就没再出过问题。6.3 在线日志与远程协助如果设备部署在比较远的现场最好把运行日志写到本地文件同时保留一个远程导出日志的入口。我们目前的做法是日志按天滚动保留最近30天文件名为logs/2025-06-01.log。远程诊断时直接让对方用U盘把日志拷出来或者如果有联网条件通过MQTT把关键状态实时上报到云端看板这样在办公室就能看到所有设备的运行情况。几点实操上的感受Avalonia做跨平台上位机是完全可行的尤其是已经有WPF基础的团队迁移成本比想象中低很多。但你要有心理准备Linux下的生态没有Windows那么顺手字体、权限、原生库这些问题都得自己趟一遍。我们第一版Linux端从开始移植到全部跑通大约花了一周时间其中一半都是在解决部署环境的问题。还有一点跨平台不等于一次编写到处运行通信层和界面层必须严格解耦否则任何平台差异都会让你改到怀疑人生。如果你现在正要启动一个新的上位机项目可以把Avalonia列入选型清单同时一定要在项目初期就把Linux的发布流程跑通越早暴露环境问题后面越省心。最后再分享一个小技巧Avalonia社区版本的NuGet包更新比较频繁遇到奇怪行为时先看一下是不是框架版本的问题升级到最新patch版本往往能解决一半的灵异Bug。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →