简介:面向希望掌握跨平台UI开发的.NET开发者,这是一份基于Avalonia框架的完整桌面应用工程。项目整合LiveChart2数据可视化库与SukiUI扩展组件,涵盖仪表盘、进度、数据表格等典型界面,并已处理Linux环境下默认字体显示问题,在Ubuntu 20.04上验证可正常运行。压缩包共1031个文件,约90.39MB,以734个C#源码、211个AXAML界面文件为主,另有16个TTF与7个OTF字体资源,以及XAML样式、项目配置和图标文件,结构清晰便于拆解学习。内容包含Button、DataGrid、DatePicker、SplitView等多个控件的样式定义与Playground/Dashboard等视图示例,读者可借此掌握Avalonia的XAML布局、MVVM数据绑定、LiveChart2图表动态更新及SukiUI主题定制等关键技能。资源已有1782人学习,适合想从WPF平滑过渡到跨平台场景,并希望补充数据可视化与UI美化经验的开发者。 搞跨平台桌面程序这几年,我一直是 WPF 的重度用户,后来被逼着接触 Avalonia,结果一上手就回不去了。这框架最大的好处是 XAML 那一套是现成的,Windows、Linux、macOS 一套代码通吃,而且社区现在活跃度明显上来了。不过 Avalonia 也确实有不少暗坑,最典型的就是默认字体问题——在 Windows 上写好的界面,一跑 Linux 就满屏“豆腐块”,中文全变方块。后来我干脆做了一个完整的小项目,把 LiveChart2 和 SukiUI 这两个库一起整合进去,顺便把字体问题彻底解决了。这篇文章就把我的完整过程和踩坑记录分享出来,给正准备从 WPF 迁移或者刚入手 Avalonia 的同学一个参考。内容偏向实操,不需要你有太深的框架底子,照着做基本都能跑起来。
1. 项目整体设计与方案选型
1.1 为什么是 Avalonia,而不是继续用 WPF
先聊一个最基础的问题:项目选型的时候为什么从 WPF 迁到 Avalonia。如果你只做 Windows 桌面端,WPF 确实够用,稳定且生态成熟。但现在很多内部工具、数据看板、物联网后台都需要在 Linux 服务器或者国产环境下跑,WPF 在跨平台这件事上基本帮不上忙。Avalonia 最大特点就是用 XAML 描述界面,同时渲染层不依赖系统控件,而是自绘,所以它在 Windows 和 Linux 上页面表现几乎一致。这对于做数据监控类、展示类的桌面项目来说非常关键。
Avalonia 还有一个很实际的好处:MVVM 的玩法完全兼容,你以前写的 WPF ViewModel 基本可以原样搬过来。我这次项目里的几个 ViewModel 就是从旧代码里平移过来的,几乎没有改任何绑定逻辑。这也是我刚上手 Avalonia 能快速搞定项目的原因之一。另外一个需要提的点是 Avalonia 11 之后,控件模板机制和样式系统都成熟了很多,特别是Styles支持类似 CSS 的嵌套选择和类选择器,做主题定制比 WPF 顺手得多。
选型的时候我其实也对比过其他几个方案,比如 Uno Platform、MAUI 还有跨平台的 Electron。Uno 的强项在于可以编译到 WebAssembly,但桌面端体验不如 Avalonia 直接;MAUI 发展势头不错,不过 Linux 支持一直不太明朗;Electron 体积和内存占用对我来说太大了,一个小型工具就要包几百 MB 的运行时,实在没必要。所以最终确认使用 Avalonia,它在这几个候选里是和 WPF 开发习惯最接近,跨平台落地也最实在的一个。
1.2 SukiUI 与 LiveChart2 的组合逻辑
界面库和图表库的选择,我是这么考虑的。Avalonia 原生的控件长得很朴素,一个Button一个TextBox,做内部工具没问题,但如果要给用户看,甚至要拿出去演示,就需要一套相对完整的视觉规范。当时调研了几套 UI 库,像 FluentAvalonia 走的是 WinUI 风格,Materiual Design 风格的库也有,但最终我选了 SukiUI。
SukiUI 吸引我的几点:第一,它内置了一套相对完整的浅色深色配色体系,不需要你自己再去设计色板;第二,它提供了现成的SukiWindow、SukiSideMenu、SukiControlCenter这类一些 Avalonia 原生没有的容器控件,特别适合做那种“左侧菜单 + 右侧内容页”的传统桌面工具;第三,动画过渡做得很克制,不会像某些库那样花里胡哨影响性能。从项目定位“简约可用”来看,SukiUI 的视觉风格很贴合,不会喧宾夺主。
图表部分用 LiveChart2,其实是当前 Avalonia 生态里最省心的选择。LiveChart2 是 LiveCharts 的重写版,底层直接走 SkiaSharp 渲染,所以它和 Avalonia 的兼容性天然就好。和旧版 LiveCharts 相比,LiveChart2 的 API 设计更简洁,性能也明显更优,一万多个数据点实时刷新也没什么压力。而且它对 MVVM 的支持非常彻底,图表的数据可以直接绑定到 ViewModel 的集合,不需要像某些图表库那样在后台代码里拼图形对象。
这套组合的核心逻辑是:SukiUI 负责“外观和布局”,LiveChart2 负责“数据和可视化”,Avalonia 负责“跨平台和通用机制”。三者各管一摊,互不干扰。整合过程中主要的工作量在初始化配置,真正写业务代码时反而非常顺畅。
2. 默认字体问题的根源与解决思路
2.1 字体问题到底出在哪里
Avalonia 默认字体问题,几乎每个入坑的人都会遇到一次。现象是:代码里没指定FontFamily的时候,Windows 上显示正常,到 Linux 上中文就变成方块或乱码;或者在某些精简版 Linux 上,连英文都可能变得特别难看。原因其实不复杂:Avalonia 自带的默认字体FontFamily.Default依赖操作系统的字体环境来做 fallback。
Windows 自带微软雅黑和宋体,字体匹配技术也相对成熟,所以FontFamily.Default能顺利找到中文字体。但 Linux 上没有统一的字体管理规范,如果系统里恰好没有安装包含中文 glyph 的字体,Avalonia 的字体匹配就会失败,最终显示成“豆腐块”。macOS 上情况好一些,但也不完美,比如某些自绘界面里苹方字体和 Avalonia 的字体度量会有偏差,导致文字被截断。
还有一个更隐藏的因素:Avalonia 的 FontManager 在处理 fallback 时,和 Windows 上的 DirectWrite 逻辑不完全一样。DirectWrite 会根据字符所属的 Unicode 区块自动找系统中对应的字体,而 Avalonia 的字体 fallback 链条在某些版本里不够完整,所以即便你系统里装了中文字体,只要默认字体里没有,Avalonia 也不一定会主动去搜索它。这就是为什么同一个程序在 Windows 正常、到 Linux 出问题。
2.2 解决字体问题的方案对比
解决 Avalonia 字体问题,业内常见有几种方法。
第一种,直接给顶层窗口或全局样式设置一个明确的FontFamily,比如说FontFamily="Microsoft YaHei"。这个办法在 Windows 上立竿见影,但一跨平台就露馅,因为 Linux/macOS 上根本没有“微软雅黑”这个字体,你会得到一个异常或者直接退回默认字体。
第二种,在系统里安装字体。让用户去装一个字体文件,比如在 Linux 上执行fc-cache之类的手动操作,对开发者来说省事,但用户体验很差,不符合“开箱即用”的目标。
第三种,也是我最终采用的办法:把字体文件作为资源一起打包进程序,然后通过avares://这种 Avalonia 的嵌入式资源协议来引用它。这样做的好处是彻底摆脱了对操作系统的依赖,不管在什么环境下运行,字体都是同一个版本,渲染效果统一可控。唯一注意的点是需要选择一个可商用的开源字体,这一类我后面细说。
我最终选定的字体的思路上,没有用常见的“微软雅黑”或者某些仿宋,而是选了一款开源中文字体,体积适中,字重完整,而且支持简体中文常见生僻字。同时兼顾了一点点代码显示的辨识度。文件名放到Assets/Fonts目录下面,设置Build Action为EmbeddedResource,然后在 App.axaml 里通过资源路径引用。
2.3 字体方案落地细节
落地步骤其实不复杂,我这里给出核心代码。
首先,在项目的.csproj里添加字体文件引用,并确保属性为EmbeddedResource:
<ItemGroup> <EmbeddedResource Include="Assets\Fonts\MyFont.ttf" /> </ItemGroup>然后在App.axaml的<Application.Resources>里注册这个字体:
<Application.Resources> <FontFamily x:Key="AppDefaultFont">avares://YourProjectName/Assets/Fonts/MyFont.ttf#字体家族名</FontFamily> </Application.Resources>最后在App.axaml的窗口样式或者全局样式中引用这个资源:
<Style Selector="Window"> <Setter Property="FontFamily" Value="{DynamicResource AppDefaultFont}" /> </Style>这里有一个非常容易踩的坑:#号后面的字体家族名填错,整个字体引用就会失效。这个名称并不是文件名,而是字体文件内部定义的字体家族名称。可以用 Windows 自带的字体预览器打开 TTF 文件查看,或者用一些字体管理工具查看元数据。如果填错了,Avalonia 一般不会报错,只会静默 fallback 到默认字体,结果就是你以为设置成功了,实际上并没有生效。
另外在 Linux 打包时,字体文件路径要注意大小写。Avalonia 的资源路径是大小写敏感的,/assets/fonts/和/Assets/Fonts/是两个完全不同的路径。我一开始在 Windows 上调试没注意,到了 Linux 上打了解包之后才排查到是路径大小写问题。
如果你不想把字体打进程序里,也可以试试在运行时动态加载,比如从System.Fonts读取系统字体做 fallback,这种方案灵活性更高,但复杂度也会上升不少。对“简约可用”定位的项目来说,把字体打进包里是最可靠、最省心的路径。
3. LiveChart2 图表库的实际接入
3.1 安装与控件引入
LiveChart2 接入 Avalonia 的方式非常简单,NuGet 安装一个包就好。这里要特别强调版本匹配,我用的 Avalonia 11.0.9,对应安装的 LiveChartsCore.SkiaSharpView.Avalonia 版本是 2.0.0-rc2 左右。如果你用 Avalonia 10 或者更早的版本,必须选旧版 LiveCharts,不能混用。
安装完成之后,在页面 XAML 头部添加引用:
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.Avalonia;assembly=LiveChartsCore.SkiaSharpView.Avalonia"然后在界面上添加图表控件:
<lvc:CartesianChart Series="{Binding Series}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" TooltipPosition="Top" />CartesianChart是 LiveChart2 里最常用的图表容器,用来画折线图、柱状图、面积图都行。它有几个核心属性:Series是数据系列集合,XAxes和YAxes配置坐标轴,TooltipPosition控制悬浮提示的位置。绑定的数据源在 ViewModel 里定义,这也是我选择 LiveChart2 的主要原因——图表数据和业务数据自然融合,不需要在 Code-behind 里操作任何控件实例。
3.2 数据绑定与配置
ViewModel 里的定义大概长这样:
public ISeries[] Series { get; set; } = { new LineSeries<double> { Values = new double[] { 2, 4, 1, 5, 3, 6, 8 }, Stroke = new SolidColorPaint(SKColors.CornflowerBlue, 2), Fill = null }, new ColumnSeries<double> { Values = new double[] { 1, 3, 2, 4, 3, 5, 4 }, Stroke = null, Fill = new SolidColorPaint(SKColors.LightCoral) } };重点说几个配置细节。Stroke设置线条的颜色和粗细,Stroke = null表示不要描边;Fill = null表示不要填充区域,这在折线图上很常用,不然默认会在线条下方铺一层渐变填充,看起来不够干净。ColumnSeries会按 index 自动和LineSeries对齐 X 轴位置,所以两组数据可以叠加展示,这在数据对比场景下很方便。
坐标轴的配置也不能忽略。Avalonia 下用 SkiaSharp 画坐标轴,需要配置TextSize、LabelsPaint、SeparatorsPaint这些属性,否则坐标轴文字很小,而且默认颜色在深色主题下看不清:
public Axis[] XAxes { get; set; } = { new Axis { Labels = new[] { "周一", "周二", "周三", "周四", "周五", "周六", "周日" }, TextSize = 12, SeparatorsPaint = new SolidColorPaint(SKColors.LightGray) { StrokeThickness = 0.5f } } }; public Axis[] YAxes { get; set; } = { new Axis { MinLimit = 0, MaxLimit = 10, TextSize = 12, SeparatorsPaint = new SolidColorPaint(SKColors.LightGray) { StrokeThickness = 0.5f } } };MinLimit和MaxLimit建议显式设置,如果省略,LiveChart2 会根据数据自动计算范围,导致不同的图表之间 Y 轴刻度不一致,数据对比时容易产生误导。单位如果比较特殊,还可以在Labeler里做格式化,比如加上%或者k后缀。
3.3 动态数据刷新的注意点
实时刷新的场景,比如定时从后端拉取数据绘制波形图,要特别注意 LiveChart2 的线程模型。LiveChart2 的绑定数据源支持ObservableCollection,但你如果在后台线程里给集合添加数据,界面不会自动刷新,需要借助Dispatcher.UIThread.Post切换到 UI 线程:
Dispatcher.UIThread.Post(() => { ObservableCollection<double> values = (ObservableCollection<double>)Series[0].Values; values.Add(newValue); if (values.Count > 200) values.RemoveAt(0); });这里还有另一个容易忽略的问题:频繁地往ObservableCollection里塞数据会触发多次 UI 刷新,数据点一多性能就会明显下滑。一个比较实用的优化是采用缓冲、批量刷新的方式,比如每秒把攒好的一批数据一次性更新到集合中,而不是拿一条刷一条。实测下来,同样 500 个数据点,批量刷新的 CPU 占用要比逐条刷新低三分之一左右。
另外,LiveChart2 的性能在数据点非常多时依然有限,如果单条序列超过 5000 个点,建议提前做降采样或滚动窗口,否则拖动图表时会有明显的卡顿感。我之前做实时监控页时,就是靠每秒 20 个点、保留最近 300 个点的方式,才在普通办公电脑上保持了页面流畅。
4. SukiUI 主题框架的整合实录
4.1 SukiUI 的初始化和基本用法
SukiUI 的引入方式比较直接,NuGet 安装包之后,改动集中在App.axaml和窗口定义上。先说App.axaml的配置:
<Application.Styles> <StyleInclude Source="avares://SukiUI/Controls/SukiUI.axaml" /> <StyleInclude Source="avares://SukiUI/Controls/SukiUI.Cursors.axaml" /> </Application.Styles>这里必须把SukiUI.axaml放在最上面,因为它是其他样式的基础。如果你在全局样式里还用到了 Avalonia 自带的FluentTheme,注意顺序,谁在前面谁先执行。SukiUI 有自己的一套控件样式,如果FluentTheme在其之前执行,部分控件的外观会混乱,甚至出现两个系统同时争抢样式的情况。
窗口定义上,把原来的Window替换成 SukiUI 的SukiWindow:
<suki:SukiWindow x:Class="YourApp.MainWindow" xmlns:suki="clr-namespace:SukiUI;assembly=SukiUI" Title="我的数据看板" Width="1280" Height="800" Background="#FAFAFA"> </suki:SukiWindow>SukiWindow支持标题栏自定义样式、圆角、阴影效果以及深色模式的自动切换。如果你用它的SukiSideMenu做导航,结构一般长这样:
<suki:SukiWindow> <suki:SukiSideMenu ItemsSource="{Binding MenuItems}" SelectedItem="{Binding SelectedItem}"> <!-- 右侧内容区域 --> </suki:SukiSideMenu> </suki:SukiWindow>需要提醒的是,SukiUI 的版本迭代中 API 变化不小,我见过好几个项目用的SukiWindow属性和当前最新版本对不上。一定要在你安装的 NuGet 版本对应的文档下翻 API,不要拿旧版项目的写法直接套新版。
4.2 与 Avalonia 原生控件混用时的样式覆盖
SukiUI 虽然提供了一套完整样式,但它并没有覆盖 Avalonia 的所有控件。实际使用中,我经常是 SukiUI 控件和原生控件混搭。这种混用本身问题不大,但要注意样式覆盖的优先级。
比如,你在 SukiUI 基础上给某个按钮自定义样式:
<Style Selector="Button.successButton"> <Setter Property="Background" Value="#67C23A" /> <Setter Property="Foreground" Value="White" /> </Style>这个样式是正常的。但如果直接在一个没有 class 的Button上设置Background,在某些版本的 SukiUI 里可能不生效,因为 SukiUI 内部的控件模板用到了主题资源,而你在页面上的显式设置优先级低于主题里的Setter。解决这个问题的办法是给按钮加一个 class 或者直接用Classes属性来覆盖,比如Classes="primary",SukiUI 内置了对这些类名的主题支持。
另外,SukiUI 深色模式下,如果某些原生控件你没有给它配置前景色,背景会自动变成深色,但文字可能依然是深色,结果就是黑底黑字。排查时先看这个控件有没有显式设置前景色,其次看它是否继承了正确的资源。这是我踩过的坑。
4.3 主题切换和动态换肤实践
SukiUI 的另一个亮点是内置了主题切换能力。它内置了多套主题色,理论上你可以通过SukiTheme在程序运行时动态切换:
SukiTheme.GetInstance().ChangeTheme(ThemeType.Light); // 或者 Dark实测下来,这个切换做的还是比较丝滑的,整个界面颜色会平滑过渡,不需要重启程序。不过需要注意一个边界问题:如果你在代码里给某个窗口的 Background 写死了颜色,主题切换不会覆盖它,界面会变成“大花脸”。所以用主题切换功能时,所有颜色最好都通过DynamicResource引用主题资源,而不是写死。
还有一点,主题切换时图表组件的颜色不会自动变化。因为 LiveChart2 用的是 SkiaSharp 绘制,颜色是在 ViewModel 里用SKColors直接定义的,它不感知 SukiUI 的主题变化。我当时的做法是在主题切换事件里重新生成图表系列,把颜色换成对应主题下的颜色。虽然不是特别优雅,但胜在可控,切换成本也不高。
5. 常见问题与排查技巧实录
5.1 字体相关问题的速查表
字体问题是 Avalonia 项目的重灾区,我整理一个排查速查表方便你对照使用:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| Linux 下中文变方块 | 系统无中文字体,fallback 失败 | 打包开源中文字体,用avares://引用 |
| 设置了字体但看起来没变化 | 字体家族名写错了 | 用字体工具查看 GDEF 表里的 Family Name |
| 字体在某些语言字符下异常 | 字体文件子集不完整 | 换用更完整的字体,检查是否支持对应字符集 |
| 打包后路径报错 | 资源路径大小写不对 | Linux 文件系统大小写敏感,统一用小写路径 |
| 字体文件很大,程序包膨胀 | 包含太多字重或字型 | 只保留需要的字重,用字体子集化工具裁剪 |
5.2 图表不显示或闪烁的排查过程
用 LiveChart2 的时候,最常见的问题是图表一片空白。这个问题的根源多半不是绑定语法,而是数据集合类型不对。Series属性必须实现IEnumerable,而且要在 XAML 绑定之前就初始化好,如果你在构造函数里先给了空数组、后续再赋值,绑定是不会更新的。
另一个常见问题是图表闪烁。这个在数据实时刷新时特别明显,原因是ObservableCollection的每次更新都触发整个图表重绘,数据量大时绘制耗时就会超过帧间隔,表现就是画面一跳一跳的。
解决思路是降低重绘频率。我之前测试过一个方案:在 ViewModel 里用一个中间缓存队列,由后台线程把数据写入队列,UI 线程每秒统一从队列取出数据更新集合。这样既不需要每来一条数据就打断 UI,也保证了数据不会丢失。实测这个方案对 CPU 占用率下降非常明显。另外,如果你用了AnimationsSpeed属性,注意不要设置得太快,LiveChart2 的动画在快速刷新场景下反而会带来额外性能开销,实时数据页面建议直接把它关掉。
5.3 SukiUI 常见集成错误与规避
SukiUI 集成中我遇到最多的是“样式丢失”和“控件不生效”两类问题,这里挑几个重点说一下。
样式丢失多半是App.axaml里StyleInclude顺序不对,或者和原生主题冲突导致控件模板被覆盖。规避方法:不要同时引入FluentTheme和一个 UI 库,除非你很清楚你在做什么。
控件不生效,比如SukiSideMenu点击后内容区域不切换,通常是你没有写对SelectedItem的绑定。它的ItemsSource绑定的是导航项集合,SelectedItem绑定到当前选中项,然后你要在内容区域根据SelectedItem用DataTemplate做视图切换。如果只绑了ItemsSource没绑SelectedItem,导航项可以显示,但点击不会有任何反应。
还有一个小细节,SukiUI 某些控件的默认动效在低配机器上会有明显掉帧。如果项目跑在老旧设备上,可以在全局设置里关闭无意义的动画,比如窗体的淡入淡出和列表滚动动画,观感差异不大,但流畅度提升显著。
几个可以继续深挖的方向
做完整合之后,我的体会是:Avalonia 的跨平台能力和生态,已经完全可以支撑起一个正经的桌面工具链。SukiUI 负责交互和视觉,LiveChart2 负责数据可视化,字体打包补齐了最后的短板,这三家组合起来,能让一个单人维护的小项目在多个平台都保持统一的质感。
如果你打算在此基础上继续扩展,以下几个方向我觉得值得投入精力:
- 数据持久化:给项目接一个 SQLite 或者 LiteDB,把看板上的历史数据落盘。Avalonia 在这块没有任何特殊之处,直接用 .NET 的数据访问层就行。
- 插件化:如果你的项目后期会变成一个通用的数据平台,可以考虑用
MEF或者Prism做模块化,配合 SukiUI 的菜单结构做插件加载,体验会好很多。 - 自动更新:跨平台应用的自动更新比 Windows 专用程序要麻烦一些,因为涉及到不同平台的文件权限和路径规则,建议提前调研一下
Velopack或者自建的更新流程。
最后再分享一个我在实际使用中的小经验:不要一上来就追求功能大而全,先把项目主流程跑通,字体、主题、图表一个个验证通过,再逐步加需求。Avalonia 和 WPF 虽然在 XAML 上相似,但生态差异还是客观存在的,尤其在第三方库的细节上,动手之前先跑个小 Demo 验证可行性,比在完整项目里反复折腾要高效得多。
本文还有配套的精品资源,点击获取