43 KiB
AtomUI AOT 编程规范
这份文档给日常写 AtomUI 代码的人用,定义新增控件、主题、图标、语言资源、Gallery 示例和发布配置时要遵守的规则。 控件注册采用 TypeMap 目标架构;架构采纳、源码迁移状态和删除旧实现的验收边界统一见 AOT 与裁剪架构。本文的注册规则是新实现必须满足的契约,不能据此 推断当前源码或完整 Gallery 已迁移。注册机制见 TypeMap 注册架构, 类型与资源规则见 控件注册契约。
目标很简单:
src/里的库项目在 AOT、trim、single-file analyzer 下不产生项目自身 warning。- Desktop NativeAOT、trimmed CoreCLR、Browser 裁剪运行和 Browser AOT 都必须通过真实发布、启动及精细裁剪验证。
- AOT 改造不能改变原来的控件行为、绑定语义、异常语义、资源释放边界和关键性能路径。
先看这几条
日常开发先记住这 7 条,绝大多数 AOT 问题都能在写代码时避开。
- 新增功能和修复 bug 时,AOT 兼容是第一设计约束。同一需求有 AOT 友好实现和运行时反射/动态发现实现时,必须选择 AOT 友好实现;能用 source generator 就不要用反射。
- 不要在 AtomUI 内置路径里新增字符串绑定,例如
new Binding("Name")或 AXAMLReflectionBinding。 - 不要运行时扫描 assembly、type、field、property 来完成内置注册。使用生成式 registry/catalog;TypeMap 注册只查询已知 key,并读取已选代理上的已知注册 Attribute,不扩大为程序集发现或动态方法搜索。
- 不要用
UnconditionalSuppressMessage掩盖未解决的 trim/AOT 问题。条件 TypeMap 声明和受控访问点只允许有理由、可验证的局部处理;suppression 本身不会保留 metadata,禁止全局关闭 IL2026/IL3050。 - 替换 AOT 不安全代码时,先确认旧语义,再改实现。尤其是 binding mode、binding priority、初始值、异常包装、dispose 后行为。
- 新增 subscription、binding、event handler、activation scope、cache 时,必须能说清楚在哪里释放或失效。
- Analyzer 通过不等于发布产物可用。涉及发布配置、linker、root descriptor 时,要验证真实产物启动、控件行为和未用类型删除。
按场景查
| 场景 | 推荐写法 | 避免写法 | Review 重点 |
|---|---|---|---|
| 控件属性同步 | AvaloniaProperty、GetObservable、BindUtils.RelayBind(...) |
new Binding("Path") |
mode、priority、初始值、dispose 后行为 |
| 模板内 part 同步 | C# 里拿到 template part 后强类型绑定 | AXAML ReflectionBinding |
template reapply 时旧 part 是否释放 |
| 控件注册 | 普通生成器输出片段与 TypeMap,官方链接器决定保留集合 | Assembly.GetTypes()、应用 usage 扫描、构造全集后过滤 |
Token/语义/资源是否一致,未用类型是否删除 |
| token converter | generator 生成静态数组 | 运行时扫描 attribute 后 Activator.CreateInstance |
数量、顺序、map 行为是否不变 |
| 语言资源 | generated Catalog descriptor + compiled Translation Bundle | GetFields(...) 枚举资源字段 |
Catalog ID、占位符和回退语义是否不变 |
| 图标创建 | generated factory 或 virtual factory | 扫描 icon assembly 后反射创建 | 非法 kind 的异常包装是否不变 |
| DataGrid 动态 path | [GenerateDataMemberAccessors] 或手写 descriptor |
对用户模型直接 GetProperty(path) |
sort/filter/group/AddNew 是否走 descriptor |
| 非 Visual AvaloniaObject 资源宿主 | [GenerateScopedResourceHost] 生成 scoped host 生命周期 |
每个对象手写 IResourceHost / IThemeVariantHost 样板代码 |
owner attach/release、WeakReference、资源更新测试 |
| ReactiveUI view activation | AtomUI/Gallery 自己管理 activation scope | view-side WhenActivated extension 反射路径 |
Loaded/Unloaded 和 VM 切换释放 |
| 发布配置 | analyzer 加目标后端的真实发布与运行 | 只看普通 build | linker、浏览器转换、未用类型及构建工具部署边界 |
适用范围
这份规范覆盖以下项目:
src/AtomUI.Coresrc/AtomUI.Controls.Sharedsrc/AtomUI.Controlssrc/AtomUI.Desktop.Controlssrc/AtomUI.Desktop.Controls.DataGridsrc/AtomUI.Desktop.Controls.ColorPickersrc/AtomUI.Desktop.Controls.Extrassrc/AtomUI.Icons.*src/AtomUI.Generatorcontrolgallery/AtomUIGallerycontrolgallery/AtomUIGallery.Desktopcontrolgallery/AtomUIGallery.Browser
Binding
为什么字符串 binding 有风险
new Binding("Name")、ReflectionBinding 这类写法把成员访问延迟到运行时:运行时拿到字符串,再去找属性、字段或索引器。NativeAOT 和 trimming 会删除静态代码没有证明会用到的 metadata,所以这类路径很容易出现 analyzer warning,严重时运行时才失败。
AtomUI 内置控件和 Gallery AOT 路径应尽量把绑定改成编译期可见的属性访问。
AvaloniaObject 之间同步属性
两个 Avalonia object 之间同步属性时,优先使用 AvaloniaProperty:
disposables.Add(BindUtils.RelayBind(
source,
SourceControl.SomeProperty,
target,
TargetControl.SomeProperty,
BindingMode.OneWay,
BindingPriority.Template));
BindUtils.RelayBind(...) 的语义要求:
OneWay:从 source 的GetObservable(sourceProperty)绑定到 target。TwoWay:保留 source-to-target 和 target-to-source 的双向同步,不能把 target 初始值错误写回 source。OneWayToSource:只把 target 后续变化写回 source,不能额外制造 source-to-target 行为。OneTime:也要保持旧 binding 的写入 priority 和释放语义,不能简单写成SetCurrentValue(...) + Disposable.Empty,除非旧语义本来就是一次性写入且无需恢复。
不要再新增字符串 path overload。BindUtils.RelayBind(object, string, ...) 这类 API 应保持为兼容边界或编译期错误入口。
非 AvaloniaObject 数据源
对 INotifyPropertyChanged 类型,可以用属性名过滤通知,但取值必须走强类型 getter:
disposables.Add(BindUtils.RelayBind(
viewModel,
nameof(MyViewModel.Options),
static vm => vm.Options,
optionsControl,
SomeControl.OptionsProperty));
这里的 nameof(...) 只用于判断是哪一个属性变了,不允许再用反射按名字读取属性值。
Template part binding
模板里不要新增 ReflectionBinding。template part 之间要同步时,在控件类里拿到 part 后绑定:
_templateBindingDisposables?.Dispose();
_templateBindingDisposables = new CompositeDisposable();
if (partA is not null && partB is not null)
{
partB.Bind(
TargetProperty,
partA.GetObservable(SourceProperty),
BindingPriority.LocalValue)
.DisposeWith(_templateBindingDisposables);
}
这里最容易出错的是生命周期:
OnApplyTemplate开始时先释放旧的 disposable。- template reapply 后,旧 part 不能继续被 observable、event handler 或 binding 持有。
- 删除旧 binding 前,要确认旧 binding 是否会跟随动态 effective value 变化。不能因为初始默认值一样就删掉。
Visual ancestor binding
需要找 visual ancestor 时,使用 BindUtils.BindVisualAncestor(...):
_relayBindingDisposables.Add(BindUtils.BindVisualAncestor(
this,
target,
TargetControl.OpenOnProperty,
typeof(TopLevel),
priority: BindingPriority.Template));
注意:
ancestorLevel从 1 开始。- 返回的 disposable 必须在 detach 或 template reapply 时释放。
- 不要用一次性的
TopLevel.GetTopLevel(this)替代动态 ancestor binding,除非旧语义就是只查一次。
Source Generator
什么时候该用 SG
如果代码需要做这些事,优先考虑 source generator:
- 扫描类型并注册。
- 根据 attribute 生成 registry。
- 根据 enum 创建具体类。
- 把资源字段写入 dictionary。
- 根据数据模型生成属性 accessor。
- 为 closed generic 或具体类型生成 factory。
- 为 owner-managed 非 Visual
AvaloniaObject生成 scopedIResourceHost/IThemeVariantHost生命周期样板代码。
SG 的价值不是“把反射挪个地方”,而是让运行时代码变成普通的强类型 C#。这样 trimmer 能看见类型、构造函数和成员,NativeAOT 也不需要动态代码生成。
非 Visual AvaloniaObject 资源宿主类需求统一遵循 Scoped Resource Host 开发规范。不要在每个描述对象中复制手写资源宿主代码;业务属性保留在主文件,资源宿主生命周期由 generator 生成,owner 控件只负责 attach/release。
Generator 项目边界
AtomUI.Generator 是 Roslyn analyzer 项目,不是运行时依赖。引用方式保持:
<ProjectReference Include="../AtomUI.Generator/AtomUI.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"
PrivateAssets="all" />
生成文件可以输出到 GeneratedFiles/,但项目要继续排除重复编译;这些编译产物默认不跟踪:
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>GeneratedFiles</CompilerGeneratedFilesOutputPath>
<Compile Remove="$(CompilerGeneratedFilesOutputPath)/**/*.cs"/>
PublishAot=true 是 MSBuild global property,会沿着 ProjectReference 传播。generator 是 netstandard2.0 analyzer 项目,不应该参与 NativeAOT 发布,所以 generator 项目要把这些属性隔离在本项目内:
TreatAsLocalProperty="IsAotCompatible;EnableAotAnalyzer;EnableTrimAnalyzer;EnableSingleFileAnalyzer;PublishAot;PublishTrimmed;PublishSingleFile;RunAOTCompilation;SelfContained;RuntimeIdentifier"
并且在 generator 项目里显式关闭运行时发布属性。
应用和第三方作者不需要为了 AOT 或 trimming 额外引用生成器或手配链接器参数。产品 SDK 随 NuGet 自动交付同版本
普通 Generator、Build Tasks、浏览器链接后端和 buildTransitive assets;Package identity 默认由正常包身份推导,
不携带注册方法名或裁剪粒度。显式 Generator 引用不能造成重复加载,最终 @(Analyzer) 中只能有一份 AtomUI.Generator.dll。
维护这条打包链时遵守以下约束:
- Generator、Build Tasks 和浏览器链接后端只能进入 NuGet 的编译期目录,不能进入
lib/、普通输出或 publish 目录。 - 多个产品包的入口必须幂等;Analyzer 去重依据
ResolveReferences后的最终编译器输入。 - MSBuild 项目求值阶段的
Import、ItemGroup或 item Condition 不得引用 item list;需要检查@(Analyzer)时放入BeforeTargets="CoreCompile"的 Target。 - Release 打包必须先用相同
AtomUIVersion构建 Generator/Build Tasks;修改版本后不能用--no-build复用旧输出。 - 验证至少覆盖单包、多包、显式 Generator 引用、普通构建、传递引用、预编译消费 DLL、NuGet 冷消费和全部发布后端。
生成物必须稳定
改 generator 时要同时看两层:
- writer 代码是不是符合预期。
GeneratedFiles/AtomUI.Generator/**里的生成物是不是能由当前 writer 稳定复现。
不能只手改生成物,也不能只改 generator 却不检查生成物 diff。之前 review 里已经出现过 generator writer 和提交的生成物不一致,这类问题会让后续维护很难判断真实来源。
包级生成代码统一位于 AtomUI.Generated.<AssemblyOwner>。AssemblyOwner 必须是由程序集名生成的单一 PascalCase
标识符,点号、连字符、下划线和其他非字母数字字符只作为单词边界,不进入最终标识符。例如:
AtomUI.Controls -> AtomUI.Generated.AtomUIControls
AtomUI.Desktop.Controls -> AtomUI.Generated.AtomUIDesktopControls
AtomUI.Desktop.Controls.DataGrid -> AtomUI.Generated.AtomUIDesktopControlsDataGrid
Source Generator、Localization writer、控件注册 metadata 和 AXAML Theme wrapper Build Task 必须复用同一
命名 helper,不得各自维护 sanitizer。手写代码引用生成入口时必须引用当前项目自己的 owner namespace;禁止依赖
InternalsVisibleTo 从其他 AtomUI 包调用同名 Generated* 类型,因为这种错误可能正常编译却注册错误 Package。
TypeMap 控件注册
控件注册只有一个普通生成模型。可访问的 public/internal 非泛型 Control 拥有主题、Token 或 Semantic Part 契约时, 生成自己的注册片段;没有独立内容的派生类型不生成空片段。内部 presenter 可以只有资源片段,不伪造公开 Token identity。 目录位置和包大小不决定裁剪粒度;Common 控件也遵循这一模型。
普通生成器负责 Control/Token/Semantic descriptor、资源 factory、条件 TypeMap 和 Package marker。应用引导只读取已解析
程序集引用中的 Package marker,生成 TypeMapAssemblyTarget<ConcretePackageGroup>;不扫描用户 C#/AXAML usage,
不为预编译消费 DLL恢复调用图,不生成应用保留计划,也不沿 ProjectReference 传播额外的发布分析上下文。
详见 控件注册生成器。
必须满足以下保留规则:
- 一个具体保留条件使用一个唯一字符串 key。多个条件可以指向同一代理,但不能复用同一 key 表达 OR。
- 正常 Control 使用、生成 TokenResource/Token key、Own Token enum、生成 identity 和专用 Semantic Style 的保留条件 必须覆盖各自契约;不能假设 Token-only 或 Style-only 总会构造 owner 控件。
- 候选表只能包含字符串,不能包含所有 Control Type、代理实例或 factory delegate。运行时逐项
TryGetValue, 按代理 Type 去重后再读取已知的ControlRegistrationFragmentAttribute。 - 每包生成具体闭合的 TypeMap API 调用。共享 Runtime 接收已经取得的映射,不用开放泛型
ReadMap<TGroup>()包装查询。 - 代理只收集自身 descriptor 和资产,不递归注册其他片段。真实类型/工厂引用交给官方 ILLink/ILC 求可达性闭包。
- 静态附加属性访问本身不保证保留 owner。需要 owner 的路径必须包含可观察且参与真实校验的数据引用,不能放置会被消除的
空
typeof。动态创建沿用类型化工厂、DAM/DD 等官方保留机制,不引入字符串注册清单。 - 平台判断必须位于 descriptor、语义和资源工厂引用/构造之前,不能在构造全集后以运行时 predicate 过滤。
平台声明的约定是“控件能力放在控件类型,主题额外限制放在正常资源 CLR 类型”,不按目录推断或维护主题路径名单。 声明归属、可用域算法和迁移边界以平台分支 及其状态链接为准。不要为移除资源配置而收窄公共控件的平台能力;已退役的自定义平台元数据不再读取。
TypeMap API、触发条件和异常边界见 TypeMap 契约。它不保证枚举、任意运行时 字符串类型名自动保留或不同后端得到完全相同的最小集合。不得通过异常捕获、全包保留或 late registration 掩盖漏注册。
Package 入口与冻结
UseDesktopControls()、UseCommonControls() 和第三方 UseXxxControls() 仍是普通包启用入口。Provider、服务、语言、
initializer 与真正公共资源属于 Package Core;入口通过正常 provider factory 和显式 prepare/complete 回调接入生成 helper。
不解析入口方法体猜测顺序,也不让 Package marker 自动启用包。
包入口执行 prepare、创建 Provider、收集公共资源和选中片段、检查包内身份、暂存注册,再执行 complete。跨包 Token owner、 Semantic Part 和 schema 校验,以及资源创建、排序、挂载和冻结,统一在所有配置入口返回后的 Build/Initialize 阶段完成。 首次控件实例化前必须完成冻结;主题切换、scoped Token 和 Style 构造不再追加注册。
公共重复入口在 prepare 前报错;递归注册同包也报错。内部基础包 Ensure 操作与公共重复 Use 区分,保留实际基础包调用顺序。 状态属于 builder 实例,失败后不能继续构建部分成功的应用;不得使用持有 builder/provider 的静态缓存。
资源按真实包提交顺序以及包内明确的 Phase/Order 排序。TypeMap 查询顺序、程序集遍历顺序和选中数量不能改变共同资产的 相对优先级。多个片段引用同一 AssetId 只挂载一次,冲突定义报错;deferred wrapper 必须保留。
浏览器编译后端
Browser 精细裁剪使用官方 ILLink 的 TypeMap 标记结果,再通过独立构建工具转换自有 map accessor;不在运行时扫描残留 Attribute,不修改 BCL,也不实现第二套应用依赖图。NativeAOT 使用官方 TypeMap 后端,不加载浏览器转换器。
转换必须位于 Mark 完成后、Sweep 前,只引用已标记的代理和 Mark 前已保留的 helpers。所有可达 accessor 必须转换完成, 且不再调用当前 Mono 后端不可执行的 TypeMapping API。无可达 accessor 的合法空应用与漏执行转换必须区别处理。
编译工具与 Roslyn Generator、运行时库物理隔离;随 SDK 自动接线。后端 DLL、依赖、配置和 ABI 纳入增量输入。 未验收工具链、不兼容 ABI、重复 key、缺 helper 或残留 accessor 都必须阻止发布。不得退回全包注册。 完整要求见 浏览器链接后端。
第三方 Control Package 检查
普通第三方作者只维护正常产品契约:
- 引用兼容的 AtomUI 产品 SDK,沿用自动交付的普通 Generator 和构建资产。
- 提供 Control、可选 Own Token、Semantic Part 和正常编译型 AXAML;资源依赖通过词法作用域和显式 include 表达。
- 以普通
UseXxxControls()调用生成 registration helper,提供 Provider;有生命周期逻辑时明确传入回调。 - 不手写 TypeMap、入口身份 Attribute、AOT 分支、控件名单、目录粒度或 linker XML。
- 验证完整与选中注册共用的 descriptor、资源优先级和行为;发布兼容声明前验证实际目标平台。
操作示例见 第三方 AtomUI Control Package 指南。预编译消费 DLL 无需使用清单,但其引用的控件包必须符合新生成契约;缺包或不兼容包必须明确失败,不能恢复旧扫描与全包兜底。
注册 ABI 与生成命名
跨程序集隐藏 ABI 使用中性的注册命名,如 ControlRegistrationFragmentAttribute、ControlPackageRegistrationBuilder
和 GeneratedControlPackageRegistration。公开性仅用于跨程序集生成代码访问,不成为用户手写注册扩展点。
Package Group 是无全包静态引用的隐藏公共类型;生成器内部和输出命名规则由
控件注册生成器与 TypeMap 契约统一维护。
完整与选中分支使用同一片段事实源,由链接器 feature switch 选择。不能依赖包编译时的 Debug/Release 常量,因为同一 NuGet 必须支持非裁剪运行和不同发布后端。模式选择、版本验证和产物检查由构建资产自动完成,用户不配置内部开关。
Theme / Token
Control 与 Token 注册
运行时不要扫描 assembly 查找可主题化 Control 或 Control Token。应由 generator 生成 独立的 descriptor factory,完整注册与选中片段调用同一 factory。拥有公开可配置 Token 契约的 Control 即使没有 Own Token, 也拥有 identity 和 descriptor;仅有主题资源的内部 Control 不需要 Token descriptor。不得通过全包静态 descriptor 池 间接保留所有控件。
descriptor 必须直接提供以下静态已知信息:
- exact Control CLR type、
ControlTokenIdentity和 registry slot。 - 无参数
[ControlDesignToken]标记的可选 Own Token 类型;Attribute 不携带 Control 类型或 identity。 - 可选的 Own Token builder 直接构造委托。
- Own Token name、value type、stage 和 slot。
- 强类型 parse、set、get 和 resource projection 委托。
OwnTokensschema;Control 的可配置 Global Token 集合始终是完整 Global Token schema,不生成消费白名单。- 实际 owner Type 与身份验证信息;主题导出、RequiredTokenOwners、Semantic Part 和资产 factory 由对应片段一并收集。
Semantic Part descriptor 必须由 Control 声明和构建输入静态生成。运行时不得扫描 AXAML、ControlTheme、
Classes 或 VisualTree 来发现公共 Part,也不得使用 Dictionary<string, Style>、反射 Property 查找或动态 Theme
factory 合并 Part 样式。.semantic-* marker 只参与 Avalonia 原生 Selector;Gallery 的 VisualTree 高亮属于开发
工具路径,不能进入 Control 运行时。
注册还必须满足:
- Builder 必须原样传递 descriptor,不能丢弃 identity 后退化为
Type注册。 - 内置正常路径不调用
Activator.CreateInstance、Type.GetProperties或PropertyInfo.GetValue/SetValue。 - 第三方 Control 包必须使用 AtomUI generator,并只通过一个真实的包级入口调用生成 registration helper,注册 Control、 可选 Own Token 和主题资产;不提供手写 descriptor、手工 manifest 或反射 fallback 旁路。
- Own Token 可以放在包内正常源码位置并使用
[ControlDesignToken]标记;禁止泛型 Control 参数和手写 ID。 - Control Token 定义继承只允许 Generator 在编译期把显式标记的抽象层扁平化到
sealed终端。抽象层不生成 identity、descriptor、注册片段或动态 root,运行时不扫描或遍历 Token 基类。完整契约见 Control Design Token 继承架构。
Token value converter 注册
不要在运行时通过 Assembly.GetTypes()、IsDefined(...)、Activator.CreateInstance(...) 扫描 converter。应由 TokenValueConverterRegistrationGenerator 生成静态注册:
ITokenValueConverter[] valueConverters =
[
new StringTokenValueConverter(),
new IntegerTokenValueConverter(),
new DoubleTokenValueConverter()
];
改这里时要确认:
- converter 实例数量和旧逻辑一致。
TargetType()到 converter 的 map 行为不变。- 新增 converter 后能在
TokenValueConverterRegistry.g.cs里看到生成结果。
Resource key cache
Resource key cache 应该基于显式 token metadata。不要在资源生成或 theme 切换的 hot path 里反射查找 token property。
cache 要满足:
- key 稳定。
- theme/token 更新时失效边界清楚。
- 不引入无上限增长的缓存。
Language
Catalog 与翻译表走生成代码
所有应用、控件和类库使用 [LanguageCatalog] enum 与 XLIFF 2.1。LocalizationGenerator 在编译期直接生成:
- enum 数字 ID 到 slot 的静态映射。
LanguageCatalogDescriptor和编译后TranslationBundleDescriptor。- 强类型
XxxLangResourceExtension。 GeneratedLanguageModuleRegistration和最终应用 bootstrap。
正常路径禁止通过 Assembly.GetTypes()、GetFields()、Enum.GetNames() 或 Attribute 反射发现 Catalog,也禁止
运行时解析 XLIFF。模块主包的 en-US 与静态语言包目标 XLIFF 只作为 AdditionalFiles 进入最终应用编译;运行时
只保留不可变 Snapshot 和字符串表。
Review 时要看:
- Catalog enum 是否公开、使用稳定显式正整数 ID,且不含别名或
[Flags]。 - 所有 Catalog 是否具有完整
en-US,目标语言的 unit/占位符契约是否一致。 - 类库包级入口是否直接调用生成的模块注册,不扫描程序集。
- 应用 bootstrap 是否只直接注册静态语言包和 Override Bundle。
- 发布目录是否没有 XLIFF、Build Tasks 或 Generator 程序集。
Icon
图标创建走 generated factory
图标 provider 不要运行时扫描 icon assembly,也不要用 Activator.CreateInstance(iconType) 创建图标。生成器负责生成 GetIconType(kind) 和 CreateIcon(kind)。
GetIcon(kind) 的异常包装语义要保留:
try
{
return CreateIcon(kind);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Create icon {kind} failed", ex);
}
这不是细节。非法 kind、构造失败、内部异常的外层异常类型和 message 都属于对外行为。
Gallery icon catalog
Gallery 展示图标时使用 generated catalog:
AntDesignIconCatalog.GetIcons()
catalog entry 应包含:
NameThemeTypeKindIconType- direct
Func<Icon>creator
Gallery 不要运行时扫描 icon assembly。
同类型 icon clone
需要创建同类型 icon 时,不要通过反射构造。使用虚方法:
public override Icon CreateInstance()
{
return new SameIconType();
}
要求:
- 每个生成图标 override 后返回同一具体类型。
- base
Icon.CreateInstance()默认失败,避免静默创建错误类型。 - clone 后只复制旧逻辑复制过的属性,不能额外覆盖 brush、animation、stroke 等状态。
DataGrid / Collection 动态数据
问题本质
DataGrid、collection sort/filter/group、自动生成列这些场景经常拿到用户数据模型和字符串 property path。这里的动态性是功能需求,不能简单把 warning suppress 掉。
正确方向是:对我们能控制的模型生成 accessor;对用户模型提供显式 descriptor;最后才保留带 warning 的兼容 fallback。
推荐写法
可控制的数据模型加 [GenerateDataMemberAccessors]:
[GenerateDataMemberAccessors]
public partial class PersonRow
{
public string? Name { get; set; }
public int Age { get; set; }
}
如果集合以接口或基类作为 item type 暴露,并且排序、过滤或分组 path 来自这个接口/基类,也要在对应接口或基类上生成 accessor;不要依赖运行时从首个 item 反推具体类型。
不可加 attribute 的模型,显式传入 IDataMemberAccessorDescriptor:
var descriptor = new DataMemberAccessorDescriptor<PersonRow>(
static () => new PersonRow(),
new IDataMemberAccessor[]
{
new DataMemberAccessor<PersonRow, string?>("Name", static row => row.Name),
new DataMemberAccessor<PersonRow, int>("Age", static row => row.Age)
});
相关逻辑优先从 descriptor 取信息:
- sort
- filter
- group
- AddNew
- auto-generate columns
- data type inference
- read-only metadata
如果 descriptor 不存在,可以进入 RUC fallback,但调用点必须显式看到风险。RequiresUnreferencedCode 不会保留成员,它只会把风险传递给调用方,让 analyzer 在 AOT/trim 场景下报警。
所以结论是:AtomUI 内置模型要走生成 accessor;用户如果要 NativeAOT 稳定发布,就要提供 generated 或手写 descriptor。
编译期诊断
DataGrid、List、collection view 等使用字符串 path 做排序、过滤、分组、自动列或数据成员读取时,必须优先让问题在编译期暴露,而不是等到 NativeAOT 运行时才失败。
可静态判断的场景必须提供 analyzer warning。诊断 ID、ID 命名、severity、编码组织和测试规则统一维护在 compiler-diagnostics-guidelines.md。
Reflection helper
反射 helper 只能存在于明确边界;控件注册另外允许读取已选 TypeMap 代理上的已知注册 Attribute,不扩展为任意类型发现:
- public compatibility API。
- 用户动态模型 fallback。
- 平台或框架能力探测。
保留反射时要写清楚风险:
- 需要成员 metadata 的
Type参数或返回值,要用DynamicallyAccessedMembers标注。 - 无法证明 trim 安全的 API,要用
RequiresUnreferencedCode标注。 - helper 内不要动态构造泛型类型。优先扫描对象已经实现的 closed interface。
- 不要用
UnconditionalSuppressMessage让调用方误以为代码安全。
可以这样理解:
- DAM 用来告诉 trimmer:“这个 Type 值流到这里时,需要保留哪些成员。”
- RUC 用来告诉调用者:“这个 API 在 trim/AOT 下不保证安全。”
- suppression 只是不显示 warning,不解决问题。
ReactiveUI
AtomUI runtime controls
AtomUI runtime 控件不能依赖 ReactiveUI view-side reflection activation path。ReactiveWindow<TViewModel> 这类基类要自己完成关键行为:
- 实现
IViewFor<TViewModel>。 - 维护
ViewModel和DataContext同步。 Loaded时激活当前IActivatableViewModel。Unloaded或切换ViewModel时释放旧 activation disposable。- 处理 reentrancy,避免 unload 过程中误激活新 VM。
构造函数里空的 this.WhenActivated(...) 没有业务价值,应删除。非空 activation block 要迁移到 AOT 友好的本地 activation API。
Gallery controls
Gallery AOT 路径使用 GalleryReactiveUserControl<TViewModel> 替代 ReactiveUserControl<TViewModel>。
它要保留 ReactiveUI 的核心使用体验:
IViewFor<TViewModel>。ViewModel和DataContext双向同步。- Loaded/Unloaded 对应 view activation scope。
IActivatableViewModel.Activator.Activate()。WhenActivated(Action<CompositeDisposable>)的注册、激活、卸载释放语义。
也就是说,我们不是放弃 ReactiveUI,而是避开它 view-side extension API 中对 AOT 不友好的表达式/反射路径。
Gallery binding / command
Gallery code-behind 不要新增 ReactiveUI expression binding:
this.OneWayBind(...);
this.BindCommand(...);
this.WhenAnyValue(...);
observable.ToProperty(...);
替代写法:
GalleryBindingUtils.OneWay(...)GalleryBindingUtils.BindCommand(...)- setter 内显式更新派生属性
- 显式
BehaviorSubject<T>或IObservable<T>管理 command canExecute
GalleryBindingUtils.BindCommand dispose 时要恢复绑定前 command,避免 activation scope 结束后留下旧 command。
Publish / trimming / NativeAOT
日常构建不运行发布分析器
普通 Debug 和非 AOT、非裁剪的 Release 构建必须关闭 SDK 的 trim、AOT 和 single-file analyzer,避免把发布期静态分析成本带入日常开发。Gallery Desktop 的 Release 项目只保留 IsTrimmable=true 和 IsAotCompatible=true 兼容性声明;PublishTrimmed、PublishAot 和自包含发布属性由发布脚本显式传入,不能隐式改变普通构建的 analyzer 开关。
仓库构建按以下输入自动启用对应 analyzer:
PublishTrimmed=true:启用 trim analyzer。PublishAot=true或RunAOTCompilation=true:启用 trim 和 AOT analyzer。PublishSingleFile=true:启用 single-file analyzer。- 显式传入
EnableTrimAnalyzer、EnableAotAnalyzer或EnableSingleFileAnalyzer时,保留调用方选择,用于专项验证。
普通 Generator 在所有构建中输出同一套 Theme/Token/Semantic/资源片段、TypeMap 和 Package marker。非裁剪运行直接使用 完整片段;裁剪发布选择映射,Browser 再执行编译期转换。不能加载旧 usage analyzer 或产生 Sidecar/应用计划;这些历史 输出是否仍存在于迁移中的源码,由 AOT 与裁剪架构的状态边界说明。
Analyzer 和真实 publish 都要跑
AOT/trim analyzer 通过,只说明静态分析没有发现项目自身 warning。它不等于 trimmed JIT 或 NativeAOT 链接和运行一定成功。涉及控件片段、资源依赖、TypeMap、浏览器后端、发布配置或 native 依赖时,要做对应模式的真实 publish。
Windows 11 上 Gallery Desktop 的 NativeAOT 工具链、发布命令、产物验证和排障记录见 Windows NativeAOT 发布。Linux 平台的对应手册见 Linux NativeAOT 发布。
库项目 analyzer:
dotnet build src/AtomUI.Core/AtomUI.Core.csproj -c Release --no-incremental \
/p:IsAotCompatible=true \
/p:EnableTrimAnalyzer=true \
/p:EnableAotAnalyzer=true \
/p:EnableSingleFileAnalyzer=true \
/nr:false --nologo -v:minimal
Gallery analyzer:
dotnet build controlgallery/AtomUIGallery/AtomUIGallery.csproj -c Release --no-incremental /m:1 \
/p:IsAotCompatible=true \
/p:EnableTrimAnalyzer=true \
/p:EnableAotAnalyzer=true \
/p:EnableSingleFileAnalyzer=true \
/nr:false --nologo -v:minimal
Gallery NativeAOT publish:
dotnet publish controlgallery/AtomUIGallery.Desktop/AtomUIGallery.Desktop.csproj \
-c Release -r osx-arm64 -p:GalleryPublishTrimmed=true \
-p:GalleryPublishAot=true --self-contained true \
--nologo -v:minimal
注册迁移和体积验收以 AOT 与裁剪架构的目标矩阵为准。迁移中的旧验证 脚本不能仅因退出成功就被视为已覆盖 TypeMap 或 Browser 后端。专项脚本成功仍不能代替真实宿主启动 smoke。Theme template 可以静态保留 CLR 类型而漏注册它的 descriptor;这类错误 只有窗口模板应用和首帧布局实际运行时才会暴露。发布后至少确认进程稳定进入主窗口,无 active theme schema、资源加载或 initializer 异常,再主动终止 smoke 进程。
macOS NativeAOT
当前 macOS 本机发布使用:
<ItemGroup Condition="'$(PublishAot)' == 'true' and $([MSBuild]::IsOSPlatform('OSX'))">
<LinkerArg Include="-L/opt/homebrew/lib"
Condition="Exists('/opt/homebrew/lib/libbrotlienc.dylib')"/>
<LinkerArg Include="-L/opt/homebrew/opt/openssl@3/lib"
Condition="Exists('/opt/homebrew/opt/openssl@3/lib/libssl.dylib')"/>
<LinkerArg Include="-L/usr/local/lib"
Condition="Exists('/usr/local/lib/libbrotlienc.dylib')"/>
<LinkerArg Include="-L/usr/local/opt/openssl@3/lib"
Condition="Exists('/usr/local/opt/openssl@3/lib/libssl.dylib')"/>
</ItemGroup>
原因:
- NativeAOT 链接
System.Net.Security.Native时需要libssl/libcrypto。 - NativeAOT 链接
System.IO.Compression.Native时还需要 Brotli 原生库。 - Apple Silicon Homebrew 的通用库目录通常是
/opt/homebrew/lib,openssl@3keg-only 库位于/opt/homebrew/opt/openssl@3/lib;Intel Homebrew 对应/usr/local路径。默认 linker 搜索路径可能找不到这些库。 - 仓库内 macOS 验证统一复用
build/MacOSHomebrewNativeAot.targets,不要在 Gallery 或 fixture 中重复硬编码路径。 - 该文件只补充 Homebrew OpenSSL/Brotli linker 搜索路径,不进入 NuGet,也不是 AtomUI 的通用 NativeAOT 配置。 Windows 和 Linux 使用共享 AOT 配置及各自平台工具链,不需要空的对称 targets 文件。
注意:
- 默认不要添加
-ld_classic。删除粗粒度 root 后,当前 NativeAOT 产物已经可以用默认 Apple linker 完成链接。 - 如果某个 Xcode/NativeAOT 组合再次触发
too many large addends,先验证是否是环境问题,再作为本机或 CI 发布参数处理,不要默认固化到项目文件里。 - 如果 CI 或开发机不是 Homebrew ARM64 路径,需要在发布脚本里提供正确的 linker search path。
- NativeAOT publish 输出的是裸 Mach-O 可执行文件。macOS 图形应用要双击或通过 LaunchServices 启动,需要
.appbundle。
Roots.xml
Roots.xml 不能保留解析不到的 assembly:
<assembly fullname="Some.Old.Assembly" preserve="All"/>
无效 root 只会产生 IL2007 warning,不会保留任何东西。删除 stale root 不改变运行时行为。
新增 root 前要说明:
- 为什么不能通过静态引用或 generator 保留。
- preserve 范围为什么不能更小。
- 是否会明显扩大 NativeAOT 体积。
控件动态边界优先使用真实保存并校验的 Control Type、类型化工厂以及匹配的 DAM/DD 注解。字符串 identity 仅匹配已保留
schema,不是动态 root。禁止使用整包 preserve="All" 修补注册或浏览器转换缺陷;正常控件接入不需要 root XML。
Browser WebAssembly 发布
Browser 发布必须同时具备裁剪后的 interpreter 与 RunAOTCompilation=true 两条真实运行证据。两条路径都使用同一
逐控件注册模型,不能以普通发布能运行、原型验证通过或关闭 AOT 替代产品验收。
发布顺序固定为普通编译、ILLink 标记与自有 accessor 转换、可选 Mono AOT、bundling。验证工具链组合包括 SDK、ILLink、 WASM workload/runtime pack、浏览器和 UI 宿主;升级其中任一相关组件后,重新确认适配器 ABI、转换与启动行为。 具体后端要求见 浏览器链接后端。
排障时区分:
dotnet.create()/ runtime 初始化阶段失败:先用最小纯 .NET Browser 程序判断工具链、打包格式和浏览器问题。- managed
Main已进入后失败:检查映射转换、包入口、资源加载与实际 Avalonia 模板。 - 最小程序通过后仍须验证最小 Avalonia 宿主,再验证真实 AtomUI/Gallery;前一级成功不能替代后一级。
WasmEnableWebcil 等打包选项按实际验证结果管理,不作为 TypeMap 注册的语义开关。若采用环境修复,保持其他变量一致
验证启动并记录适用条件;不能通过关闭裁剪、全包保留或跳过实际 UI 来隐藏问题。
Browser 裁剪与 AOT 验收门槛
每个纳入支持矩阵的 Browser 产品宿主必须证明:
- Release 裁剪发布在浏览器进入 managed 入口、完成注册冻结,并显示真实控件模板与首帧。
- 同宿主
RunAOTCompilation=true实际执行 AOT 编译,最终浏览器产物完成相同启动和控件行为验证。 - 直接使用、Token-only、Semantic Style-only 和间接模板依赖正常注册;未用控件、代理及专属资源确实未被保留。
- 最终自有 accessor 已转换,不残留不可执行的 TypeMapping 调用;缺失后端或 ABI 不匹配必须是失败对照。
- 主题切换、scoped Token、平台过滤、资源优先级和未启用可选包行为与目标契约一致。
历史 Gallery 环境曾在 runtime 初始化阶段出现 remainder by zero。这个环境失败不构成永久豁免,也不授权关闭 Browser
AOT 验收。独立 TypeMap 原型的 Browser 裁剪与 AOT 成功证明机制可行,不证明完整 Gallery 已迁移;当前产品状态只从
AOT 与裁剪架构读取。未满足上述门槛时报告尚未完成的产品验证,不宣称
浏览器发布支持完成,也不退回旧管线或全包注册。
Review 时看什么
每个 AOT 改动 review 时,至少回答这些问题:
- 旧实现依赖了哪种 AOT 不安全机制。
- 新实现如何消除这个机制。
- 旧语义是否保持,包括 binding priority、初始值、异常包装、排序、过滤、缓存顺序等。
- 新增 disposable、event handler、binding、activation scope 在哪里释放。
- 是否引入额外 per-instance 成本或 hot path 成本。
- generator 和生成物是否一致。
- analyzer 和必要测试是否通过。
- 是否完成受影响后端的真实发布、启动、精细裁剪与失败对照。
Review 记录应列出实际验证的后端、输入、产物和未完成门槛。正式架构与产品状态以 AOT 与裁剪架构为准,不依赖临时计划或本地实验目录。
新增代码自查
新增或修改 AtomUI 代码时,先扫一遍:
- 是否新增
new Binding("...")、AXAMLReflectionBinding或字符串 path binding。 - 是否新增 assembly/type/member 扫描。
- 是否新增
Activator.CreateInstance(Type)、Expression.Compile()或MakeGenericType(...)。 - 是否新增 ReactiveUI expression/view activation API。
- 是否新增动态 data model path,但没有 descriptor 或 generator。
- 是否新增 DataGrid/List/collection 字符串 path,但没有可在编译期报警的 analyzer 覆盖。
- 是否新增订阅、binding、event handler,但没有 release path。
- 是否新增 source generator 逻辑,但没有检查生成物稳定性。
- 是否新增 suppress trim/AOT warning。
- 是否修改 publish/linker/root descriptor,但没有真实 publish 验证。
建议 grep:
rg -n "new Binding\\(|ReflectionBinding|Assembly\\.GetTypes|GetFields\\(|GetCustomAttribute|Activator\\.CreateInstance|Expression\\.Compile|MakeGenericType|WhenAnyValue|ToProperty|OneWayBind|BindCommand|ReactiveUserControl" src controlgallery -g '*.cs' -g '*.axaml'
grep 命中不一定都是错误,但每个命中都要能说明边界和原因。
推荐验证命令
日常验证先按 按改动影响选择验证执行 plan 与对应 scope。以下全解决方案命令 仅用于明确需要全域构建或专项 analyzer 检查的场景,不能替代真实产品发布验证。
全域源码构建:
dotnet build AtomUI.slnx -c Release --no-incremental /m:1 /nr:false --nologo -v:minimal
全解决方案 AOT analyzer:
dotnet build AtomUI.slnx -c Release --no-incremental /m:1 \
/p:IsAotCompatible=true \
/p:EnableTrimAnalyzer=true \
/p:EnableAotAnalyzer=true \
/p:EnableSingleFileAnalyzer=true \
/nr:false --nologo -v:minimal
Gallery NativeAOT publish:
dotnet publish controlgallery/AtomUIGallery.Desktop/AtomUIGallery.Desktop.csproj \
-c Release -r osx-arm64 -p:GalleryPublishTrimmed=true \
-p:GalleryPublishAot=true --self-contained true \
--nologo -v:minimal
Focused tests:
dotnet test tests/AtomUI.Desktop.Controls.Tests/AtomUI.Desktop.Controls.Tests.csproj --nologo -v:minimal /m:1 /nr:false
dotnet test tests/AtomUI.Controls.Shared.Tests/AtomUI.Controls.Shared.Tests.csproj --nologo -v:minimal /m:1 /nr:false
dotnet test tests/AtomUI.Desktop.Controls.DataGrid.Tests/AtomUI.Desktop.Controls.DataGrid.Tests.csproj --nologo -v:minimal /m:1 /nr:false
dotnet test tests/AtomUIGallery.Tests/AtomUIGallery.Tests.csproj --nologo -v:minimal /m:1 /nr:false
Diff hygiene:
git diff --check
已知受控边界
下面这些命中不等于必须删除,但必须维持注解和文档边界:
TypeHelper动态 path fallback。ObjectExtension/TypeMemberExtension反射 helper。- DataGrid 对用户
Binding/ReflectionBinding的兼容读取。
共同要求是:AtomUI 内置正常路径不用这些 fallback;用户动态场景使用时风险要显式暴露;AOT 用户要有 descriptor、generator 或显式注册这样的稳定替代路径。