# AtomUI 主题自定义 — 原理说明 > 本文档详细阐述 AtomUI 主题自定义的内部实现原理,包括主题加载机制、局部主题作用域(`ThemeConfigProvider`)的实现,以及针对特定组件的主题覆盖和 Token 传递路径。本文是 [Overview.md](Overview.md) 和 [TokenArchitecture.md](TokenArchitecture.md) 的延伸,聚焦于"自定义"能力的底层机制。 --- ## 1. 设计背景:Ant Design 5.0 主题定制体系 Ant Design 5.0 的主题定制核心能力: 1. **全局 Token 修改** — 通过 `ConfigProvider` 的 `theme.token` 修改 Seed/Map/Alias Token,级联影响所有组件。 2. **组件级 Token 修改** — 通过 `theme.components[Component]` 修改某个组件的 Component Token 或覆盖该组件消费的 Shared Token。 3. **算法切换** — 通过 `theme.algorithm` 切换 Default/Dark/Compact 算法。 4. **局部主题(嵌套主题)** — 嵌套 `ConfigProvider` 实现页面局部的主题更换,未改变的 Token 继承父主题。 5. **组件级算法开关** — 组件 Token 配置中可选开启 `algorithm`,控制覆盖的 Shared Token 是否经过算法重新派生。 AtomUI 在 .NET / Avalonia 平台完整还原了上述所有能力。 | Ant Design (React) | AtomUI (C# / Avalonia) | |---|---| | `ConfigProvider` 的 `theme` prop | `ThemeConfigProvider` XAML 控件 | | `theme.token` | `ThemeConfigProvider.SharedTokenSetters` | | `theme.algorithm` | `ThemeConfigProvider.Algorithms` | | `theme.components[X]` | `ThemeConfigProvider.ControlTokenInfoSetters` | | 全局 `ConfigProvider` | 主题 XML 定义文件 (`DaybreakBlue.xml`) + `ThemeManager` | | 嵌套 `ConfigProvider` | 嵌套 `ThemeConfigProvider` | --- ## 2. 全局主题加载机制 ### 2.1 主题定义文件 AtomUI 的全局主题通过 XML 定义文件描述。内置主题存放在 `AtomUI.Core/Assets/Themes/` 目录下: ```xml ``` **XML 结构说明:** | 元素/属性 | 说明 | |---|---| | `` | 主题根节点,`Name` 为显示名称,`IsDefault` 标记是否为默认主题 | | `` | 算法声明(可选),逗号分隔,如 `Default,Dark` | | `` | 全局 Shared Token 覆盖区域 | | `` | 单个 Token 设置,`Name` 为 Token 属性名,`Value` 为字符串值 | | `` | 组件级 Token 覆盖区域 | | `` | 针对特定组件的 Token 配置块,`Id` 为组件 Token ID | | `` | 在 `ControlToken` 内,`IsShared="true"` 表示这是覆盖组件消费的 Shared Token | ### 2.2 主题扫描与发现 `ThemeManager.ScanThemes()` 按以下优先级顺序扫描主题定义文件: ``` 1. 用户自定义目录(CustomThemeDirs) ← 最高优先级 2. 内置主题目录(BuiltInThemeDirs) ← 应用数据目录下的 Themes/ 3. ThemeAssetPathProviders(注册的提供者) ← 控件包提供的主题路径 4. AtomUI.Core/Assets/Themes/ ← 框架内置默认主题(最低优先级) ``` 对于每个发现的主题定义文件,系统自动创建**四种算法组合**的 `Theme` 实例: ``` DaybreakBlue.xml → Theme("DaybreakBlue", {Default}) → Theme("DaybreakBlue", {Default, Dark}) → Theme("DaybreakBlue", {Default, Dark, Compact}) → Theme("DaybreakBlue", {Default, Compact}) ``` 每种组合生成唯一的 `ThemeVariant`(如 `DaybreakBlue`、`DaybreakBlue-Dark`、`DaybreakBlue-Dark-Compact`),存入 `ThemePool`。 ### 2.3 主题加载流程 当一个 `Theme` 被激活(`SetActiveTheme`)时,如果尚未加载,会触发完整的加载流程: ``` Theme.Load() │ ├── 1. NotifyLoadThemeDef() │ └── ThemeDefinitionReader.Load(ThemeDefinition) │ ├── 解析 XML → 填充 ThemeDefinition.SharedTokens │ ├── 解析 XML → 填充 ThemeDefinition.ControlTokens │ └── 解析 XML → 填充 ThemeDefinition.Algorithms │ ├── 2. BuildThemeResource(algorithms) │ ├── 2a. 创建算法链 │ │ ├── DefaultThemeVariantCalculator │ │ ├── (可选) DarkThemeVariantCalculator(base) │ │ └── (可选) CompactThemeVariantCalculator(base) │ │ │ ├── 2b. Shared Token 推导 │ │ ├── _sharedToken.LoadConfig(seedTokens) ← 加载 Seed 覆盖 │ │ ├── calculator.Calculate(_sharedToken) ← 算法推导 Map Token │ │ ├── _sharedToken.LoadConfig(mapTokens) ← 加载 Map 覆盖 │ │ ├── _sharedToken.CalculateAliasTokenValues() ← 推导 Alias Token │ │ ├── _sharedToken.LoadConfig(aliasTokens) ← 加载 Alias 覆盖 │ │ └── _sharedToken.BuildResourceDictionary() ← 写入 ResourceDictionary │ │ │ ├── 2c. Control Token 收集与计算 │ │ ├── CollectControlTokens() ← 实例化所有已注册的 ControlDesignToken │ │ ├── controlToken.AssignSharedToken() ← 注入全局 SharedToken │ │ ├── (若有覆盖) Clone SharedToken → 应用组件级覆盖 │ │ │ ├── EnableAlgorithm=true → 重新走算法推导 │ │ │ └── EnableAlgorithm=false → 直接覆盖值(不派生) │ │ ├── controlToken.CalculateTokenValues() ← 组件自身 Token 计算 │ │ ├── controlToken.LoadConfig(customTokens) ← 加载组件自身 Token 覆盖 │ │ └── controlToken.BuildResourceDictionary() ← 写入 ResourceDictionary │ │ │ └── 2d. SharedResourceDelta 构建 │ └── 对有自定义配置的 ControlToken,计算其 SharedToken 与全局 SharedToken 的差异 │ └── 3. ResourceDictionary 挂载到 ThemeManager.Resources.ThemeDictionaries ``` ### 2.4 Token 覆盖的三层优先级 在加载过程中,Token 值的确定遵循以下优先级(从高到低): ``` 用户 XML/AXAML 中显式设置的值 ↓ 覆盖 算法计算得到的派生值 ↓ 覆盖 DesignToken 类中定义的代码默认值 ``` 具体而言,对于 Shared Token: 1. `DesignToken` 类中 `InitSeedTokenValues()` 设置代码默认值。 2. `LoadConfig(seedTokens)` 加载用户配置的 Seed Token 覆盖。 3. `calculator.Calculate()` 从 Seed 推导 Map Token(覆盖代码默认的 Map 值)。 4. `LoadConfig(mapTokens)` 加载用户配置的 Map Token 覆盖(覆盖算法推导值)。 5. `CalculateAliasTokenValues()` 从 Map 推导 Alias Token。 6. `LoadConfig(aliasTokens)` 加载用户配置的 Alias Token 覆盖。 这确保了用户在任何层级的显式覆盖都能生效,同时未覆盖的 Token 仍由算法自动派生。 --- ## 3. 局部主题作用域:ThemeConfigProvider ### 3.1 核心设计 `ThemeConfigProvider` 是 AtomUI 对 Ant Design `ConfigProvider` 嵌套主题能力的实现。它是一个 Avalonia `Control`,可以在 AXAML 中作为容器使用,为其子控件树建立**独立的 Token 计算作用域**。 ```csharp public class ThemeConfigProvider : Control, IThemeConfigProvider { // 子内容 public Control? Content { get; set; } // 算法列表(如 "Default", "Dark") public List Algorithms { get; set; } // 全局 Shared Token 覆盖 public List SharedTokenSetters { get; set; } // 组件级 Token 覆盖 public List ControlTokenInfoSetters { get; set; } // 计算后的 Token 实例 public DesignToken SharedToken { get; } public Dictionary ControlTokens { get; } } ``` ### 3.2 Token 计算流程 当 `ThemeConfigProvider` 首次挂载到可视树(`OnAttachedToVisualTree`)时,触发 `CalculateTokenResources()`: ``` ThemeConfigProvider.CalculateTokenResources() │ ├── 1. 解析 Algorithms → 创建算法链 │ ├── 2. 分类 SharedTokenSetters │ ├── seedTokenKeys → Seed 覆盖 │ ├── mapTokenKeys → Map 覆盖 │ └── aliasTokenKeys → Alias 覆盖 │ ├── 3. Shared Token 推导(与全局主题完全相同的流程) │ ├── _sharedToken.LoadConfig(seed) │ ├── calculator.Calculate(_sharedToken) │ ├── _sharedToken.LoadConfig(map) │ ├── _sharedToken.CalculateAliasTokenValues() │ ├── _sharedToken.LoadConfig(alias) │ └── _sharedToken.BuildResourceDictionary(localDict) │ ├── 4. Control Token 计算 │ ├── CollectControlTokens() → 实例化所有 ControlDesignToken │ ├── 对每个 ControlTokenInfoSetter: │ │ ├── Clone SharedToken │ │ ├── EnableAlgorithm=true → 重新走算法推导 │ │ ├── EnableAlgorithm=false → 直接覆盖 │ │ └── AssignSharedToken(copiedSharedToken) │ ├── controlToken.CalculateTokenValues() │ ├── controlToken.LoadConfig(customTokens) │ └── controlToken.BuildResourceDictionary(localDict) │ └── 5. Resources.MergedDictionaries.Add(localDict) ← 挂载到自身的资源字典 ``` ### 3.3 资源解析机制 `ThemeConfigProvider` 将计算好的 `ResourceDictionary` 添加到自身的 `Resources.MergedDictionaries` 中。由于 Avalonia 的**资源就近查找**机制,子控件在解析 `{DynamicResource}` 时会**先查找最近的祖先资源字典**,再向上查找直到 `Application` 级别。 ``` Window (全局主题 ResourceDictionary) │ ├── StackPanel │ └── Button → 使用全局 ColorPrimary = #1677ff │ ├── ThemeConfigProvider (局部 ResourceDictionary: ColorPrimary = #00b96b) │ └── Button → 使用局部 ColorPrimary = #00b96b ← 就近查找 │ └── ThemeConfigProvider (局部 ResourceDictionary: ColorPrimary = red) ├── Button → 使用局部 ColorPrimary = red └── ThemeConfigProvider (嵌套, ColorPrimary = #7D3C98) └── Button → 使用嵌套 ColorPrimary = #7D3C98 ``` **关键点**:`ThemeConfigProvider` 的 Token 计算是**完全独立**的 — 它从零创建 `DesignToken` 实例,走完整的算法推导流程,不依赖父级 `ThemeConfigProvider` 或全局主题的计算结果。这确保了每个作用域的 Token 值都是自洽的。 ### 3.4 嵌套主题 `ThemeConfigProvider` 支持任意层级的嵌套: ```xml Theme 1 Theme 2 ``` 在上例中,"Theme 1" 按钮使用 `#1677ff` 主色,"Theme 2" 按钮使用 `#00b96b` 主色。 > **与 Ant Design 的区别**:Ant Design 的嵌套 `ConfigProvider` 默认继承父级未修改的 Token。AtomUI 的 `ThemeConfigProvider` 则是独立计算的 — 未设置的 Token 使用 `DesignToken` 的代码默认值而非父级值。这在大多数场景下效果相同,因为默认值即是 Ant Design 的标准设计值。 --- ## 4. 针对特定组件的主题覆盖 ### 4.1 组件级 Token 覆盖的两种路径 AtomUI 支持两种覆盖特定组件主题的路径: **路径 A:覆盖组件自身 Token(Component Token)** 修改组件 `AbstractControlDesignToken` 子类中定义的专属属性,如 Button 的 `PaddingInline`、`FontWeight` 等。 **路径 B:覆盖组件消费的 Shared Token** 为某个组件建立一份**独立的 SharedToken 副本**,修改其中的值(如 `ColorPrimary`),使该组件的所有派生值都基于修改后的 SharedToken 计算。 ### 4.2 ControlTokenInfoSetter 机制 在 AXAML 中,通过 `ControlTokenInfoSetters` 配置组件级覆盖: ```xml Custom Button ``` **`TokenSetter` vs `ControlTokenSetter` 的区别:** | 类型 | 目标 | 说明 | |------|------|------| | `TokenSetter` | Shared Token (Seed/Map/Alias) | 覆盖组件消费的全局 Token,放入 `ControlTokenConfigInfo.SharedTokens` | | `ControlTokenSetter` | Component Token | 覆盖组件自身专属 Token,放入 `ControlTokenConfigInfo.Tokens` | ### 4.3 EnableAlgorithm 开关 `ControlTokenInfoSetter` 的 `EnableAlgorithm` 属性控制 Shared Token 覆盖是否经过算法重新派生: **`EnableAlgorithm = true`(启用算法):** ``` 1. Clone 全局 SharedToken → copiedSharedToken 2. copiedSharedToken.LoadConfig(seed覆盖) ← 如 ColorPrimary = #00b96b 3. calculator.Calculate(copiedSharedToken) ← 从修改后的 Seed 重新推导 Map 4. copiedSharedToken.LoadConfig(map覆盖) ← 加载 Map 覆盖 5. copiedSharedToken.CalculateAliasTokenValues() ← 重新推导 Alias 6. copiedSharedToken.LoadConfig(alias覆盖) ← 加载 Alias 覆盖 7. controlToken.AssignSharedToken(copiedSharedToken) ``` 这意味着修改 `ColorPrimary` 为 `#00b96b` 后,该组件的 `ColorPrimaryHover`、`ColorPrimaryBg` 等梯度色都会自动重新计算。 **`EnableAlgorithm = false`(禁用算法,默认值):** ``` 1. Clone 全局 SharedToken → copiedSharedToken 2. copiedSharedToken.LoadConfig(seed覆盖) ← 直接覆盖值 3. copiedSharedToken.LoadConfig(map覆盖) ← 直接覆盖值 4. copiedSharedToken.LoadConfig(alias覆盖) ← 直接覆盖值 5. controlToken.AssignSharedToken(copiedSharedToken) ``` 此模式下,只有被显式覆盖的 Token 会变化,其他派生 Token 保持全局值不变。这对应 Ant Design 中组件级 `algorithm` 为 `false` 时的行为。 ### 4.4 SharedResourceDelta 与控件级资源注入 当组件的 SharedToken 被自定义覆盖后,需要一种机制让**该组件实例**能看到修改后的 Shared Token 值。AtomUI 通过 `SharedResourceDelta`(差异资源字典)实现: ``` AbstractControlDesignToken.BuildSharedResourceDeltaDictionary(globalSharedToken) │ ├── 比较 controlToken.SharedToken vs globalSharedToken ├── 找出所有不同的属性 └── 将差异写入 _sharedResourceDeltaDictionary 例:SharedTokenKind.ColorPrimary → #00b96b(局部值,全局为 #1677ff) ``` 当控件实例挂载到逻辑树时(通过 `ControlTokenResourceScopeHost` 机制): ``` 控件.AttachedToLogicalTree │ ├── 获取 TokenResourceScopeProvider(控件注册时设置) ├── TokenFinderUtils.FindControlToken() → 沿逻辑树向上查找 │ ├── 遇到 ThemeConfigProvider?→ 返回其 ControlToken │ └── 到达根节点?→ 返回全局主题的 ControlToken ├── 获取 ControlToken.GetSharedResourceDeltaDictionary() └── 如果有差异 → 创建 ResourceDictionary 注入到控件的 Resources.MergedDictionaries ``` 这使得组件级的 Shared Token 覆盖只影响目标组件实例,不影响其他组件。 ### 4.5 Token 查找路径(TokenFinderUtils) `TokenFinderUtils` 提供了沿控件树向上查找 Token 的工具方法: ```csharp internal static class TokenFinderUtils { // 查找 SharedToken:沿 StylingParent 链向上找 IThemeConfigProvider public static DesignToken FindSharedToken(Control control); // 查找 ControlToken:沿 StylingParent 链向上找 IThemeConfigProvider public static IControlDesignToken? FindControlToken(Control control, string tokenId, string? catalog); // 查找 ThemeVariant public static ThemeVariant? FindThemeVariant(Control control); } ``` 查找逻辑: ``` 控件实例 │ ├── 检查父级是否是 IThemeConfigProvider? │ ├── 是 → 返回其 Token │ └── 否 → 继续向上 │ ├── 检查祖父级是否是 IThemeConfigProvider? │ ├── 是 → 返回其 Token │ └── 否 → 继续向上 │ └── ... 直到根节点 └── 从 ThemeManager.ActivatedTheme 获取全局 Token ``` --- ## 5. 动态响应机制 ### 5.1 属性变更触发重新计算 `ThemeConfigProvider` 监听其 `SharedTokenSetters`、`ControlTokenInfoSetters`、`Algorithms` 属性的变化: ```csharp protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { if (VisualRoot != null) { if (change.Property == ControlTokenInfoSettersProperty || change.Property == SharedTokenSettersProperty || change.Property == AlgorithmsProperty) { CalculateTokenResources(); } } } ``` 当这些属性在运行时变化时,会重新执行完整的 Token 计算流程并更新 `ResourceDictionary`。由于控件主题通过 `DynamicResource` 绑定 Token 值,UI 会自动刷新。 ### 5.2 全局主题切换 全局主题切换通过 `ThemeManager` 的 `ThemeVariant` 属性驱动,利用 Avalonia 的 `ThemeDictionaries` 机制实现无缝切换(详见 [ThemeManagement.md](ThemeManagement.md))。 --- ## 6. 架构总图 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 全局主题层 │ │ │ │ XML 定义文件 (DaybreakBlue.xml) │ │ │ │ │ ▼ │ │ ThemeDefinitionReader → ThemeDefinition │ │ │ │ │ ▼ │ │ Theme.BuildThemeResource() │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ 1. DesignToken (Seed → Algorithm → Map → Alias) │ │ │ │ 2. ControlDesignToken × N (AssignSharedToken + Calculate) │ │ │ │ 3. ResourceDictionary (SharedTokenKind + ControlTokenKind) │ │ │ └───────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ThemeManager.Resources.ThemeDictionaries[ThemeVariant] = ResourceDict │ └───────────────────────────────┼─────────────────────────────────────────┘ │ │ Avalonia 资源解析链 ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ 局部主题层 (可选, 可嵌套) │ │ │ │ ThemeConfigProvider │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ Algorithms: ["Dark"] │ │ │ │ SharedTokenSetters: [ColorPrimary=#00b96b, ...] │ │ │ │ ControlTokenInfoSetters: [Button{ColorPrimary=#eb2f96}, ...] │ │ │ │ │ │ │ │ → 独立计算 DesignToken + ControlDesignToken │ │ │ │ → 生成局部 ResourceDictionary │ │ │ │ → 挂载到 self.Resources.MergedDictionaries │ │ │ └───────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ 子控件通过资源就近查找消费局部 Token │ └───────────────────────────────┼─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ 控件级作用域 (按需) │ │ │ │ ControlTokenResourceScopeHost │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ 控件挂载时: │ │ │ │ 1. TokenFinderUtils.FindControlToken() 向上查找最近的 Provider │ │ │ │ 2. 获取 SharedResourceDelta(覆盖 vs 全局的差异) │ │ │ │ 3. 将差异注入到控件自身的 Resources.MergedDictionaries │ │ │ │ │ │ │ │ 效果:同一 ThemeConfigProvider 下, │ │ │ │ Button 看到 ColorPrimary=#eb2f96 │ │ │ │ LineEdit 看到 ColorPrimary=#00b96b │ │ │ └────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## 7. 源文件索引 | 文件 | 职责 | |------|------| | `Theme.cs` | 全局主题实现:加载定义文件、构建资源、Token 推导 | | `ThemeDefinition.cs` | 主题定义数据模型 | | `ThemeDefinitionReader.cs` | XML 主题定义文件解析器 | | `ThemeConfigProvider.cs` | 局部主题配置控件(对应 Ant Design ConfigProvider) | | `IThemeConfigProvider.cs` | 局部主题配置接口 | | `TokenSetter.cs` | Token 设置器(AXAML 中的 ``) | | `ControlTokenInfoSetter.cs` | 组件级 Token 配置设置器 | | `ControlTokenResourcesHost.cs` | 控件级 Token 资源作用域注入机制 | | `IControlTokenResourceScopeProvider.cs` | 控件级 Token 作用域提供者接口 | | `TokenFinderUtils.cs` | Token 查找工具(沿控件树向上查找) | | `AbstractControlDesignToken.cs` | 组件 Token 抽象基类(含 SharedResourceDelta 构建) | | `AbstractDesignToken.cs` | Design Token 抽象基类(LoadConfig、BuildResourceDictionary) | | `ControlTokenConfigInfo.cs` | 组件 Token 配置信息数据模型 | | `CalculatorUtils.cs` | Token 推导算法工具类 |