28 KiB
AtomUI 主题定制指南
AtomUI 的主题系统不是一组可以随意覆盖的 Brush,也不是在每个 ControlTheme 外面再包一层 ResourceDictionary。 它解决的是一个更完整的问题:如何用稳定的设计语言描述全局视觉,允许单个 Control 精确偏离全局规则,又能在 运行时安全切换主题、建立局部主题、支持第三方 Control,并保持 NativeAOT、性能和资源生命周期可控。 本文说明这些能力在 Avalonia、C# 和 AtomUI ControlTheme 中的使用方式与定制边界。
本文面向两类读者:
- 希望为应用定制颜色、尺寸、暗色、紧凑模式和单个 Control 外观的主题作者。
- 希望开发 AtomUI Control、主题包或维护主题运行时的工程师。
本文先建立容易理解的心智模型,再逐步进入 Token 计算、Control identity、主题资产、作用域、事务和 AOT 等 使用边界。稳定运行时契约与验收条件以 AtomUI 主题系统架构 为准。
1. 先把主题理解成一次编译
最容易误解主题系统的方式,是把它想成一个不断变化的资源字典。这样会自然地产生几个问题:
- 一个颜色变化后,哪些派生颜色应该重新计算?
- Button 可以覆盖
ColorPrimary,但为什么不能影响同一棵树里的 DatePicker? - 一个主题切换过程中,控件会不会短暂读到一半新值、一半旧值?
- 局部暗色主题里的 Popup 和独立 Window 应该从哪里获得资源?
- 第三方 Control 如何注册 Token,而不依赖运行时反射?
AtomUI 采用的心智模型是:
主题配置是源代码,ThemeCompiler 是编译器,ThemeSnapshot 是不可变编译产物,ResourceProvider 只是产物的 Avalonia 投影。
flowchart LR
Definition["ThemeDefinition"] --> Normalize["Bind / Normalize"]
Config["ThemeConfig"] --> Normalize
Schema["ThemeSchemaRegistry"] --> Normalize
Normalize --> Compile["ThemeCompiler"]
Compile --> Snapshot["Immutable ThemeSnapshot"]
Snapshot --> Provider["ThemeTokenResourceProvider"]
Provider --> Axaml["ControlTheme / Template"]
Snapshot --> Resolver["Imperative Token Resolver"]
这个模型带来四个直接结论:
- Token 计算只有一条标准路径,不能在 ControlTheme、Control 实例和服务层各维护一份状态。
- 主题切换先完整生成新 snapshot,再一次性交换引用,不把中间状态暴露给 UI。
- AXAML 只按稳定资源 key 读取结果,不在查询时重新计算 Token。
- schema、identity、Token key 和主题资产都应在构建期确定,运行时不扫描程序集或 AXAML。
2. 从 Global Token 到最终视觉
AtomUI 把 Token 分成 Global Token、Effective Global Token、Control Own Token 和 Effective Control Token。
Global Token
-> Control-specific Global Override
Effective Global Token
-> calculate Control Own Token defaults
-> Control Own Token Override
Effective Control Token
-> ControlTheme
-> Semantic Part Selector
-> optional Semantic Part Theme
-> Template / Runtime visual state
2.1 Global Token
Global Token 描述整个设计语言,例如主色、通用文字颜色、Control 高度、圆角、排版、间距和动效时长。修改全局
ColorPrimary 的含义是改变整个应用的主色体系,而不是只改变某一个 Button。
Global Token 还可以经过 Default、Dark、Compact 等算法派生 Map Token 和 Alias Token。主题作者通常修改少量 Seed Token,再由算法计算完整结果,而不是手工维护每一种状态颜色。
2.2 Effective Global Token
有些设计需要“只让 Button 的主色变成绿色,其他 Control 仍使用蓝色”。这不是一个新的 Button Own Token,而是
Button identity 下的 ColorPrimary 有效值:
Global ColorPrimary = Blue
Button Effective ColorPrimary = Green
DatePicker Effective ColorPrimary = Blue
Control 级 Global Token 覆盖不会修改全局 snapshot,也不会形成一棵新的资源子树。它只是当前 ThemeContext 的 snapshot 中,属于特定 Control identity 的稀疏差量。
2.3 Control Own Token
Own Token 只表达某个 Control 独有的稳定设计语义,例如:
Button.ContentPaddingInline。DatePicker.CellHeight。Rating.StarGap。
Own Token 不是 Global Token 的副本。如果 ControlHeight 已经存在于 Global Token,Rating 不应再次定义一个同名
Own Token。它直接从 Rating 的 Effective Global Token 读取 ControlHeight。
Own Token 类型使用无参数 [ControlDesignToken] 标记,使生成器可以直接发现 Token 定义。该标记不接收 Control
类型、identity、ID 或主题路径;[ControlDesignToken<Rating>] 这类泛型关联不属于设计。
2.4 Effective Control Token
ControlTheme 最终消费的是:
Effective Control Token
= Effective Global Token for this Control
+ Control Own Token
Global Token 与 Own Token 在定义和配置层保持分离,但在 AXAML 消费层使用同一个简洁入口。主题作者不需要在每个 Setter 中重复声明它来自 Global 还是 Own。
3. Control identity 为什么不能由 TargetType 推断
ControlTheme.TargetType 回答的是“这份样式应用到什么类型”,ControlTokenIdentity 回答的是“这次 Token 查询
属于哪个公开 Control 的设计契约”。两者经常相同,但不能把它们设计成同一件事。
一个 SearchEdit 可以在模板里使用 Button。这个内部 Button 的 TargetType 是 Button,但它同时处于 SearchEdit 的组合语义中:
- Button 的基础字体、尺寸、背景和交互视觉属于 Button。
- 搜索按钮与输入框的拼接边框、圆角、状态投射和搜索行为属于 SearchEdit。
如果根据 TargetType 自动选择 Token,SearchEdit 无法表达自己的组合语义;如果让内部 Button 借用 LineEdit identity,又会导致用户不知道应该在 Button、LineEdit 还是 SearchEdit 下配置 Token。
因此 AtomUI 坚持以下边界:
- 每个对外可主题化的 Control 拥有独立
ControlTokenIdentity。 - TargetType、Control 继承和 ControlTheme
BasedOn都不能推断 Token identity。 - internal Part、Presenter、Decorator 和 Host 不拥有独立 identity。
- Token identity 由显式的
XxxTokenResource表达,不依赖 ambient scope。
这使 ControlTheme 可以自由组合,而 Token 的归属仍然清晰。
4. 两种 TokenResource,各自只做一件事
4.1 SharedTokenResource
<Setter Property="FontFamily"
Value="{atom:SharedTokenResource FontFamily}" />
SharedTokenResource 永远读取当前 ThemeContext 的全局 Token。它不寻找 Control identity,也不响应 Control 级
覆盖。适合真正要求所有 Control 始终共享的值。
4.2 XxxTokenResource
<Setter Property="MinHeight"
Value="{atom:RatingTokenResource ControlHeight}" />
<Setter Property="Margin"
Value="{atom:RatingTokenResource StarGap}" />
RatingTokenResource 同时读取 Rating 的 Effective Global Token 和 Rating Own Token。ControlHeight 来自
Global Token,StarGap 来自 Own Token,但语法完全一致。
生成器为它产生强类型 RatingTokenKey 和 MarkupExtension 构造参数,因此:
- 输入
{atom:RatingTokenResource后可以获得 Token 提示。 - 可以选择 Rating Own Token 和当前 registry 中的全部 Global Token。
- Token 写错时在 AXAML 编译期失败。
- 运行时不解析字符串、不反射属性、不推断 TargetType。
这里故意没有 Global=、Own= 或 ControlTheme scope 配置。定义层负责分类,消费层只表达“读取 Rating 的这个
Token”。
5. 每个 Control 都拥有完整 Effective Global Token
Control 配置允许使用的 Token 固定为:
Configurable Control Tokens
= All Global Tokens
+ Current Control Own Tokens
AtomUI 不维护“这个 Control 消费了哪些 Global Token”的白名单。Own Token 计算、AXAML 和 C# Binding 都可以直接 使用任意已注册 Global Token,不需要生成依赖声明。配置绑定时先匹配当前 Control Own Token,未命中时匹配完整 Global Token schema;两处都不存在才报错。Own Token 与任意 Global Token 禁止同名。
一个合法 Global Token 如果没有被当前 Control 直接或间接使用,覆盖后可以没有视觉效果。这是允许的确定行为,
不能因此影响其他 Control identity 或真正的 Global Token snapshot。启用 Global 或 Custom Control 算法时,
Seed/Map 覆盖可能重新派生其他 Token,因此一个看似未直接使用的 Token 仍可能产生局部间接效果。
5.1 AXAML 和 C# 使用同一语义
<Setter Property="MotionDuration"
Value="{atom:RatingTokenResource MotionDurationSlow}" />
只要 MotionDurationSlow 存在于 Global Token schema,这个引用和对应 Rating 配置就始终合法,不扩展 schema。
C# Binding 显式区分普通 Global 与当前 Control Effective Global:
TokenResourceBinder.CreateGlobalTokenBinding(
this, MotionDurationProperty, SharedTokenKind.MotionDurationSlow);
TokenResourceBinder.CreateControlTokenBinding(
this, MotionDurationProperty, SharedTokenKind.MotionDurationSlow);
第二种 API 使用实例 exact CLR type 对应的生成式 identity;基类代码在注册的派生 Control 实例上读取派生
identity。Own Token 不随 CLR 继承替换。内部 part 由公开 owner 使用带 owner 的重载绑定,不创建公开 identity。
命令式读取使用 ControlTokenAccessor.Capture(owner),不通过隐藏 StyledProperty 转运 Token。
5.2 为什么不保留“仅用于提示”的消费清单
消费清单无法完整证明间接计算、继承代码、C# Binding 和第三方主题行为。一旦它不再参与正确性,却继续出现在 Gallery 或文档中,就容易再次被误认为支持边界。AtomUI 因此彻底删除该清单;Gallery 单独展示 Control Own Token,并提供统一、可搜索的 Global Token 目录。
6. 主题系统的五个运行时边界
理解内部边界有助于判断一项定制应该放在哪里。
| 边界 | 负责什么 | 不负责什么 |
|---|---|---|
| Resolver / Definition | 发现和读取主题来源 | 不计算 Token,不加载 Control 实例 |
| Schema | 描述 Token、Control、算法和主题资产 | 不保存当前主题值 |
| Configuration | 绑定、验证和合并用户输入 | 不访问 Visual Tree |
| Compiler / Snapshot | 计算并冻结完整主题结果 | 不发布 Avalonia 通知 |
| ThemeContext / Resources | 发布 snapshot 并投影为资源 | 不维护第二份 Token 状态 |
6.1 ThemeSchemaRegistry
Registry 在应用初始化时由生成 descriptor 构建,包含 Global Token schema、带 exact CLR type 的 Control descriptor、算法 descriptor 和 ControlTheme asset manifest。首个 snapshot 编译前冻结,之后不能动态追加类型。
6.2 ThemeConfig 与 ThemeDefinition
ThemeDefinition 是可命名、可发现、可持久化的完整主题来源。ThemeConfig 是应用启动、运行时请求或局部作用域
提供的不可变覆盖。两者最终进入同一个规范化和合并管线。
6.3 ThemeSnapshot
Snapshot 保存已经计算完成的全局 Token、Control Effective Global Token 差量、Own Token、资源投影、palette 和 appearance。它不保存 Control、Visual、Provider、订阅或可变 Token builder。
6.4 ThemeContext
ThemeContext 是稳定的运行时作用域身份,只持有当前 snapshot 的原子引用和唯一 ResourceProvider。主题变化时 替换 snapshot,不替换 Context 或 Provider。
这与 ControlTokenIdentity 完全不同:
ThemeContext = UI 子树当前使用哪个 ThemeSnapshot
ControlTokenIdentity = Snapshot 内查询哪个 Control 的 Effective Token
7. 选择正确的定制层级
| 需求 | 应使用的入口 |
|---|---|
| 修改整个应用的主色、圆角或字体 | Global Token |
| 只修改所有 Button 的主色或高度 | Button 下的 Global Token 覆盖 |
| 修改 Button 独有的内容内边距 | Button Own Token |
| 为页面建立局部暗色或紧凑主题 | ThemeConfigProvider |
| 修改 Control 的模板结构或状态映射 | 自定义 ControlTheme |
| 修改稳定内部区域的局部视觉 | Semantic Part Selector |
| 完整替换 SearchEdit 的搜索按钮 ControlTheme | SearchButtonTheme Semantic Part Theme |
| 开发一个全新的 Rating Control | 独立 Control identity、可选 Own Token、主题资产和包级注册 |
一个简单判断原则是:
flowchart TD
Start["希望改变什么?"] --> Global{"影响全部 Control?"}
Global -->|是| GlobalToken["Global Token"]
Global -->|否| SameControl{"是某个 Control 的通用设计值?"}
SameControl -->|是| Existing{"Global Token 已表达该语义?"}
Existing -->|是| ScopedGlobal["Control-specific Global Override"]
Existing -->|否| Own["Control Own Token"]
SameControl -->|否| Structure{"涉及模板结构或稳定内部区域?"}
Structure -->|模板结构| Theme["ControlTheme"]
Structure -->|局部视觉| Part["Semantic Part Selector"]
Structure -->|完整替换 public 子 Control| PartTheme["Semantic Part Theme"]
Structure -->|运行时状态| Variable["Theme Variable / Control state"]
8. 定制一套完整主题
Theme Definition XML 适合声明可命名、可切换、可放入应用资源或用户主题目录的完整主题。下面是一份精简示例:
<?xml version="1.0" encoding="utf-8"?>
<Theme xmlns="https://atomui.net/schemas/theme/v1"
Id="AcmeLight"
Name="Acme Light"
Appearance="Light"
IsDefault="false">
<Algorithms>
<Algorithm Id="Default" />
</Algorithms>
<Tokens>
<Token Name="ColorPrimary" Value="#0F6CBD" />
<Token Name="BorderRadius" Value="4" />
<Token Name="FontSize" Value="14" />
</Tokens>
<Controls>
<Control Catalog="AtomUI" Id="Button" Algorithm="Disabled">
<Tokens>
<Token Name="ColorPrimary" Value="#115EA3" />
<Token Name="ContentFontSize" Value="14" />
</Tokens>
</Control>
</Controls>
</Theme>
这份主题表达了三层意图:
ColorPrimary、BorderRadius和FontSize改变全局设计语言。- Button 在自己的 Effective Global Token 中进一步覆盖
ColorPrimary。 ContentFontSize覆盖 Button Own Token。
Binder 会根据当前 registry revision 验证 Button identity、Token 名和值类型。Button Own Token 与任意已注册 Global Token 都可以配置;只有名称在这两个 schema 中都不存在时才失败。
完整 XML 协议、算法形态和安全限制见 主题定义 XML v1 规范。
8.1 让主题进入 ThemeCatalog
主题定义只有经过 Resolver、Reader 和 Binder 后才会进入 ThemeCatalog。来源分为三类:
- AtomUI 内置主题,由内置 Resolver 显式注册。
- 应用或 Control 包中的
avares://主题资源,由包级注册入口显式提供 URI。 - 用户主题目录中的
*.theme.xml,只在应用主动启用后读取。
应用启用用户主题目录时必须提供稳定 Application Id:
this.UseAtomUI(builder =>
{
builder.WithApplicationId("Acme.Editor");
builder.UseUserThemeDirectory();
builder.UseDesktopControls();
});
默认目录位于 Environment.SpecialFolder.ApplicationData/{ApplicationId}/Themes。主题系统不使用
FileSystemWatcher 自动监听;用户保存主题文件后,由应用调用 IThemeManager.ReloadThemesAsync() 手动刷新。
刷新会整批读取用户目录并原子提交 Catalog。任一用户主题无效时,不提交一半新、一半旧的结果;当前 Catalog、 根 snapshot 和局部 snapshot 全部保持原值。
9. 暗色、紧凑和 Control 算法
Global Algorithm 是有序派生链。例如暗色主题可以使用:
<Algorithms>
<Algorithm Id="Default" />
<Algorithm Id="Dark" />
</Algorithms>
Compact 算法可以继续追加,并声明为保持当前 appearance。算法不是简单的布尔开关;顺序属于主题语义。
ThemeAlgorithm 是算法身份的唯一类型,当前封闭成员为 Default、Dark 和 Compact。C# 配置、运行时状态、
descriptor 和缓存都使用枚举;XML 的 Id 只是在 Reader 边界使用的严格序列化文本。
Control 级算法有四种状态:
| 模式 | 含义 |
|---|---|
Unspecified |
继承父 ThemeConfig 中同一 Control 的策略 |
Disabled |
应用 Control 级覆盖,但不重新运行派生链 |
Global |
使用当前全局算法链重新派生该 Control 的有效 Token |
Custom |
使用该 Control 配置声明的算法链 |
大多数只调整少量颜色或尺寸的主题应使用 Disabled。只有当 Control 级 Seed Token 需要重新生成完整 Map/Alias
关系时,才使用 Global 或 Custom。
10. 在运行时切换主题
应用启动时应在 UseAtomUI Builder 阶段配置初始主题,使首个可见帧已经拥有正确 snapshot:
this.UseAtomUI(builder =>
{
builder.WithInitialTheme(
"AcmeLight",
new ThemeConfigBuilder()
.WithAlgorithms(ThemeAlgorithm.Default)
.Build());
builder.UseDesktopControls();
});
启动后的切换使用 IThemeManager.ApplyThemeAsync(ThemeRequest)。ThemeConfig 是深度不可变值;更新主题意味着创建
并提交新的 Config,不能修改已经进入 Manager 的集合或 Token entry。
Control 级配置使用生成的强类型 identity 和 Token key。目标 API 形态为:
theme.ControlTokens
.For(ButtonTokens.Identity)
.Set(GlobalTokens.ColorPrimary, Colors.SeaGreen)
.Set(ButtonTokens.ContentFontSize, 15);
配置入口与 Theme Definition XML 使用同一个 schema,所以两者对 identity、Own/Global Token 分类、值类型和 算法的判断完全一致。
11. 为页面建立局部主题
ThemeConfigProvider 用于让某个 UI 子树拥有独立 ThemeContext。例如编辑器页面可以使用暗色,应用其余区域仍
使用亮色。Provider 当前 ThemeConfig.Inherit 决定是否从父主题的有效配置继续合并。
Root ThemeContext
+-- ordinary content: inherit root snapshot
+-- ThemeConfigProvider(Editor Dark)
+-- editor content: local snapshot
+-- Popup with logical owner: inherit editor context
需要注意:
- Provider 每次更新仍使用同一个 ThemeContext 和 ResourceProvider。
Inherit=true在父有效配置上叠加本地配置。Inherit=false从 AtomUI library baseline 重新开始,不复制父主题。- 普通 Popup/Flyout 通过逻辑树继承。
- 独立 Window、Dialog、Notification TopLevel 必须从显式 owner 获得 ThemeContextLease。
- 无 owner 的静态 API 只能使用根主题,不能根据焦点或调用栈猜测局部主题。
局部主题是 UI 子树作用域;Control 级 Token 覆盖是 snapshot 内的 Control 域。两者可以组合,但不能混为一谈。
12. 自定义 ControlTheme
Token 负责设计值,ControlTheme 负责把设计值映射到 Avalonia 属性、selector 和模板结构。一个标准 Rating 主题 可以这样消费 Token:
<ControlTheme x:Key="{x:Type local:Rating}"
TargetType="local:Rating">
<Setter Property="MinHeight"
Value="{local:RatingTokenResource ControlHeight}" />
<Setter Property="Foreground"
Value="{local:RatingTokenResource ColorText}" />
<Setter Property="ItemSpacing"
Value="{local:RatingTokenResource StarGap}" />
<!-- Template 和 selector 按 Rating 的公开视觉契约定义。 -->
</ControlTheme>
这里的关键不是 AXAML 语法,而是职责分配:
ControlHeight和ColorText是这个默认 RatingTheme 当前消费的 Global Token;Rating 仍可覆盖其他任意 Global Token。StarGap是 Rating Own Token。- pressed、hover、disabled 等实例状态由 selector 和 Theme Variables 映射。
- 模板节点名称、临时布局值和实例状态不进入 Own Token。
ControlTheme 不声明 Token scope。RatingTokenResource 已经明确了 identity。
13. 定制 Semantic Part
Control 通过稳定 .semantic-* Selector 开放少量公共视觉区域。用户可以在 Application、局部 StyleHost 或单个
Control 的 Styles 中定制这些区域,而不依赖 PART_*、Name 或 internal 类型。完整契约见
Semantic Part 系统设计。
当 Part 是真实 public 子 Control,并且需要允许完整替换其 ControlTheme 时,owner 可以额外使用强类型
ControlTheme? 属性开放 Semantic Part Theme。
SearchEdit 的设计是:
SearchEdit
+-- SearchEditTokenResource: 输入和组合语义
+-- real public Button: Button 基础视觉
\-- SearchButtonTheme: 稳定定制入口
用户可以定义一个 TargetType 为 Button 的 ControlTheme,并把它赋给所有 SearchEdit 或单个 SearchEdit 的
SearchButtonTheme。该 Part Theme 可以显式读取:
{atom:ButtonTokenResource ...}
{atom:SearchEditTokenResource ...}
前者负责 Button 基础视觉,后者负责 SearchEdit 集成语义。无需创建 SearchButtonToken,也不能让 SearchButton 借用 LineEdit identity。
Semantic Part Theme 是 Selector 的可选扩展,不是 Popup、Overlay 或普通模板节点的默认入口。临时模板节点不进入 公开 descriptor;普通局部视觉差异优先使用 Semantic Selector。
14. 开发第三方 AtomUI Control
第三方 Control 使用与 AtomUI 内置 Control 相同的 Control、Own Token 和 Theme 约定。AOT 接入的默认心智模型只有 Package,不要求普通作者理解 Registration Unit:
Acme.Controls Package
+-- Control、可选 Own Token 和 Themes/
+-- AcmeControlThemesProvider
\-- [ControlPackageRegistrationEntry] UseAcmeControls()
包项目声明稳定 identity:
<PropertyGroup>
<AtomUIRegistrationPackageId>Acme.Controls</AtomUIRegistrationPackageId>
</PropertyGroup>
应用在 ThemeManager 构建前显式启用:
this.UseAtomUI(builder =>
{
builder.UseDesktopControls();
builder.UseAcmeControls();
});
默认整个 Acme.Controls 是一个安全 Unit。包内任一公开 Control 被使用时,Generator 会一起保留内部 View/Presenter、
descriptor、Own Token 和主题资源。普通作者不写 AtomUIRegistrationUnit、Unit dependency、AXAML ownership metadata、
linker XML 或手工 manifest。
只有包含大量独立控件族、并且有真实体积基线和 NativeAOT 测试的大型包,才显式设置
AtomUIRegistrationGranularity=Directory。该模式是高级裁剪优化,不是普通 AOT 接入步骤。
完整项目文件、Provider、入口代码和发布检查见 第三方 AtomUI Control Package 指南。详细注册顺序见 启动与注册链路。
15. 一次主题切换为什么不会暴露半成品
AtomUI 把一次主题更新分成五个阶段:
flowchart LR
Capture["Capture\nimmutable input + graph stamp"] --> Prepare["Prepare\nmerge + compile"]
Prepare --> Commit["CommitCore\nsilent reference swap"]
Commit --> Publish["Publish\nvariant + resources + events"]
Publish --> Complete["Complete\nresult"]
- Capture 在 UI 线程冻结配置、作用域拓扑和受影响集合。
- Prepare 执行纯合并、缓存查询和完整 snapshot 编译。
- CommitCore 一次性交换全部受影响 Context 的 snapshot 引用,不调用外部代码。
- Publish 更新 Avalonia variant、资源通知和事件。
- Complete 返回
Committed、NoOp、Superseded或Failed。
Publish 开始前所有相关 Context 已指向完整新 snapshot。即使 Avalonia 在资源通知中同步回调,也不会看到新旧 Token 混合状态。无效配置或编译失败发生在 Commit 前,旧主题保持完整可用。
16. 性能与 NativeAOT 不是附加要求
主题资源会被大量 ControlTheme 和模板反复查询,因此热路径必须足够简单:
strongly typed resource key
-> registry control slot
-> token slot
-> immutable table lookup
-> optional global fallback
正常查询不创建对象、不拼接字符串、不运行 LINQ、不反射属性,也不扫描 Visual Tree。Control 级 Global Token 使用 稀疏差量,没有覆盖时直接回退到 global resource。
NativeAOT 约束决定了注册模型:
- generator 产生 Token descriptor、factory、getter、setter 和 resource projector。
- generator 产生 ControlTheme asset manifest、owner 和引用的 Control identities。
- 包级入口显式注册生成结果。
- 运行时不使用
Assembly.GetTypes()、Activator.CreateInstance或 PropertyInfo 构建 schema。
这不只是为了消除 analyzer warning。它让 identity、Own Token 和资源 key 在构建期可验证,也让第三方 Control 与内置 Control 使用同一条确定性路径。
17. 常见错误及其根因
| 错误做法 | 为什么有问题 | 正确入口 |
|---|---|---|
在 ControlTheme 中给 ControlTokenScope.Identity 赋值 |
把 Token 归属变成隐式环境状态 | 使用 XxxTokenResource |
在 RatingToken 中重新定义 ControlHeight |
与 Global Token 产生重名和双真源 | 使用 Rating Effective Global Token |
NumericUpDownToken : ButtonSpinnerToken |
配置 identity 与实际消费关系失真 | 定义独立 Own Token,共享值提升到 Global Token |
| 把合法但未消费的 Global Token 当成配置错误 | 重新引入不完整消费白名单 | 接受全部 Global Token,未知名称才失败 |
用 SharedTokenResource 期待 Rating 局部覆盖 |
Shared 始终读取全局值 | 改用 RatingTokenResource |
| 为 SearchEdit 创建借用 Button/LineEdit Token 的 SearchButton | 一个类型出现多个 Token 归属 | 组合 public Button + SearchButtonTheme |
| 为每个 ControlTheme 写注册代码 | 容易遗漏且破坏第三方开发体验 | 约定目录 + 生成 manifest |
| 运行时扫描第三方程序集 | trimming 后元数据不可靠,启动成本不可控 | 包级生成注册入口 |
18. 推荐的主题定制工作流
- 先确定修改属于全局设计语言、Control 有效 Global Token、Own Token、Semantic Selector、ControlTheme 还是 Semantic Part Theme。
- 从 Global Token 开始定制,让算法生成完整派生值。
- 只有单个 Control 需要偏离时,优先选择已有 Global Token;只有 Control 独有语义才使用 Own Token。
- Token 能表达的视觉差异不要复制模板。
- 只有结构、selector 或状态映射需要改变时才创建自定义 ControlTheme。
- 稳定内部区域优先使用 Semantic Selector;只有完整替换真实 public 子 Control 时才使用 Semantic Part Theme。
- 新主题资产通过生成 manifest 注册,在 ThemeManager 构建前冻结 owner、引用 identity 和结构契约。
- 验证 Light/Dark、Control 算法、局部 ThemeContext、Popup/TopLevel 和 NativeAOT 路径。
19. 最终心智模型
可以把 AtomUI 主题系统记成下面五句话:
- Global Token 定义设计语言,Own Token 只表达某个 Control 独有的稳定语义。
- Control 可以覆盖完整 Global Token schema,但不能修改全局结果或影响其他 Control。
SharedTokenResource永远读全局,XxxTokenResource永远读 Xxx 的 Effective Control Token。- ControlTheme 负责整体样式和模板,Semantic Selector 负责稳定区域覆盖,Semantic Part Theme 只负责可选的 public 子 Control 完整替换。
- 所有输入先编译成不可变 ThemeSnapshot,再由稳定 ThemeContext 原子发布。
相关文档
- AtomUI 主题系统架构:完整运行时模型、事务、缓存、生命周期和验收标准。
- Semantic Part 系统设计:稳定视觉区域、Selector、ContractType、Popup 和兼容性契约。
- Control Token 设计规范:Control 和第三方 Control 的研发约束。
- 主题定义 XML v1 规范:主题文件格式、算法和验证规则。
- 启动与注册链路:应用、Control 包和 ThemeManager 的构建顺序。