1. 为什么我选了 Avalonia 而不是继续用 WPF 做这块工业面板
去年年底接了个小活,给一家做注塑机的厂子做上位机监控面板。需求说起来不复杂:把车间里六台设备的实时数据拉上来,温度、压力、转速、运行状态,用表格和几个仪表盘展示,异常的时候弹个提示。之前这类活我都是用 WPF 做的,熟门熟路,但这次客户提了个要求——现场那台工控机是 Windows 7 的,而且他们后续想换 Linux 的瘦客户端,希望这套界面能跨平台。
WPF 跨平台这条路基本走不通,虽然有个 Avalonia 号称是"WPF 的精神续作",但我一直没正经用过。这次算是被逼着上手,从建项目到跑通 Modbus TCP 通信,前后折腾了大概一周,踩的坑比我预想的多得多。这篇文章就把整个过程拆开讲,包括为什么这么选、通信层怎么写、数据绑定怎么接、DataGrid 怎么调,以及那些文档里不会写但实际一定会遇到的问题。
先说清楚这篇适合谁看:如果你有 C# 基础,做过 WinForm 或 WPF,现在想用 Avalonia 做一个需要跟工业设备通信的桌面应用,那这篇基本能帮你省掉我踩坑的那几天。如果你完全没接触过桌面开发,建议先把 C# 和 XAML 的基础过一遍再来看,不然有些地方会卡住。
关键词里提到的 Avalonia、Modbus TCP、NModbus、INotifyPropertyChanged、DataGrid,这几个东西基本就是整个项目的骨架。Avalonia 负责界面,NModbus 负责跟设备说话,INotifyPropertyChanged 负责把数据变化通知到界面,DataGrid 负责把数据摆出来给人看。听起来简单,但每一环都有它自己的脾气。
2. 环境搭建阶段那些让人抓狂的细节
2.1 模板安装与项目创建的正确姿势
Avalonia 的项目模板不是装完 .NET SDK 就有的,得单独装。官方推荐的方式是:
dotnet new install Avalonia.Templates装完之后用dotnet new avalonia.mvvm就能创建一个带 MVVM 结构的项目。这里第一个坑就来了:如果你用的是 Visual Studio,装完模板后新建项目列表里可能不会立刻出现 Avalonia 的选项,得重启 VS 才行。我第一次装完刷新了半天没看到,还以为装失败了,重装了两遍。
创建项目的时候会让你选 MVVM 框架,有 CommunityToolkit.Mvvm、ReactiveUI 等几个选项。我选的是 CommunityToolkit.Mvvm,原因是它轻量,[ObservableProperty]和[RelayCommand]这两个源生成器特性用起来很顺手,不用手写一大堆属性通知代码。ReactiveUI 功能更强但学习曲线陡,对于这种中小型工业面板来说有点杀鸡用牛刀。
项目结构大概是这样的:
ProjectName/ ├── App.axaml ├── App.axaml.cs ├── ViewModels/ │ ├── ViewModelBase.cs │ └── MainWindowViewModel.cs ├── Views/ │ ├── MainWindow.axaml │ └── MainWindow.axaml.cs ├── Models/ ├── Services/ └── Program.csViewModelBase继承自ObservableObject,这是 CommunityToolkit 提供的基类,实现了INotifyPropertyChanged。后面所有的 ViewModel 都从这个基类派生。
2.2 离线环境下的包还原问题
客户那边的开发机是内网的,不能直接访问外网。这就涉及到离线安装的问题。Avalonia 的 NuGet 包依赖不少,包括Avalonia、Avalonia.Desktop、Avalonia.Themes.Fluent、Avalonia.Fonts.Inter等等,还有 CommunityToolkit.Mvvm 和 NModbus。
我的做法是在能上网的机器上先建一个同版本的项目,把所有包还原下来,然后去 NuGet 缓存目录(Windows 下是%USERPROFILE%\.nuget\packages)把对应的文件夹整个拷到内网机器的相同位置。注意版本号要对上,Avalonia和Avalonia.Desktop的版本必须一致,否则编译时会报版本冲突。
更稳妥的方式是搭一个本地 NuGet 源,把所有 .nupkg 文件放进去,然后在NuGet.config里配置:
<configuration> <packageSources> <clear /> <add key="local" value="D:\LocalNuGet" /> </packageSources> </configuration>这样还原的时候就不会去外网找了。这个配置文件的坑在于<clear />必须加,否则默认的 nuget.org 源还在,还原时还是会尝试联网,内网环境下会卡很久然后超时。
2.3 VS Code 开发时的扩展选择
我平时用 VS Code 写代码比较多,Avalonia 在 VS Code 下的体验说实话不如 Visual Studio 完整,但也能用。必备的扩展是Avalonia for Visual Studio Code,它提供了 XAML 的智能提示和预览功能。另外C# Dev Kit也是必须的,不然连基本的代码补全都没有。
不过要提醒一句,Avalonia 的 XAML 预览在 VS Code 下经常抽风,尤其是用了自定义控件或者复杂绑定的时候,预览窗口要么空白要么报错。我的建议是别太依赖预览,直接跑起来看效果更靠谱。VS Code 下调试 Avalonia 应用跟普通 .NET 应用一样,F5 就能启动,断点也能正常打。
3. Modbus TCP 通信层的设计取舍
3.1 为什么用 NModbus 而不是自己撸协议
Modbus TCP 的协议本身不复杂,报文结构就是 MBAP 头加 PDU,功能码 03 读保持寄存器、04 读输入寄存器、06 写单个寄存器、16 写多个寄存器,这些我都清楚。理论上自己用TcpClient拼字节流完全可行,但实际做下来会发现一堆琐碎的事:事务标识符的管理、异常码的解析、超时重连、字节序转换,每一项都能写出 bug。
NModbus 这个库把这些都封装好了,ModbusIpMaster提供了ReadHoldingRegisters、ReadInputRegisters、WriteSingleRegister这些方法,调用起来很直观。而且它是 MIT 协议的,商用没问题。我用的版本是 NModbus 3.0.81,通过 NuGet 安装:
dotnet add package NModbus这里有个小坑:NModbus 的命名空间在 3.x 版本里改过,老教程里写的Modbus.Device在新版本里变成了NModbus。如果你照着几年前的博客写代码,using Modbus.Device;会直接报找不到命名空间。
3.2 连接管理与断线重连的实战处理
工业现场的网络环境跟办公室完全不是一个概念,网线被叉车压断、交换机断电、设备重启,这些都是家常便饭。所以通信层必须能扛住断线,不能一断就整个界面卡死。
我的做法是封装一个ModbusService类,内部维护TcpClient和IModbusMaster实例,用一个SemaphoreSlim保证同一时刻只有一个线程在读写:
public class ModbusService : IDisposable { private TcpClient? _tcpClient; private IModbusMaster? _master; private readonly SemaphoreSlim _lock = new(1, 1); private readonly string _ip; private readonly int _port; public bool IsConnected => _tcpClient?.Connected ?? false; public ModbusService(string ip, int port = 502) { _ip = ip; _port = port; } public async Task<bool> ConnectAsync() { await _lock.WaitAsync(); try { if (_tcpClient?.Connected == true) return true; _tcpClient?.Close(); _tcpClient = new TcpClient(); await _tcpClient.ConnectAsync(_ip, _port); _master = ModbusIpMaster.CreateIp(_tcpClient); _master.Transport.ReadTimeout = 1000; _master.Transport.WriteTimeout = 1000; return true; } catch { return false; } finally { _lock.Release(); } } }超时设置很关键。默认的读超时是无限等待,设备一旦不响应,读操作就会一直挂着,界面上的轮询任务全部堵死。我设成 1000 毫秒,超过就抛异常,外层捕获后标记连接断开,等下一轮轮询再尝试重连。
轮询这块我用的是PeriodicTimer,每 500 毫秒读一次。为什么是 500 毫秒而不是更快?因为 Modbus TCP 本身是请求-响应模式,一次读操作要经历发送请求、等待响应两个阶段,加上设备端的处理时间,实际一轮下来差不多要几十毫秒。如果设成 100 毫秒,请求会堆积,反而容易超时。500 毫秒对于温度、压力这种变化缓慢的物理量来说完全够用。
3.3 寄存器地址映射与数据类型转换
这是整个通信层最容易出错的地方。Modbus 的寄存器是 16 位的,但实际设备里的数据可能是 32 位浮点数、32 位整数,甚至 64 位的。一个 32 位浮点数要占两个连续的寄存器,怎么拼这两个寄存器,不同厂家的设备可能不一样。
常见的有两种字节序:大端(ABCD)和小端(DCBA),还有混合的(BADC、CDAB)。我这次遇到的设备用的是 CDAB 顺序,也就是低字在前、高字在后,每个字内部又是大端。第一次读出来的温度值是 1.2e-38 这种离谱的数字,排查了半天才发现是字节序搞反了。
转换的代码大概是这样:
public static float RegistersToFloat(ushort[] registers, int startIndex, ByteOrder order) { var bytes = new byte[4]; switch (order) { case ByteOrder.ABCD: bytes[0] = (byte)(registers[startIndex] >> 8); bytes[1] = (byte)(registers[startIndex] & 0xFF); bytes[2] = (byte)(registers[startIndex + 1] >> 8); bytes[3] = (byte)(registers[startIndex + 1] & 0xFF); break; case ByteOrder.CDAB: bytes[0] = (byte)(registers[startIndex + 1] >> 8); bytes[1] = (byte)(registers[startIndex + 1] & 0xFF); bytes[2] = (byte)(registers[startIndex] >> 8); bytes[3] = (byte)(registers[startIndex] & 0xFF); break; } if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); }提示:字节序这个问题没有通用解法,只能对着设备手册一个一个试。我的经验是先读一个已知值的寄存器(比如设备型号或者固定参数),用四种顺序各转一遍,哪个结果合理就用哪个。
4. 数据绑定:从设备到界面的完整链路
4.1 INotifyPropertyChanged 在 Avalonia 里的实际表现
Avalonia 的数据绑定机制跟 WPF 基本一致,都是依赖INotifyPropertyChanged接口。属性值变了,触发PropertyChanged事件,绑定引擎收到通知后更新界面。这个链路听起来简单,但实际用的时候有几个地方特别容易出问题。
第一个问题是属性通知的线程。Modbus 的轮询是在后台线程跑的,读回来的数据直接赋值给 ViewModel 的属性,这时候PropertyChanged事件是在后台线程触发的。WPF 里这会直接抛异常,因为 UI 元素只能在 UI 线程访问。Avalonia 稍微宽容一点,简单属性的绑定在后台线程更新有时不会报错,但涉及集合操作(比如往ObservableCollection里加数据)就一定会炸。
正确的做法是用Dispatcher.UIThread.Post或者InvokeAsync把更新操作切回 UI 线程:
await Dispatcher.UIThread.InvokeAsync(() => { DeviceList[i].Temperature = value; });我一开始图省事没切线程,结果跑了大概十几分钟就随机崩溃,错误信息是Collection was modified或者Call from invalid thread,排查起来很痛苦。后来统一在数据更新入口处切线程,就再没出过这个问题。
第二个问题是 CommunityToolkit 的[ObservableProperty]源生成器。它的用法是在字段上打特性:
public partial class DeviceViewModel : ObservableObject { [ObservableProperty] private float _temperature; [ObservableProperty] private string _status = "离线"; }编译后会生成Temperature和Status属性,setter 里自动调用OnPropertyChanged。这里有个坑:类必须声明为partial,字段名必须以下划线开头或者小写字母开头,否则源生成器不工作。我第一次写的时候忘了加partial,编译没报错但属性通知完全不生效,界面上的值一直不刷新,查了半天才发现是这个问题。
4.2 DataGrid 绑定 ObservableCollection 的性能陷阱
设备列表我用的是DataGrid,数据源是ObservableCollection<DeviceViewModel>。六台设备的时候一切正常,但后来客户说要扩展到三十台,我测试了一下发现界面明显卡顿。
原因在于ObservableCollection的CollectionChanged事件会触发 DataGrid 重新计算布局。如果频繁地往集合里增删元素,每次都会触发一次完整的布局更新。解决办法是批量操作时先清空再一次性添加,或者用BindingOperations暂停通知。
不过对于这个项目来说,设备数量是固定的,集合本身不会频繁变化,变化的是每个设备对象内部的属性值。这种情况ObservableCollection的开销其实不大,真正影响性能的是 DataGrid 的单元格渲染。三十行乘以十几列就是三百多个单元格,每个单元格都要走一遍绑定和渲染流程。
我的优化手段是关掉一些不必要的功能:
<DataGrid AutoGenerateColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal" HeadersVisibility="Column">CanUserSortColumns关掉是因为排序对实时数据没意义,而且排序会触发集合的重新排列,白白消耗性能。CanUserResizeColumns关掉是因为列宽我在 XAML 里写死了,不需要用户调整。
4.3 值转换器在工业场景下的妙用
设备读回来的原始值是数字,但界面上要显示成有意义的信息。比如运行状态寄存器,0 表示停机、1 表示运行、2 表示故障,直接显示数字没人看得懂。这时候就需要值转换器。
Avalonia 的值转换器实现IValueConverter接口:
public class StatusToTextConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { return value switch { 0 => "停机", 1 => "运行", 2 => "故障", _ => "未知" }; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); }在 XAML 里声明资源并引用:
<UserControl.Resources> <local:StatusToTextConverter x:Key="StatusConverter" /> </UserControl.Resources> <DataGridTextColumn Header="状态" Binding="{Binding Status, Converter={StaticResource StatusConverter}}" />除了文本转换,颜色转换也很常用。运行状态显示绿色、故障显示红色,这种视觉反馈在工业监控里非常重要,操作员扫一眼就能发现问题。我写了一个StatusToBrushConverter,返回IBrush对象,绑定到单元格的背景色或者一个圆点的填充色上。
注意:值转换器里不要做耗时操作,它会在每次绑定时被调用。如果转换逻辑复杂,考虑在 ViewModel 里预先算好,绑定到一个现成的属性上。
5. 界面布局与实时刷新的那些坑
5.1 轮询频率与界面卡顿的平衡
前面提到轮询间隔是 500 毫秒,但界面刷新频率不一定跟轮询频率一致。如果每读一次数据就更新一次界面,三十台设备乘以十几个参数,每秒要触发几百次属性通知,UI 线程会被淹没。
我的做法是在 ViewModel 里维护一个"脏数据"标记,轮询线程只负责往一个缓冲区写数据,UI 线程每隔一秒从缓冲区取一次数据批量更新。这样界面刷新频率降到 1Hz,肉眼看起来完全够用,CPU 占用也降下来了。
具体实现是用一个ConcurrentQueue或者Channel做生产者-消费者队列,轮询线程是生产者,UI 定时器是消费者。Channel是 .NET 里比较新的 API,性能比BlockingCollection好,用法也不复杂:
private readonly Channel<DeviceData> _channel = Channel.CreateBounded<DeviceData>(new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.DropOldest });DropOldest这个模式在监控场景下很合适,数据积压的时候丢掉最旧的,保证界面上显示的是最新值。
5.2 仪表盘控件的选择与自绘
温度、压力这些模拟量用数字显示不够直观,客户要求有仪表盘。Avalonia 自带的控件里没有仪表盘,得自己画或者用第三方库。
第三方库我试过几个,要么收费要么样式不符合工业审美。最后决定用 Avalonia 的Path和Arc自己画一个简单的半圆仪表。核心是用Arc画背景弧和数值弧,用RotateTransform控制指针角度。
<Canvas Width="200" Height="120"> <Path Stroke="LightGray" StrokeThickness="10" Data="M 20,100 A 80,80 0 0 1 180,100" /> <Path Stroke="DodgerBlue" StrokeThickness="10" Data="{Binding GaugeArc}" /> <Line X1="100" Y1="100" X2="100" Y2="40" Stroke="Black" StrokeThickness="2" RenderTransformOrigin="0.5,1"> <Line.RenderTransform> <RotateTransform Angle="{Binding NeedleAngle}" /> </Line.RenderTransform> </Line> </Canvas>GaugeArc和NeedleAngle都是在 ViewModel 里根据当前值算出来的。弧线的Data属性是一个路径字符串,需要根据数值动态生成,这块逻辑稍微有点绕,但写一次就能复用。
自绘仪表盘的好处是完全可控,颜色、大小、刻度都能按客户要求调。坏处是要自己处理数值范围映射、角度计算这些数学问题。我的建议是如果项目里仪表盘数量不多,自绘完全可行;如果大量使用,还是找个成熟的图表库更省事。
5.3 异常弹窗与日志记录的实现
设备故障的时候要弹窗提示,这个功能看起来简单,但实际做的时候要考虑几个问题:弹窗不能阻塞轮询线程、同一故障不能反复弹、弹窗关闭后要能恢复。
我用的是 Avalonia 的Window做一个自定义的提示窗口,通过ShowDialog模态显示。但ShowDialog是异步的,在轮询线程里直接调用会有问题,必须切到 UI 线程:
Dispatcher.UIThread.Post(async () => { var dialog = new AlarmWindow(); dialog.SetMessage($"设备 {deviceName} 温度超限:{temperature}℃"); await dialog.ShowDialog(mainWindow); });防重复弹窗用一个HashSet<string>记录当前活跃的报警,报警消除后从集合里移除。这个逻辑放在 ViewModel 里,弹窗之前先检查集合里有没有相同的报警。
日志记录我用的是简单的文本文件写入,按天分文件,格式是yyyy-MM-dd.log。工业现场一般不需要复杂的日志框架,能记录时间、设备、参数、事件类型就够了。写入的时候注意加锁,多个线程同时写会出问题。
6. 部署与运行时的意外状况
6.1 发布配置与目标平台选择
Avalonia 应用发布的时候有几个选项要选:目标框架、是否自包含、是否单文件。客户那边的工控机是 Windows 7,.NET 8 官方不支持 Win7,最低要 .NET 6。所以我把目标框架定在net6.0。
发布命令:
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true--self-contained true意味着把 .NET 运行时也打包进去,生成的文件夹大概 150MB 左右。好处是目标机器不需要装 .NET 运行时,拷过去就能跑。对于工业现场这种不方便装环境的情况,自包含发布是最省心的。
PublishSingleFile会把所有 DLL 合并成一个 exe,但 Avalonia 的原生库(比如libSkiaSharp.dll)不会被合并进去,还是会散落在文件夹里。所以最终交付的是一个文件夹,不是单个文件。这点要有心理预期,别以为设了单文件就真的只有一个 exe。
6.2 现场部署时遇到的显示问题
第一次去现场部署,程序在开发机上跑得好好的,到了工控机上界面直接花了。排查后发现是显卡驱动的问题。Avalonia 默认用 Skia 渲染,在某些老显卡上会走软件渲染路径,性能差而且可能渲染异常。
解决办法是在Program.cs里配置渲染选项:
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>() .UsePlatformDetect() .With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } }) .LogToTrace();强制用软件渲染,虽然性能差一点,但兼容性最好。对于这种数据刷新频率不高的监控面板来说,软件渲染完全够用。
另一个问题是分辨率。工控机的屏幕是 1024x768,我开发的时候用的是 1920x1080,界面布局在高分屏上排得好好的,到低分屏上就挤成一团。后来把窗口最小尺寸设成 1024x768,所有控件用Grid的相对布局而不是固定像素,才解决了这个问题。
6.3 长时间运行的内存与句柄泄漏
工业软件是要 7x24 小时运行的,内存泄漏是致命的。我跑了一个通宵的测试,发现内存从 80MB 涨到了 300MB,虽然没崩但趋势不对。
用 dotMemory 抓了一下,发现是TcpClient重连的时候旧的实例没有释放。每次断线重连都会 new 一个TcpClient,旧的虽然调了Close()但底层的 socket 句柄没有立即回收。改成实现IDisposable并在重连前显式Dispose旧实例后,内存稳定在 100MB 左右不再增长。
另外PeriodicTimer和CancellationTokenSource也要注意释放,尤其是在窗口关闭的时候。我在MainWindow的Closing事件里取消了所有后台任务,确保进程能干净退出。
提示:工业现场部署前一定要做至少 48 小时的老化测试,观察内存、句柄数、CPU 占用的变化趋势。很多问题在开发阶段跑几分钟是看不出来的。
7. 几个让我印象深刻的排查过程
7.1 数据偶尔跳变到异常值的根因
测试的时候发现温度值偶尔会跳到一个完全不可能的数字,比如 6553.5 或者 -0.1,下一轮又恢复正常。一开始怀疑是设备端的问题,但用 Modbus 调试工具直接读寄存器,原始值一直是稳定的。
问题出在读取和转换之间。我的代码是先读两个寄存器,然后转成 float。但 NModbus 的ReadHoldingRegisters返回的是ushort[],如果读取过程中设备刚好在更新这个寄存器(比如 PLC 的扫描周期刚好切到这个变量),就可能读到高字是新值、低字是旧值的组合,转换出来就是一个乱七八糟的数字。
这种问题在工业通信里叫"数据撕裂",解决办法是读两次,如果两次结果一致才采用,不一致就丢弃等下一轮。虽然牺牲了一点实时性,但数据可靠性大大提升。另一种做法是设备端支持的话,用连续寄存器的原子读取功能,但很多小型设备不支持。
7.2 DataGrid 滚动时绑定失效的诡异现象
DataGrid 数据多了之后出现滚动条,滚动的时候发现有些单元格的值不更新了。这个现象很迷惑,因为不滚动的时候一切正常。
查了资料才明白,Avalonia 的 DataGrid 用了虚拟化技术,只渲染可见区域的行。滚动的时候行会被回收和重用,绑定上下文会重新设置。如果 ViewModel 的属性通知时机不对,回收后的行可能绑定了旧的数据上下文。
解决办法是确保DataContext正确设置,并且在DataGrid上显式开启行虚拟化:
<DataGrid VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling">Recycling模式会重用行容器,性能更好,但对绑定的要求更高。如果遇到显示错乱,可以试试改成Standard模式,虽然性能差一点但更稳定。
7.3 跨线程更新集合导致的随机崩溃
前面提过ObservableCollection的跨线程问题,这里展开说一下排查过程。崩溃是随机的,有时候跑几小时才出现一次,错误信息也不固定,有时候是InvalidOperationException,有时候直接访问违例。
我一开始在所有的集合操作外面加了lock,但问题依旧。后来才想明白,lock只能保证同一时刻只有一个线程在操作集合,但ObservableCollection在变更时会触发CollectionChanged事件,订阅这个事件的 DataGrid 会在事件处理器里访问 UI 元素。如果这个事件是在后台线程触发的,DataGrid 的处理器就在后台线程执行,访问 UI 元素就崩了。
所以lock解决不了这个问题,必须在源头就把集合操作切到 UI 线程。我最后的方案是所有对ObservableCollection的写操作都通过一个UiThreadInvoker封装,确保在 UI 线程执行。读操作(比如遍历)如果只在 UI 线程做,就不需要额外处理。
这个坑的教训是:在 Avalonia/WPF 里,任何涉及 UI 绑定的集合,增删改都必须在 UI 线程。不要心存侥幸觉得"偶尔在后台线程改一下没关系",它一定会在某个你最不希望的时候崩给你看。
8. 关于这套技术栈的一些个人体会
Avalonia 做工业上位机是可行的,跨平台能力确实比 WPF 强,API 设计也基本沿袭了 WPF 的思路,有 WPF 经验的人上手不算太难。但它的生态确实还不如 WPF 成熟,遇到问题搜到的资料少,很多坑得自己踩。
NModbus 这个库够用,简单场景下没什么问题。但如果你要做 Modbus RTU over TCP、或者需要处理大量并发连接,可能需要考虑更专业的通信库,或者自己在 NModbus 基础上做一层封装。
数据绑定这块,CommunityToolkit.Mvvm 的源生成器确实能省不少代码,但它的"魔法"也带来了一些调试上的困难。属性通知不生效的时候,你没法直接看到生成的代码,只能靠经验判断是不是partial忘了加、字段命名不对之类的问题。
最后说一个实际部署后的反馈:客户用了两个月,反馈说界面响应速度比之前的 WinForm 版本快,而且没出现过崩溃。这让我对 Avalonia 在工业场景下的稳定性有了信心。如果后续还有类似的项目,我大概率会继续用这套组合,但会把通信层和 UI 层的解耦做得更彻底一些,方便单独测试和替换。