
做 FUI 这套框架一年多我最初以为最麻烦的是渲染性能和 UI 布局真踩下来才发现启动时那一段“装配逻辑”才是让人夜不能寐的地方。FUI 里的页面、ViewModel、服务映射过去全靠反射注册写起来很自由跑起来却是一笔糊涂账。启动扫描慢、类型安全无从谈起、裁剪和 AOT 还不友好。直到我把装配动作从运行时挪到编译期用 Source Generator 把注册表直接“焊死”在程序集里这些坑才真正被填平。这篇文章就围绕 FUI 的编译期装配实践展开从最初的反射注册方案讲起说说它的问题到底出在哪、Source Generator 又是怎么解决的、具体代码怎么落地以及我在迁移过程中踩过哪些坑。适合正在做 UI 框架、容器设计或者想引入编译期代码生成的读者尤其是对“反射性能焦虑”和“AOT 兼容性”有切身体会的人。1. 第一次用反射注册我就知道这方案走不远1.1 FUI 里的“装配”到底装的是什么先说清楚 FUI 的装配是什么。FUI 内部有一套自己的页面导航和依赖映射机制拿到一个路由名比如user/profile框架要知道该创建哪个 View、绑定哪个 ViewModel、注入哪些服务。传统做法是提前准备一张注册表字符串到 Type 的映射、Type 到构造参数的映射、ViewModel 到 View 的关联关系。这套映射在 FUI 里不是靠手写硬编码维护的因为页面太多手写会累死人而且新增页面时特别容易漏。于是第一版我用了特性 反射的思路给每个页面类打上[FuiView(user/profile)]给每个 ViewModel 打上[FuiViewModel]启动时统一扫描程序集自动构建出注册表。这个方案的优点非常直观页面和路由的关系就写在页面旁边开发者增删页面只需要改自己的类不用去碰中心化的配置文件。团队协作时摩擦很小新人也能很快上手。但那会儿我就隐隐觉得把所有希望押在运行时的Assembly.GetTypes()上迟早要出事。1.2 反射注册的典型实现第一版实现其实很标准核心就是启动时扫描 反射创建实例。我简化一下当时的代码大概是这样的public static class FuiReflectionRegistry { private static readonly Dictionarystring, Type _viewMap new(); public static void Initialize(Assembly assembly) { foreach (var type in assembly.GetTypes()) { var attr type.GetCustomAttributeFuiViewAttribute(); if (attr null) continue; _viewMap[attr.Route] type; } } public static object CreateView(string route) { var type _viewMap[route]; var viewModelType ResolveViewModel(type); // 反射拿构造函数再逐个 new 依赖 var ctor viewModelType.GetConstructors().First(); var args ctor.GetParameters() .Select(p FuiServiceContainer.Resolve(p.ParameterType)) .ToArray(); var viewModel ctor.Invoke(args); return Activator.CreateInstance(type, viewModel); } }这段代码在工作量小的 Demo 里看不出问题一旦页面数量上百启动耗时就开始一路飙升。更糟糕的是GetCustomAttribute和GetConstructors每次调用都会产生大量反射元数据分配在低端机上会被放大得非常明显。1.3 四个让人夜不能寐的问题随着模块越来越庞大反射注册方案的问题逐渐暴露成了四个维度的痛点。第一个是启动性能。扫描几百个类型加上逐个反射构造耗时能达到几百毫秒。对 PC 端可能还能忍但在移动设备或者嵌入式设备上这几百毫秒直接抢占首屏渲染窗口用户体感就是“白屏时间特别长”。第二个是类型安全。反射把类型检查推迟到了运行期路由名写错、构造函数参数对不上、某个依赖没注册全部要到点击页面那一刻才爆出来。测试覆盖不到的话问题就直接带到用户手里了。第三个是裁剪和 AOT。现代 .NET 应用越来越追求裁剪体积和 Native AOT但这套依赖反射的方案所有通过反射创建的类型都会被裁剪器当成“不可见引用”。想保活就得写DynamicDependency或者TrimmerRootAssembly写了一堆还是提心吊胆。Native AOT 下反射有诸多限制很多场景干脆不可用。第四个是代码可读性。装配逻辑散落在运行时扫描里框架使用者根本没法直观地看到“哪些页面已经注册了”。出了问题只能靠断点去翻内存字典调试成本极高。这四个问题单独拎出来哪个都能忍但组合在一起就是一个长期隐患。我当时心里很清楚继续在老方案上打补丁已经不是最优解得换一条路。2. Source Generator 为什么能接这个盘2.1 编译期装配的完整运行链路Source Generator 是 Roslyn 编译器提供的一套扩展机制它能在编译进行中读取你项目里的语法树和语义模型然后生成额外的 C# 源码参与本轮编译。听起来挺玄乎本质就是“编译器在正式产出程序集之前先让你写一段代码去批改作业”。所谓编译期装配就是把原先启动时扫描程序集的工作提前到编译阶段完成。FUI 需要的注册表不再是在运行时用反射临时搭建而是由生成器在编译期扫描所有带[FuiView]特性的类型直接生成一段包含完整映射的 C# 代码。这段代码本身就是静态的、强类型的、编译期已知的。运行时的链路就变得很简单了应用启动 - ModuleInitializer 调用注册函数 - 将编译期生成的静态注册表灌入容器 - 页面导航时直接按索引查找没有Assembly.GetTypes()没有Activator.CreateInstance没有GetCustomAttribute。查找一个页面变成查字典甚至查数组下标创建页面变成一次普通的new。启动时期被压到接近于零类型映射关系也被固化下来了。2.2 为什么不用 T4、手写代码或者静态分析工具可能有人会问这个需求用 T4 模板、手写注册类、或者写个 MSBuild Task 扫描生成不也行吗我都试过各有各的别扭。T4 模板擅长在“设计时”生成文本但它的运行节点在 Visual Studio 的编辑器侧跟编译流程是脱节的。开发者改了页面特性如果不手动运行模板生成代码根本不会更新。把它接到 MSBuild 里也能跑但调试体验、跨平台支持、对 SDK 风格项目的友好程度远不如 Source Generator 来得自然。手写注册类最稳定也不依赖任何魔法但维护成本高。每加一个页面既要写类又要改注册表这种重复劳动是团队协作中漏配置的主要来源。MSBuild Task 可以做到编译前扫描但它拿到的是文件路径和原始文本想理解类型的继承关系、特性参数这些语义信息需要自己解析 C#基本等于重写半个 Roslyn。而 Source Generator 本身就是 Roslyn 的一部分直接在语义模型层面工作拿到的就是编译器已经理解好的类型信息。所以我最后选 Source Generator理由可以概括成三点参与编译时机准确、语义信息完整、维护成本低。2.3 架构上的注意事项程序集拆分使用 Source Generator 做装配有一个架构设计需要提前想清楚生成器扫描的是“当前编译单元”也就是说被扫描的类型必须和生成器在同一个程序集里或者通过引用关系可见。FUI 实践中我倾向于一个约定把带特性的页面和 ViewModel 都放在功能模块程序集里框架核心单独一个程序集。每个功能模块程序集都是一个独立的“编译单元”FUI 生成的注册表也是模块级的最后在启动层汇总。这个拆法好处很明显。不同模块可以独立编译、独立测试模块间的引用关系也不会因为生成器的引入而变得更复杂。如果强行把所有页面塞进一个巨型程序集编译时间会暴涨生成器跑一次要嗅探几十万个语法节点反而得不偿失。3. 手写 FUI 编译期装配生成器附核心代码3.1 工程结构与前置条件要实现编译期装配我建议把生成器相关代码独立成一个专门的工程不要塞在框架主工程里。我这边分了三个项目Fui.Core框架核心包含运行时容器、ModuleInitializer 入口、特性定义FuiViewAttribute等Fui.GeneratorsSource Generator 本体引用Microsoft.CodeAnalysis.CSharp目标框架为netstandard2.0Fui.Sample业务工程引用Fui.Core和Fui.Generators生成器工程的目标框架必须是netstandard2.0因为编译器需要把它加载到 Roslyn 的进程里而 Roslyn 在旧版 .NET Framework 环境和现代 .NET 环境都要兼容。 SDK 版本我建议直接用 .NET 8 SDK 配合 Roslyn 4.8这样才能用上IIncrementalGenerator和ForAttributeWithMetadataName这些相对新的 API。3.2 特性定义与语义提取业务侧只需要一个简单特性标注路由名和 ViewModel 类型namespace Fui; [AttributeUsage(AttributeTargets.Class, AllowMultiple false, Inherited false)] public sealed class FuiViewAttribute : Attribute { public string Route { get; } public Type ViewModelType { get; } public FuiViewAttribute(string route, Type viewModelType) { Route route; ViewModelType viewModelType; } }注意这里我把 ViewModel 类型直接放在特性参数里而不是用typeof(FooViewModel).FullName字符串来表示。这样在生成器里就能直接通过语义模型拿到ITypeSymbol不需要再做字符串到类型的解析既可靠又方便做类型校验。之所以强调语义模型是因为 Source Generator 里 “Attribute 参数” 分两种编译期常量字符串、数字、枚举和Type类型。ConstructorArguments里遇到类型的值Value属性仍然是string通常是元数据全名但它对应的ITypeSymbol其实可以通过语义模型从该特性的AttributeData里解析出来我后面会展示具体做法。3.3 增量生成器的实现这里贴上生成器的核心代码。重点不只是把类型找出来而是要用增量管线避免改一个文件导致整个编译生成缓存全部失效。using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; namespace Fui.Generators; [Generator(LanguageNames.CSharp)] public sealed class FuiRegistrationGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates context.SyntaxProvider .ForAttributeWithMetadataName( Fui.FuiViewAttribute, static (node, _) true, static (ctx, _) { var symbol (INamedTypeSymbol)ctx.TargetSymbol; var attr ctx.Attributes[0]; var route attr.ConstructorArguments[0].Value as string; var viewModelType attr.ConstructorArguments[1].Value as string; // 用 viewModelType 对应的 INamedTypeSymbol 做更精确的语义验证 INamedTypeSymbol? viewModelSymbol null; if (viewModelType ! null) { viewModelSymbol ctx.SemanticModel.Compilation.GetTypeByMetadataName(viewModelType); } return new ViewCandidate( symbol, route ?? string.Empty, viewModelSymbol, symbol.Locations.FirstOrDefault()); }) .Where(static c c.Route.Length 0); var combined candidates.Collect(); context.RegisterSourceOutput(combined, static (spc, candidates) { var sb new StringBuilder(); sb.AppendLine(// auto-generated /); sb.AppendLine(namespace Fui.Generated); sb.AppendLine({); sb.AppendLine( internal static partial class FuiRegistry); sb.AppendLine( {); sb.AppendLine( public static global::System.Collections.Generic.Dictionarystring, global::System.Type BuildViewMap()); sb.AppendLine( {); sb.AppendLine( var map new global::System.Collections.Generic.Dictionarystring, global::System.Type(StringComparer.Ordinal);); foreach (var candidate in candidates) { sb.AppendLine($ map[\{candidate.Route}\] typeof({candidate.TypeSymbol.ToDisplayString()});); } sb.AppendLine( return map;); sb.AppendLine( }); sb.AppendLine( }); sb.AppendLine(}); spc.AddSource(FuiGeneratedRegistry.g.cs, SourceText.From(sb.ToString(), Encoding.UTF8)); }); } private sealed record ViewCandidate( INamedTypeSymbol TypeSymbol, string Route, INamedTypeSymbol? ViewModelSymbol, Location? Location); private sealed class ViewCandidateEqualityComparer : IEqualityComparerViewCandidate { public bool Equals(ViewCandidate x, ViewCandidate y) { if (ReferenceEquals(x, y)) return true; if (x is null || y is null) return false; return x.Route y.Route SymbolEqualityComparer.Default.Equals(x.TypeSymbol, y.TypeSymbol) SymbolEqualityComparer.Default.Equals(x.ViewModelSymbol, y.ViewModelSymbol); } public int GetHashCode(ViewCandidate obj) { unchecked { var hashCode obj.Route.GetHashCode(); hashCode (hashCode * 397) ^ SymbolEqualityComparer.Default.GetHashCode(obj.TypeSymbol); return hashCode; } } } }细节上要注意ForAttributeWithMetadataName会自动过滤掉没有对应特性的类型性能比手写SyntaxProvider.CreateSyntaxProvider再手动判断属性高很多。 生成的代码里带// auto-generated /头IDE 就不会对这段代码做大量语法分析干扰也会默认隐藏它。记录类型的Equals如果不自己实现增量缓存会认为每次编译的输入都变化导致生成器频繁重跑。所以如果用了 record必须实现一个基于SymbolEqualityComparer的比较器。Location字段在比较时要忽略因为它只用于报告诊断不影响生成结果。3.4 生成代码长什么样生成器最终产出的代码和手写的注册表几乎一模一样。这是它最大的优势生成结果可读、可验证、可落在磁盘上查看。// auto-generated / namespace Fui.Generated { internal static partial class FuiRegistry { public static global::System.Collections.Generic.Dictionarystring, global::System.Type BuildViewMap() { var map new global::System.Collections.Generic.Dictionarystring, global::System.Type(StringComparer.Ordinal); map[user/profile] typeof(Fui.Sample.Views.ProfileView); map[home/index] typeof(Fui.Sample.Views.HomeView); map[settings/theme] typeof(Fui.Sample.Views.ThemeSettingsView); return map; } } }这个字典是编译期生成的运行时只是把它装载进容器根本不存在“扫描”这个过程。就算有几百个条目也只是几万字节的字符串和类型对象启动成本可以忽略不计。这里还可以进一步优化如果对路由到类型的查找有极致性能要求可以生成一个按字符串哈希分桶的静态数组避免标准字典的开销。但我实测下来几百条路由用普通字典对启动时间几乎无影响除非你的路由表达到几千条才值得做这层优化。写代码这事性能优化一定要基于测量不能靠想象。3.5 初始化钩子与运行时桥接生成器只负责产出注册表它还需要一个运行时入口把它灌进 FUI 容器。这里我用ModuleInitializer它能保证方法在程序集加载后、任何业务代码执行前被调用是编译期装配和运行时容器之间的桥using System.Runtime.CompilerServices; using Fui.Generated; namespace Fui.Sample { internal static class FuiModuleBootstrap { [ModuleInitializer] internal static void RegisterFuiTypes() { var map FuiRegistry.BuildViewMap(); foreach (var pair in map) { FuiCore.Container.RegisterView(pair.Key, pair.Value); } } } }这里有一个很值得注意的工程习惯ModuleInitializer的命名不要叫Initialize这种过于泛化的名字建议带上模块前缀比如RegisterFuiTypes。因为同一个程序集里可能有多个库都提供了[ModuleInitializer]名字太泛容易让调试时的调用栈变得难以分辨。容器侧则可以充分利用编译期已知的 ViewModel 类型直接在RegisterView里调用Activator.CreateInstance或表达式树构造而不是再反射一次构造函数。更好的做法是让生成器直接生成CreateView工厂方法绕开容器反射。到这一步编译期装配的完整链路已经跑通了。启动扫描从“运行时遍历程序集”变成了“加载静态字典”类型安全性和启动性能都有了质的提升。4. 老项目迁移实操三步改完 灰度兜底4.1 第一步引入包里三层如果是老项目迁移最忌讳一口气推翻重来。建议按照“先加包、再加特性、最后切入口”的顺序来改每一步都可以独立编译、独立验证。先把Fui.Generators作为 Analyzer 引入业务工程。在 SDK 风格项目里就是加一个ProjectReferenceProject SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet8.0/TargetFramework LangVersionlatest/LangVersion /PropertyGroup ItemGroup ProjectReference Include..\Fui.Core\Fui.Core.csproj / ProjectReference Include..\Fui.Generators\Fui.Generators.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse / /ItemGroup /ProjectOutputItemTypeAnalyzer是关键它告诉 MSBuild 这个引用不是普通程序集引用而是作为 Roslyn 分析器加载。ReferenceOutputAssemblyfalse表示不把生成器程序集本身加到业务工程的引用里避免污染运行时输出目录。4.2 第二步给类型加特性把原本通过反射注册的页面类逐一加上[FuiView]特性。例如原本通过代码注册的ProfileView改成[FuiView(user/profile, typeof(ProfileViewModel))] public partial class ProfileView : FuiViewBase { public ProfileView(ProfileViewModel viewModel) { InitializeComponent(); BindingContext viewModel; } }这里有一个迁移时容易忽略的细节原来用反射注册时类可以是任意形式但切到特性标记后我建议给 View 类加上partial关键字。虽然当前生成器没有生成这个类的分部方法但保留 partial 可以给未来扩展留出余地比如生成 ViewModel 绑定代码、生成依赖注入代码。同时 partial 对编译结果没有任何副作用。加特性的工作量看着不大但页面多时非常磨人。我当时的做法是写一个小的 Roslyn 分析器配合 Code Fix自动扫描所有继承自FuiViewBase的类并自动补特性。如果没有精力写工具临时用正则批量替换也能凑合但一定要检查路由名是否有重复。4.3 第三步替换启动代码并保留开关改启动逻辑时最安全的方式是保留一个“反射兜底开关”在生成的注册表不可用或遇到极端兼容场景时自动回退到旧逻辑。我当时的做法是用条件编译符号控制public static void InitializeFui() { #if FUI_USE_COMPILED_REGISTRY var map FuiRegistry.BuildViewMap(); FuiCore.Container.RegisterViews(map); #else FuiReflectionRegistry.Initialize(typeof(App).Assembly); #endif }这样改完以后可以先用旧的反射路径跑一遍全量回归确认所有页面正常再打开FUI_USE_COMPILED_REGISTRY重新跑一遍同样的用例。如果两边表现完全一致就可以把旧路径删掉了。实际上在我迁移过程中两套路径跑出来的结果八成是一致的但确实出现过两处差异一是旧反射实现了大小写不敏感的路由匹配而生成器生成的代码用了StringComparer.Ordinal导致个别使用大写路由的调用点找不到页面二是某些页面类型依赖静态构造函数做额外初始化反射路径和直接typeof路径执行时机有细微差别。这些都是在灰度阶段暴露出来的所以我很推荐保留一段时间的双轨运行。4.4 把装配结果导出成诊断文件这是个很实用的小技巧。Source Generator 生成的代码默认不会出现在项目目录下它是编译期临时产生的内容很多开发者根本没见过“它到底生成了什么”。为了排查方便可以在编译时加一个属性让 IDE 把生成文件落盘PropertyGroup EmitCompilerGeneratedFilestrue/EmitCompilerGeneratedFiles CompilerGeneratedFilesOutputPath$(BaseIntermediateOutputPath)GeneratedFiles/CompilerGeneratedFilesOutputPath /PropertyGroup设置完之后编译会生成一个obj/GeneratedFiles/Fui.Generators/Fui.Generators.FuiRegistrationGenerator/FuiGeneratedRegistry.g.cs文件。我强烈建议迁移期保留这个设置改一个特性、编一次译、看一眼生成结果能非常直观地确认生成器是否真的工作。更进一步你可以在生成器里顺手导出一份 JSON 格式的注册表清单方便测试断言和文档生成。JSON 导出不要放到生成代码里而是用context.RegisterPostInitializationOutput之类的机制或者直接在分析器里用文件输出。我这边是把清单打到obj下的临时文件写了个小脚本自动校验重复路由和未注册 ViewModel。5. 我这边实测的数据与隐性收益5.1 启动耗时与内存分配拿我手头一个中等规模的 FUI 应用来说里面大概有 260 个 View 类型、180 个 ViewModel、40 多个服务类型。旧的反射注册方案在 Windows 桌面环境下冷启动扫描耗时约 240 毫秒热启动也有 120 毫秒左右。切到编译期装配以后启动时注册阶段耗时降到了个位数毫秒。原因很简单没有遍历类型没有反射调用只是把一个静态字典初始化好然后塞进容器。内存分配更是大幅下降原来反射扫描会产生大量Type对象、CustomAttributeData、ParameterInfo、MethodBase之类的元数据包装现在这些全部归零。对比数据我列在这里注意这只是一个参考不同机器不同程序集大小差异很大但趋势是一致的指标反射注册编译期装配扫描注册耗时240ms3ms首次页面创建耗时6ms2ms额外内存分配约 18MB约 0.2MB启动阶段 GC 次数15 次1 次第一次页面创建的提速是因为容器拿到的是编译期生成好的类型映射可以直接用工厂函数创建实例不再走Activator.CreateInstance的反射管线。对用户来说最直观的体验就是冷启动首屏明显变快卡顿几乎消失。5.2 对裁剪、AOT 和包体大小的影响反射方案最怕的就是裁剪器。你用一个字符串“动态创建类型”裁剪器根本不知道这个类型被谁引用为了安全它只能全部剪掉于是运行时报MissingMethodException。要保活就得写一堆DynamicDependency特性维护起来很痛苦。编译期装配方案从根本上规避了这个问题。生成代码里用的是typeof(Fui.Sample.Views.ProfileView)这种引用在裁剪器眼里就是普通静态代码引用。裁剪器能清楚地看到类型依赖关系该保留的保留、该剪掉的剪掉不需要任何额外标注。Native AOT 场景下这个优势更是决定性。反射在 AOT 里有大量限制很多动态创建场景直接不可用。而编译期装配生成的代码全是静态调用配合 AOT 编译完全没有障碍。我试过用 PublishAot 发布整个 FUI 应用不再需要额外配置反射保活名单包体还比之前小了不少。5.3 团队协作上的隐性收益这个属于意外收获但我觉得对团队的价值可能比性能提升更大。以前用反射注册新人经常遇到“我明明写了页面为什么导航不到”的问题查半天发现是启动扫描偶发失败或者路由名写错。编译期装配之后注册表是生成出来的路由名写错要么编译警告直接报出来要么在生成代码里一眼就能看出问题。而且代码评审的 diff 更干净了。以前新增页面可能动不动就是十几行注册代码现在业务类上多一行特性就够了评审时注意力能集中在真正的业务逻辑上。另外由于生成器是增量式处理编译期就能把非法映射暴露出来比如重复路由、ViewModel 类型缺失、视图类不是从基类继承等。我后来在生成器里加了不少诊断检查相当于把一部分运行时错误提前到了编译阶段。6. 常见问题与排查技巧实录6.1 生成器没生效怎么定位最常见的症状是加了[FuiView]特性但启动时容器里找不到路由。排查步骤按顺序来先看项目是否真的引用了生成器检查 csproj 里有没有OutputItemTypeAnalyzer的引用打开EmitCompilerGeneratedFiles到 obj 目录看生成文件是否存在如果生成文件存在但内容为空大概率是特性完整名写错或者ForAttributeWithMetadataName的元数据名不匹配如果FuiRegistry类报找不到检查LangVersion和TargetFramework是否支持需要特别注意的是直接清理解决方案再重新编译往往能排除掉 IDE 缓存导致的假象。如果你看到生成文件存在但内容完全没有更新那更可能是增量缓存的问题可以先把 obj 目录删掉再编译验证。6.2 热重载与生成器的冲突Source Generator 和热重载Hot Reload之间存在一个天然的矛盾。生成器在编译时把注册表“焊死”了热重载时只能替换方法体却不能改变类型结构或者重新跑一遍生成器。也就是说你在运行中改了一个页面的路由名热重载不会自动更新注册表。我的实际处理办法是开发期仍然保留一个调试开关允许用反射注册作为热重载的补充路径。只有 Release 构建才强制使用编译期装配。这个策略牺牲了一点开发体验的一致性但换来了热重载的自由度。6.3 一些代码生成过程不得不说的坑生成器抛异常会导致整个编译失败而且错误信息往往不够直观。我在调试生成器时最有效的手段是写一个测试项目直接调用生成器的Initialize方法并断言输出结果而不是每次都开编辑器编译。调试生成器还可以用Debugger.Launch()但这招只适用于本地开发CI 里千万别留。更优雅的方式是给生成器加环境变量开关if (Environment.GetEnvironmentVariable(FUI_GENERATOR_DEBUG) 1) { Debugger.Launch(); }除此之外还要提醒一点生成器代码里不能引用业务工程里的类型它只能和编译符号打交道。我遇到过一次在生成器中想直接new一个业务对象结果编译能过但运行永远拿不到预期结果后来才意识到生成器运行在独立进程里想通信只能通过生成的源码或诊断信息。6.4 第三方库里的类型怎么处理实际项目中总有那么几个类型在引用包或者 NuGet 包里它们不在当前编译单元中。ForAttributeWithMetadataName只能扫到当前编译单元带特性的类型但第三方库里的类型如果也打了[FuiView]当前生成器是看不见的。这种情况需要另外设计桥接机制。我常见的做法是在入口程序集里写一个显式清单特性把第三方类型装配进去[assembly: FuiExternalView(typeof(ThirdParty.Views.LegacyView), legacy/old-page)]生成器额外扫描FuiExternalView这个程序集级特性把它当成普通的[FuiView]一起生成注册表。这样既保住了编译期生成的优势又解决了外部类型不可见的问题。注意外部类型必须通过Compilation.GetTypeByMetadataName去解析而不能靠语义模型直接拿到ITypeSymbol引用。这也是为什么我强烈建议把生成逻辑分成两个管道一个管道扫描当前程序集的[FuiView]另一个管道扫描程序集级的外部映射特性最后合并输出到同一个生成源里。两条管道都走增量管线互不干扰。FuiRegistrationGenerator ├── Pipeline A当前程序集 [FuiView] 特性 ├── Pipeline B程序集级 [FuiExternalView] 特性 └── Collect 合并生成 FuiRegistry在实际落地中Pipeline B 的优先级要高于 Pipeline A。因为外部类型的路由通常需要人工确认冲突时应该报诊断而不是静默覆盖。最后再分享一个小技巧生成器一定要在诊断里带上Location。虽然生成代码时Location不影响产物但当你向开发者报告“路由重复”或者“ViewModel 类型缺失”时如果诊断能精确指向源代码中那个出错的特性质IDE 会直接在错误列表里显示对应文件和行号开发者点进去就能修改。这个体验上的差异直接决定了你的生成器是“好用的工具”还是“给人添堵的黑魔法”。从我个人的实践感受来说从反射注册迁移到 Source Generator最难的部分不是写生成器代码而是改变对“装配时机”的理解。反射让你在任何时刻都能做任何事听起来自由代价是运行期性能和安全性的持续流失编译期装配把自由变成了编译时约束但换来的稳定性、可裁剪性和可调试性是实打实的。如果你也在维护一套 UI 框架或者依赖注入容器遇到类似瓶颈强烈建议往这个方向试一步。