Repository Wiki
BeyondDimension/SteamTools

主题样式与自定义控件

本文介绍 Watt Toolkit(SteamTools)Avalonia 客户端的主题系统与自定义控件样式体系:AppTheme 主题模型、App.Theme.cs 中的主题切换实现、CustomTheme 自定义样式类,以及 UI/Styling 目录下的 XAML 样式资源(控件、字体、图标、窗口等)。该体系负责应用级浅色/深色/高对比度/跟随系统主题的解析与切换,以及强调色(AccentColor)的定制。

Purpose and Scope

本页覆盖以下内容:

  • AppTheme 枚举与 IApplication.Theme 契约(主题的抽象定义)。
  • Avalonia App 分部类中 Theme 属性的完整切换控制流,包括"跟随系统"与固定主题之间的双向迁移逻辑。
  • SetThemeNotChangeValue 如何把 AppTheme 映射为 Avalonia ThemeVariant(含自定义的 HighContrastTheme 变体),并同步 LiveCharts 图表主题。
  • CustomTheme 自定义样式类(Styles + IResourceProvider)的结构与 UI/Styling 下的 XAML 资源文件。
  • SetThemeAccent 强调色设置与 FluentAvaloniaTheme 的集成。
  • 平台主题桥接:IPlatformService 的 IsLightOrDarkTheme 与 SetLightOrDarkThemeFollowingSystem。

不属于本页范围、由兄弟页面承接的内容:

  • 各平台(Windows/Linux/macOS)主题 API 的具体实现细节属于 IPlatformService.Theme 平台服务页(对应 src/BD.WTTS.Client/Services.Implementation/Platform/*/*PlatformServiceImpl.Theme.cs)。
  • DI 注册细节见 ServiceCollectionExtensions.TryAddAvaloniaThemeService.cs 所在的服务注册主题页。
  • 图表(LiveCharts)本身的绘制逻辑不在本页展开,本页仅说明其与主题的联动点。

Overview

主题系统的目标是在跨平台(Windows、Linux、macOS、移动端)的 Avalonia UI 上提供一致且可跟随系统外观切换的视觉样式,核心需求包括:

  1. 三种用户可选主题 + 跟随系统:浅色(Light)、深色(Dark)、跟随系统(FollowingSystem),另外保留高对比度(HighContrast)这一特殊变体,由 AppTheme 枚举表达(见 src/BD.WTTS.Client/Enums/AppTheme.cs)。
  2. 运行时切换无重启:通过设置 Avalonia 的 RequestedThemeVariant 触发资源字典(Themes.axaml 中按变体组织的资源)整体刷新,无需重建窗口。
  3. 第三方渲染组件同步:应用内嵌 LiveCharts 图表(如加速统计页),必须在主题切换时同步调用 LiveCharts.Configure(AddLightTheme/AddDarkTheme),否则图表配色与背景脱节。
  4. 平台级跟随系统:由 IPlatformService 暴露 IsLightOrDarkTheme(当前系统是否浅色,可为 null 表示未知)与 SetLightOrDarkThemeFollowingSystem(bool)(打开/关闭跟随系统),平台实现在 WindowsPlatformServiceImpl.Theme.cs、LinuxPlatformServiceImpl.Theme.cs、MacCatalystPlatformServiceImpl.Theme.cs 中。
  5. 自定义控件样式集中管理:src/BD.WTTS.Client.Avalonia/UI/Styling/ 目录下的 Controls.axaml、Fonts.axaml、Icons.axaml、InfoBox.axaml、Themes.axaml、Window.axaml 承载全局控件模板、字体、图标与窗口样式,CustomTheme.axaml(.cs) 是其宿主样式类。

关键术语:

  • AppTheme:平台无关的主题枚举(业务层语义)。
  • ThemeVariant:Avalonia 原生主题变体(Light/Dark/Default),驱动资源查找。
  • RequestedThemeVariant:Application 级属性,应用请求的主题变体,赋值后全局资源重新解析。
  • FluentAvaloniaTheme:FluentAvalonia 库的主题对象,提供 CustomAccentColor/PreferUserAccentColor 强调色定制。

Architecture

主题体系按职责分为四层:契约层(IApplication.Theme)、实现层(Avalonia App 分部类)、样式资源层(CustomTheme + UI/Styling XAML)、平台桥接层(IPlatformService.Theme 及各平台实现)。

Loading diagram...

分层设计意图:BD.WTTS.Client(可被非 UI 项目引用的核心层)只定义 AppTheme 语义与 IApplication.Theme 契约,不依赖 Avalonia;Avalonia 宿主项目通过分部类 App 实现切换细节;平台差异(注册表、GTK、macOS 外观 API)被隔离在 IPlatformService 各平台实现中。这样业务代码(如设置页 ViewModel)只需读写 Ioc.Get<IApplication>().Theme,无需感知渲染框架与操作系统。

Main Content

主题契约:IApplication.Theme 与 AppTheme

IApplication 以分部接口形式拆分主题相关成员(src/BD.WTTS.Client/App/IApplication.Theme.cs),为所有宿主(Avalonia 桌面、移动端等)提供统一访问点:

  • AppTheme Theme { get; set; }:当前设置的主题(含"跟随系统"这个元状态)。
  • AppTheme ActualTheme:只读派生属性,把"跟随系统"折叠为实际生效的浅色/深色。
  • AppTheme DefaultActualTheme:各宿主定义的默认值(Avalonia 宿主中为 AppTheme.FollowingSystem)。
  • static GetAppThemeByIsLightOrDarkTheme(bool):布尔到主题的内联小工具。
  • GetActualThemeByFollowingSystem():读取 IPlatformService.Instance.IsLightOrDarkTheme;当平台返回 null(无法探测)时回退到 DefaultActualTheme。

ActualTheme 的表达式主体与 GetActualThemeByFollowingSystem 的空值回退是理解整条链路的关键——平台可能无法给出系统主题,因此契约层必须显式定义回退行为。

csharp
1AppTheme ActualTheme => Theme switch 2{ 3 AppTheme.FollowingSystem => GetActualThemeByFollowingSystem(), 4 AppTheme.Light => AppTheme.Light, 5 AppTheme.Dark => AppTheme.Dark, 6 _ => DefaultActualTheme, 7};

IApplication.Theme.cs

csharp
1protected AppTheme GetActualThemeByFollowingSystem() 2{ 3 var dps = IPlatformService.Instance; 4 var isLightOrDarkTheme = dps.IsLightOrDarkTheme; 5 if (isLightOrDarkTheme.HasValue) 6 { 7 return GetAppThemeByIsLightOrDarkTheme(isLightOrDarkTheme.Value); 8 } 9 return DefaultActualTheme; 10}

IApplication.Theme.cs

主题切换控制流:App.Theme.cs

App.Theme.cs 是 Avalonia 宿主对上述契约的实现(分部类 App,即 Application 派生类)。默认值常量为 AppTheme.FollowingSystem,与应用首次启动即跟随系统外观的预期一致。

Theme setter 是整个体系最核心的状态迁移函数,需要处理四种状态组合:(旧值, 新值) ×(是否为"跟随系统")。实现要点:

  1. 去重短路:if (value == mTheme) return; 避免重复触发资源重解析。
  2. 切入"跟随系统"(新值 = FollowingSystem):读取平台当前系统主题并换算为具体变体,调用 SetLightOrDarkThemeFollowingSystem(true) 打开平台级跟随,若换算结果与当前实际主题相同则仅更新记录值(goto setValue),不触发全局样式刷新。
  3. 切离"跟随系统"(旧值 = FollowingSystem,新值为固定主题):先 SetLightOrDarkThemeFollowingSystem(false) 关闭跟随,再判断系统主题换算值是否与新值一致,一致则同样短路。
  4. 普通切换:调用 SetThemeNotChangeValue(switch_value) 应用实际样式,最后统一在标签 setValue: 处提交 mTheme = value。
csharp
1public AppTheme Theme 2{ 3 get => mTheme; 4 set 5 { 6 if (value == mTheme) return; 7 AppTheme switch_value = value; 8 9 if (value == AppTheme.FollowingSystem) 10 { 11 var dps = IPlatformService.Instance; 12 var isLightOrDarkTheme = dps.IsLightOrDarkTheme; 13 if (isLightOrDarkTheme.HasValue) 14 { 15 switch_value = IApplication.GetAppThemeByIsLightOrDarkTheme(isLightOrDarkTheme.Value); 16 dps.SetLightOrDarkThemeFollowingSystem(true); 17 if (switch_value == mTheme) goto setValue; 18 } 19 } 20 else if (mTheme == AppTheme.FollowingSystem) 21 { 22 var dps = IPlatformService.Instance; 23 dps.SetLightOrDarkThemeFollowingSystem(false); 24 var isLightOrDarkTheme = dps.IsLightOrDarkTheme; 25 if (isLightOrDarkTheme.HasValue) 26 { 27 var mThemeFS = IApplication.GetAppThemeByIsLightOrDarkTheme(isLightOrDarkTheme.Value); 28 if (mThemeFS == switch_value) goto setValue; 29 } 30 } 31 32 SetThemeNotChangeValue(switch_value); 33 34 setValue: mTheme = value; 35 } 36}

App.Theme.cs

设计意图:把"跟随系统"建模为 AppTheme 的一个元状态而非布尔开关,使得设置页只需一个下拉枚举即可表达全部语义;goto setValue 的短路优化则避免在视觉结果不变时(例如系统本来就是深色,用户从"深色"切到"跟随系统")触发一次昂贵的全应用资源重解析与图表重配置。

应用主题变体:SetThemeNotChangeValue

SetThemeNotChangeValue(AppTheme) 将业务枚举映射为 Avalonia ThemeVariant 并写入 RequestedThemeVariant;同时为 LiveCharts 配置匹配的主题,保证图表配色与应用主题一致。命名中的 "NotChangeValue" 表明它只应用样式、不修改 Theme 属性记录值,因此可安全地被系统主题变更回调复用(跟随系统时系统切换 → 直接调用本方法应用新变体,而 mTheme 保持 FollowingSystem 不变)。

csharp
1public void SetThemeNotChangeValue(AppTheme value) 2{ 3 var mode = value switch 4 { 5 AppTheme.HighContrast => CustomTheme.HighContrastTheme, 6 AppTheme.Light => ThemeVariant.Light, 7 _ => ThemeVariant.Dark, 8 }; 9 10 RequestedThemeVariant = mode; 11 12 if (value == AppTheme.Light) 13 { 14 LiveCharts.Configure(settings => settings.AddLightTheme()); 15 } 16 else 17 { 18 LiveCharts.Configure(settings => settings.AddDarkTheme()); 19 } 20}

App.Theme.cs

注意映射细节:_ => ThemeVariant.Dark 是兜底分支,意味着深色也是除 Light/HighContrast 之外所有值的缺省呈现——这与深色作为默认视觉基调的产品取向一致。

强调色定制:SetThemeAccent

SetThemeAccent(string? colorHex) 从应用样式中查找 FluentAvaloniaTheme 实例并定制强调色:

  • 参数为合法十六进制颜色(Color.TryParse 成功)→ CustomAccentColor = color,PreferUserAccentColor = false(使用应用自定义强调色)。
  • 参数为字符串 "True"(bool.TrueString,大小写不敏感)→ 清空 CustomAccentColor 并 PreferUserAccentColor = true,回退为跟随操作系统/用户强调色。
  • colorHex 为 null 或找不到 FluentAvaloniaTheme(例如宿主未加载该样式)时直接返回,静默容错。
csharp
1public static void SetThemeAccent(string? colorHex) 2{ 3 if (colorHex == null) 4 { 5 return; 6 } 7 8 var thm = App.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault(); 9 10 if (thm == null) 11 { 12 return; 13 } 14 15 if (Color.TryParse(colorHex, out var color)) 16 { 17 thm.CustomAccentColor = color; 18 thm.PreferUserAccentColor = false; 19 } 20 else if (colorHex.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase)) 21 { 22 thm.CustomAccentColor = null; 23 thm.PreferUserAccentColor = true; 24 } 25}

App.Theme.cs

实现中保留了被注释掉的按 Windows 版本选择 PreferUserAccentColor 的历史代码(OperatingSystem.IsWindowsVersionAtLeast(6, 2) 分支),说明早期版本曾针对 Windows 8 以下系统禁用用户强调色,后续简化为"True 字符串 = 跟随用户"。方法被声明为 static 且直接遍历 App.Current.Styles,而非通过 Ioc.Get<FluentAvaloniaTheme>()(该写法同样以注释形式保留),可避免对 DI 容器中该对象生命周期的依赖。

CustomTheme 自定义样式宿主

CustomTheme(src/BD.WTTS.Client.Avalonia/UI/Styling/CustomTheme.axaml.cs)是一个派生自 Styles 并实现 IResourceProvider 的自定义样式类,由 CustomTheme.axaml 提供资源内容。它定义了三个模式常量与一个自定义高对比度变体:

  • LightModeString = "Light"、DarkModeString = "Dark"、HighContrastModeString = "HighContrast"。
  • HighContrastTheme = new ThemeVariant("HighContrast", ThemeVariant.Light)——以 Light 作为回退基变体注册的自定义变体,资源解析失败时可退回浅色资源,保证高对比度模式下未定义资源的可用性。
  • ModeProperty(StyledProperty<string>,默认 "Dark")+ Mode 属性:允许在 XAML 上以特性语法选择模式。
  • 两个构造函数(Uri baseUri 与 IServiceProvider serviceProvider,后者从 IUriContext 取 BaseUri)都汇入 Init(),通过 AvaloniaXamlLoader.Load(this) 加载 XAML 并置 _hasLoaded = true。
  • GetThemeFromIPlatformSettings() 把 Mode 字符串映射到 ThemeVariant;ResolveThemeAndInitializeSystemResources() 原本用于把系统主题写入 Application.Current.RequestedThemeVariant,但其调用在 Init() 中被显式注释掉——主题初始化职责已上移到 App 层,CustomTheme 退化为纯样式/资源载体。
  • IResourceNode.HasResources 显式返回 true,声明该节点参与资源查找链。
csharp
1public class CustomTheme : Styles, IResourceProvider 2{ 3 public const string LightModeString = "Light"; 4 public const string DarkModeString = "Dark"; 5 public const string HighContrastModeString = "HighContrast"; 6 7 public static readonly ThemeVariant HighContrastTheme = new ThemeVariant(HighContrastModeString, 8 ThemeVariant.Light); 9 10 public static readonly StyledProperty<string> ModeProperty = 11 AvaloniaProperty.Register<CustomTheme, string>(nameof(Mode), DarkModeString); 12}

CustomTheme.axaml.cs

csharp
1private ThemeVariant GetThemeFromIPlatformSettings() 2{ 3 return Mode switch 4 { 5 LightModeString => ThemeVariant.Light, 6 DarkModeString => ThemeVariant.Dark, 7 HighContrastModeString => HighContrastTheme, 8 _ => ThemeVariant.Default, 9 }; 10}

CustomTheme.axaml.cs

Styling 目录:XAML 样式资源组织

src/BD.WTTS.Client.Avalonia/UI/Styling/ 下的文件各司其职(本页依据目录清单说明职责划分,具体资源键值见各文件):

文件职责
CustomTheme.axaml / .axaml.cs自定义样式宿主,加载主题相关资源(见上一节)
Themes.axaml主题资源字典,按 ThemeVariant(Light/Dark/HighContrast)组织颜色、画刷等资源
Controls.axaml全局控件样式/模板(按钮、列表、输入等基础控件定制)
Fonts.axaml字体资源(FontFamily 定义与字体回退链)
Icons.axaml图标资源(图标字体/路径数据,供页面按 key 引用)
Window.axaml窗口级样式(标题栏、窗口边框等自定义窗口外观)
InfoBox.axaml提示信息框(InfoBox)控件样式

配套的 DI 注册入口位于 src/BD.WTTS.Client.Avalonia/Extensions/ServiceCollection/ServiceCollectionExtensions.TryAddAvaloniaThemeService.cs,把 Avalonia 主题服务接入应用服务集合(注册细节属于服务注册主题页)。

Core Flow

以"用户在设置页把主题从深色切换为跟随系统"为例的时序(方法名与判断条件均取自 App.Theme.cs):

Loading diagram...

反向流程(从跟随系统切换到固定主题)走 mTheme == AppTheme.FollowingSystem 分支:先关闭平台跟随,再比对换算值,必要时应用新变体。系统侧主题变化事件(平台层监听系统外观变化)最终也落到 SetThemeNotChangeValue,从而保证"跟随系统"时 mTheme 记录值不变、实际变体实时刷新。

Configuration Options

选项/资源类型默认值说明
IApplication.ThemeAppThemeAppTheme.FollowingSystem用户选择的主题(元状态),由 _DefaultActualTheme 常量定义默认值
IApplication.DefaultActualThemeAppThemeAppTheme.FollowingSystem(Avalonia 宿主)实际主题无法探测时的回退值
RequestedThemeVariantThemeVariant?继承系统/DefaultAvalonia 应用级请求变体,由 SetThemeNotChangeValue 写入
CustomTheme.Modestring(StyledProperty)"Dark"(DarkModeString)XAML 上选择的模式字符串
CustomTheme.HighContrastThemeThemeVariant"HighContrast"(基变体 Light)高对比度自定义变体定义
SetThemeAccent 参数string?null(直接返回)合法颜色十六进制 = 自定义强调色;"True" = 跟随用户强调色
FluentAvaloniaTheme.CustomAccentColorColor?null自定义强调色(色值无效时保持不变)
FluentAvaloniaTheme.PreferUserAccentColorbool—是否优先使用系统/用户强调色,与 CustomAccentColor 互斥使用
IPlatformService.IsLightOrDarkThemebool?平台相关当前系统是否浅色;null 表示平台无法探测
IPlatformService.SetLightOrDarkThemeFollowingSystem(bool)void—打开/关闭平台级"跟随系统"同步

API Reference

App.Theme(IApplication.Theme 实现)

属性 setter,处理主题状态迁移。参数:value (AppTheme) 新主题。行为:去重短路 → "跟随系统"分支或"离开跟随系统"分支 → SetThemeNotChangeValue(switch_value) → mTheme = value。副作用:写 RequestedThemeVariant、配置 LiveCharts、调用平台 SetLightOrDarkThemeFollowingSystem。

void SetThemeNotChangeValue(AppTheme value)

应用主题变体但不修改 Theme 记录值。参数:value (AppTheme) 要呈现的具体主题(不应为 FollowingSystem)。行为:HighContrast → CustomTheme.HighContrastTheme、Light → ThemeVariant.Light、其余 → ThemeVariant.Dark;随后按 Light/非 Light 配置 LiveCharts 浅色/深色主题。调用方:Theme setter、系统主题变更回调路径。

static void SetThemeAccent(string? colorHex)

静态方法,设置强调色。参数:colorHex (string?)。分支:null 或无 FluentAvaloniaTheme → 返回;合法色值 → CustomAccentColor = color, PreferUserAccentColor = false;"True" → CustomAccentColor = null, PreferUserAccentColor = true;其他非法字符串 → 无操作。

IApplication.ActualTheme(只读派生属性)

FollowingSystem → GetActualThemeByFollowingSystem()(平台返回 null 时回退 DefaultActualTheme);Light/Dark 原样返回;其余 → DefaultActualTheme。

CustomTheme 构造与成员

  • CustomTheme(Uri baseUri) / CustomTheme(IServiceProvider serviceProvider):均调用 Init() 加载 XAML。
  • string Mode { get; set; }:由 ModeProperty 支持的模式选择,取值 "Light" / "Dark" / "HighContrast"。
  • static ThemeVariant HighContrastTheme:预注册的高对比度变体。
  • bool IResourceNode.HasResources => true:参与资源查找。

Failure Modes, Edge Cases & Concurrency

  • 平台无法探测系统主题:IsLightOrDarkTheme == null 时,切入"跟随系统"分支只更新 mTheme、不刷新样式(保持当前外观),且不会调用 SetLightOrDarkThemeFollowingSystem;ActualTheme 则回退 DefaultActualTheme。契约层与实现层对 null 均有显式处理。
  • 无效主题值兜底:SetThemeNotChangeValue 的 switch 兜底为 ThemeVariant.Dark;GetThemeFromIPlatformSettings 的兜底为 ThemeVariant.Default(走系统/上级解析)。非法/新增枚举值不会抛异常,而是落到安全默认。
  • 强调色输入容错:SetThemeAccent 对 null、找不到 FluentAvaloniaTheme、非法色值字符串均静默返回;仅 "True"(不区分大小写)触发"跟随用户"重置。调用方需自行保证色值合法性,否则表现为"无效果"而非异常。
  • 重复赋值短路:Theme setter 首行 if (value == mTheme) return;,避免设置页反复绑定写入造成资源重解析风暴;"跟随系统"与固定主题间的等价切换(视觉结果不变)也通过 goto setValue 短路。
  • 线程模型:Theme/SetThemeNotChangeValue 直接操作 Application.RequestedThemeVariant 与 Styles 集合,属 UI 线程操作(Avalonia StyledElement 依赖属性要求 UI 线程访问);SetThemeAccent 为 static 且访问 App.Current?.Styles,同样应在 UI 线程调用。
  • 主题记录值与实际变体解耦:mTheme 保存用户意图(可能为 FollowingSystem),RequestedThemeVariant 保存实际呈现。任何绕过 setter 直接调用 SetThemeNotChangeValue 的路径(如系统变更回调)不会污染用户意图,这是"跟随系统"在系统切换时仍保持开启的前提。

Performance & Operational Notes

  • 资源重解析成本:RequestedThemeVariant 赋值触发 Avalonia 全局资源重新解析与控件样式重应用,是最昂贵的单步操作,因此实现中存在两处短路(值相等、等价视觉切换)以避免无谓刷新。
  • LiveCharts 全局重配置:每次变体切换都会调用一次 LiveCharts.Configure(...),用于刷新已渲染图表的默认色板;这是主题切换时图表正确配色的保障,也是切换成本的一部分。
  • 平台跟随开启是一次性调用:SetLightOrDarkThemeFollowingSystem(true/false) 只在进入/离开"跟随系统"状态时调用,不在每次变体应用时重复调用,减少对系统设置的写入。
  • 运维角度:排查"主题不生效"问题时,检查顺序建议为 Ioc.Get<IApplication>().Theme(用户意图)→ Application.RequestedThemeVariant(实际变体)→ Themes.axaml 中对应变体的资源键 → FluentAvaloniaTheme 是否存在于 App.Current.Styles(影响强调色)。

Extension Points

  • 新增主题变体:仿照 HighContrastTheme 的方式,在 CustomTheme 中注册 new ThemeVariant("<name>", <基变体>),并在 Themes.axaml 中按 ThemeDictionaries 提供对应资源,再在 SetThemeNotChangeValue 的 switch 中加入映射分支即可,无需改动契约层。
  • 扩展强调色来源:SetThemeAccent 目前接受十六进制字符串或 "True",可在其解析分支中扩展更多语义值(如 "System");底层 CustomAccentColor/PreferUserAccentColor 的组合即完整状态机。
  • 新平台桥接:实现 IPlatformService 的 IsLightOrDarkTheme 与 SetLightOrDarkThemeFollowingSystem(参考 WindowsPlatformServiceImpl.Theme.cs、LinuxPlatformServiceImpl.Theme.cs、MacCatalystPlatformServiceImpl.Theme.cs),App.Theme.cs 的控制流无需任何修改。
  • 样式资源扩展:新控件的全局样式建议继续集中在 Controls.axaml;字体、图标分别扩展 Fonts.axaml、Icons.axaml,保持单一资源入口。

Sources

(3 files)
src/BD.WTTS.Client.Avalonia/UI
src/BD.WTTS.Client.Avalonia/UI/Styling
src/BD.WTTS.Client/App