简介:面向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在很多设计上借鉴了WPF,XAML布局、数据绑定、样式和模板,基本上WPF工程师过去能直接上手。它和WPF最大的区别是渲染管线是自绘的,不依赖微软的DirectX,而是走Skia渲染,所以天然能跨Windows、Linux和macOS。用下来的感受是,在Avalonia里写界面的思路和WPF非常接近,但一些底层行为需要额外注意,比如输入事件的处理顺序、焦点管理、以及一些标准控件的可用性。
举个实际例子,我们做设备状态页面需要显示大量的状态指示灯,WPF里可以直接用Ellipse配合Style,Avalonia里也支持,但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的绑定默认是单向的,如果想双向绑定输入框,必须显式指定Mode=TwoWay。比如设备IP地址输入框:
<TextBox Text="{Binding DeviceIp, Mode=TwoWay}"/>这个坑我们刚开始踩过一次,设备IP一旦改绑定了别的属性,界面上怎么输入都不生效,查了半天发现是没写Mode。
2.3 依赖注入怎么组织
Avalonia里集成了自己的DI容器,在App.axaml.cs里可以按需注册服务:
public override void Initialize() { AvaloniaLocator.CurrentMutable.BindToSelf<IClassicDesktopStyleApplicationLifetime>(); base.Initialize(); } public override void OnFrameworkInitializationCompleted() { var collection = new ServiceCollection(); collection.AddSingleton<IDeviceService, TcpDeviceService>(); collection.AddSingleton<MainWindowViewModel>(); var services = collection.BuildServiceProvider(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow { DataContext = services.GetRequiredService<MainWindowViewModel>(), }; } 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:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true这样发布出来的就是一个几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 Include="SkiaSharp" Version="2.88.8" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.8" />单文件发布时,记得用IncludeNativeLibrariesForSelfExtract=true把原生库解压到临时目录,避免动态库加载不到。
5.4 串口权限和udev规则配置
Linux下非root用户访问串口常常碰到Permission denied。除了加入dialout组,还可以通过udev规则给特定设备指定更宽裕的权限。我们在部署文档里写了这样一个文件/etc/udev/rules.d/99-serial.rules:
KERNEL=="ttyUSB[0-9]*", MODE="0666" KERNEL=="ttyS[0-9]*", MODE="0666"配置完运行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。
本文还有配套的精品资源,点击获取