十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

.NET Trimmer 对编译器生成代码的处理:属性传播、数据流分析与 CompilerLoweringPreserve 机制详解

.NET Trimmer 对编译器生成代码的处理:属性传播、数据流分析与 CompilerLoweringPreserve 机制详解 .NET Trimmer 对编译器生成代码的处理属性传播、数据流分析与 CompilerLoweringPreserve 机制详解【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime导读本文基于 compiler-generated-code-handling.md 展开深入讲解 .NET 剪裁工具ILLink/trimmer如何处理async/await、迭代器iterator、lambda 与局部函数local function等由编译器生成的代码compiler-generated code。读者将理解为什么RequiresUnreferencedCode、UnconditionalSuppressMessage、DynamicallyAccessedMembers等开发者书写的特性无法自然传播到MoveNext、闭包类等生成物上.NET 10 引入的[CompilerLoweringPreserve]如何从根源上解决这一问题以及 trimmer 采用哪些启发式与确定性方案来建立用户方法 ↔ 编译器生成成员的映射关系从而正确抑制警告、追踪数据流并输出可读的警告位置。问题的根源编译器生成代码与特性传播的断裂现代编译器为语言特性生成了大量代码不只是纯 IL还包括新类型、新方法、新字段。最典型的例子是 C# 的async/await方法体会被改写进一个独立的、实现状态机的类中。剪裁trimming的大部分逻辑依赖开发者书写的特性这些特性为 trimmer 提供线索尤其是在反射等有问题的场景下参见 reflection-flow.md。问题在于开发者书写的特性不会自动传播到编译器生成的代码中。以下面这段 async 代码为例[RequiresUnreferencedCode (--MethodRequiresUnreferencedCode--)] static void MethodRequiresUnreferencedCode () { } [UnconditionalSuppressMessage (IL2026, )] static async void TestBeforeAwait () { MethodRequiresUnreferencedCode (); await AsyncMethod (); }这段代码本不应产生任何警告因为IL2026已被特性抑制。但实际剪裁时会从另一个方法报出IL2026ILLink: Trim analysis warning IL2026: SuppressWarningsInAsyncCode.TestBeforeAwaitd__1.MoveNext(): Using method MethodRequiresUnreferencedCode() which has RequiresUnreferencedCodeAttribute can break functionality when trimming application code.注意警告来自编译器生成的方法MoveNext位于类TestBeforeAwaitd__1中。UnconditionalSuppressMessage特性没有被传播过去因此从 trimmer 的视角看这是一段完全无关的代码警告自然无法被抑制。方法体级别的特性trimmer 当前识别两类作用于整个方法体的特性RequiresUnreferencedCodeAttribute标记方法不兼容剪裁同时抑制整个方法体内部的剪裁分析警告。例如迭代器场景[RequiresUnreferencedCode (Incompatible with trimming)] static IEnumerableint TestBeforeIterator () { MethodRequiresUnreferencedCode (); yield return 1; }不应产生警告。UnconditionalSuppressMessageAttribute可以作用于很多范围但最小粒度是方法无法针对方法内的某条语句。它用于抑制方法体中的特定警告例如[UnconditionalSuppressMessage(IL2026, )] static async void TestAfterAwait () { await AsyncMethod (); MethodRequiresUnreferencedCode (); }同样不应产生警告。数据流分析相关trimmer 会在单个方法体内执行数据流分析主要跟踪System.Type及相关实例的流动以检测递归的反射用法。DynamicallyAccessedMembersAttribute为System.Type类型的值局部变量、方法参数等打上标注提示 trimmer 该类型的成员会被动态通过反射访问。这种标注目前同样不会传播例如static IEnumerableint TestParameter ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type) { type.GetMethod (BeforeIteratorMethod); yield return 1; type.GetMethod (AfterIteratorMethod); }由于type变量已被正确标注不应产生任何警告。固有数据流intrinsic data flowtrimmer 还内建识别某些模式并围绕其执行数据流分析即使没有标注也能完整分析部分反射用法。例如对typeof关键字的固有处理static IEnumerableint TestLocalVariable () { Type type typeof (TestType); type.GetMethod (BeforeIteratorMethod); yield return 1; type.GetMethod (AfterIteratorMethod); }属性传播的长期方案[CompilerLoweringPreserve]为应对将用户特性传播到编译器生成代码的挑战.NET 10 引入了通用机制[CompilerLoweringPreserveAttribute]。将该特性应用于某个特性类attribute class上即可指示编译器把该特性的应用application从源代码向下流动到编译器生成的符号symbols从而帮助基于 IL 的分析工具。其定义位于 CompilerLoweringPreserveAttribute.cs[AttributeUsage(AttributeTargets.Class, Inherited false)] public sealed class CompilerLoweringPreserveAttribute : Attribute { public CompilerLoweringPreserveAttribute() { } }文档注释中给出的典型例子是 C# 主构造函数primary constructor参数若某个标有CompilerLoweringPreserve的特性应用到主构造函数参数上它也会被应用到编译器生成的、用于保存该参数的字段上。而DynamicallyAccessedMembersAttribute现在就被标记为[CompilerLoweringPreserve]见 DynamicallyAccessedMembersAttribute.cs[CompilerLoweringPreserve] [AttributeUsage( AttributeTargets.Field | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, Inherited false)] sealed class DynamicallyAccessedMembersAttribute : Attribute { public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) MemberTypes memberTypes; public DynamicallyAccessedMemberTypes MemberTypes { get; } }因此当编译器生成新字段或新类型参数时例如局部函数、迭代器/async 状态机、主构造函数参数相关的DynamicallyAccessedMembers标注会被自动应用到生成成员上。剪裁工具可以直接使用生成代码中存在的标注无需再反向推断其与用户代码的映射关系。.NET 10 及以后的依赖策略对于 .NET 10 及更高版本剪裁工具应依赖编译器将DynamicallyAccessedMembersAttribute等特性传播到所有相关的编译器生成代码由[CompilerLoweringPreserve]指示对这些程序集无需使用启发式heuristics。但这一方案并不完美程序集可能由新版本的 Roslyn 编译而新版本可能采用不同的 lowering 策略因此现有的启发式可能对 net10.0 之前程序集的新发布版本失效。文档给出几种缓解措施多重目标multitarget到net10.0使其用新的CompilerLoweringPreserve行为构建从而避开启发式修复启发式使其兼容新 Roslyn 版本产出的代码让剪裁工具检测是否存在带有CompilerLoweringPreserve的 polyfilledDynamicallyAccessedMembersAttribute类型若存在则关闭所在程序集的启发式。另一个问题是.NET 10 库可能以GenerateTargetFrameworkAttributefalse/GenerateTargetFrameworkAttribute构建工具将无法检测 TargetFramework。除了设置GenerateTargetFrameworkAttributetrue/GenerateTargetFrameworkAttribute外上述缓解措施 1 和 2 同样适用于该场景。编译器相关行为由于所有问题都由编译器生成代码引起行为取决于具体使用的编译器。当前文档的主要关注对象是 Roslyn C# 编译器——它是 .NET 代码中使用最广泛的编译器。设计上希望方案能让使用类似模式的其他编译器也受益。预期其他编译器如 F# 编译器可能有其他对 trimmer 有问题的模式这些模式应最终补充进文档但可能需要此处尚未讨论的新解决方案。A 组场景Roslyn 闭包重写closure rewrite的预期行为为了创建带捕获变量的 lambda 方法Roslyn 编译器会生成一个闭包类closure class来保存被捕获的值lambda 方法作为该类上的方法生成。目前编译器不会把特性传播到生成方法上。以下是各子场景的预期行为A1/A2 - 方法体特性与 lambda[RequiresUnreferencedCode (--TestLambdaWithCapture--)] static void TestLambdaWithCapture (int p) { Action a () MethodRequiresUnreferencedCode (p); }[UnconditionalSuppressMessage (IL2026, )] static void TestLambdaWithCapture (int p) { Action a () MethodRequiresUnreferencedCode (p); }trimmer 应因RequiresUnreferencedCode/抑制特性而抑制 lambda 内部的剪裁分析警告。C# 10 起可以直接在 lambda 上加特性特性只在 lambda 自身没有该特性时才被传播。开放问题 Q1a方法体特性是否应该传播到 lambda也许应只依赖 C# 10 的显式特性。A3/A4/A5 - 数据流标注、固有数据流与泛型参数进入 lambdastatic void TestParameterInLambda ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type) { Action a () { type.GetMethod (InLambdaMethod); }; }static void TestLocalVariableInLambda () { Type type typeof (TestType); Action a () { type.GetMethod (InLambdaMethod); }; }static void TestGenericParameterInLambda[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T () { Action a () { typeof (T).GetMethod (InLocalMethod); }; }trimmer 应能把标注从参数、固有数据流、泛型参数流入 lambda 的闭包中从而避免产生警告。A6/A7 - 方法体特性与局部函数[RequiresUnreferencedCode (--TestLocalFunctionWithNoCapture--)] static void TestLocalFunctionWithNoCapture () { LocalFunction (); void LocalFunction() { MethodRequiresUnreferencedCode (); } }[UnconditionalSuppressMessage (IL2026, )] static void TestLocalFunctionWithNoCapture () { LocalFunction (); void LocalFunction () { MethodRequiresUnreferencedCode (); } }trimmer 可以把RequiresUnreferencedCode/警告抑制传播到局部函数——除非该函数已有相应特性。开放问题 Q1b方法体特性是否应该传播到局部函数由于可以手动给局部函数加特性也许应直接依赖这一点。A8/A9/A10 - 数据流进入局部函数static void TestParameterInLocalFunction ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type) { LocalFunction (); void LocalFunction () { type.GetMethod (InLocalMethod); } }static void TestLocalVariableInLocalFunction () { Type type typeof (TestType); LocalFunction (); void LocalFunction () { type.GetMethod (InLocalMethod); } }static void TestGenericParameterInLocalFunction[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T () { LocalFunction (); void LocalFunction () { typeof (T).GetMethod (InLocalMethod); } }分别与 A3/A4/A5 相同参数标注、固有数据流、泛型参数标注都应流入局部函数。B 组场景Roslyn 迭代器重写的预期行为C# 编译器会整体重写方法体返回枚举、使用yield return的迭代器会把整个方法体移到独立类中。这与闭包问题类似但由于语法不同带来额外挑战。B1/B2 - 方法体特性与迭代器[RequiresUnreferencedCode (--TestAfterIterator--)] static IEnumerableint TestAfterIterator () { yield return 1; MethodRequiresUnreferencedCode (); }[UnconditionalSuppressMessage (IL2026, )] static IEnumerableint TestBeforeIterator () { MethodRequiresUnreferencedCode (); yield return 1; }特性应作用于整个方法体从而抑制剪裁分析警告——即使方法体被编译器拆分到不同方法中。B3/B4/B5 - 数据流贯穿迭代器体static IEnumerableint TestParameter ([DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] Type type) { type.GetMethod (BeforeIteratorMethod); yield return 1; type.GetMethod (AfterIteratorMethod); }static IEnumerableint TestLocalVariable () { Type type typeof (TestType); type.GetMethod (BeforeIteratorMethod); yield return 1; type.GetMethod (AfterIteratorMethod); }static IEnumerableint TestGenericParameter[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T () { typeof (T).GetMethod (BeforeIteratorMethod); yield return 1; typeof (T).GetMethod (AfterIteratorMethod); }方法参数的数据流标注、固有标注、泛型参数标注都应贯穿整个方法体。C 组场景Roslyn async 重写的预期行为与迭代器类似使用async/await的方法体也会被编译器整体重写。C1/C2 - 方法体特性与 async 体[RequiresUnreferencedCode (--TestAfterAwait--)] static async void TestAfterAwait () { await AsyncMethod (); MethodRequiresUnreferencedCode (); }[UnconditionalSuppressMessage(IL2026, )] static async void TestBeforeAwait() { MethodRequiresUnreferencedCode (); await AsyncMethod (); }特性应作用于整个方法体并抑制剪裁分析警告即使方法体被拆分到不同方法。分别与 B1、B2 非常相似。C3/C4/C5 - 数据流贯穿 async 体static async void TestParameter ([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type) { type.GetMethod (BeforeAsyncMethod); await AsyncMethod (); type.GetMethod (AfterAsyncMethod); }static async void TestLocalVariable () { Type type typeof (TestClass); type.GetMethod (BeforeAsyncMethod); await AsyncMethod (); type.GetMethod (AfterAsyncMethod); }static async void TestGenericParameter[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicMethods)] T () { typeof (T).GetMethod (BeforeIteratorMethod); await AsyncMethod (); typeof (T).GetMethod (AfterIteratorMethod); }与 B3/B4/B5 非常相似参数标注、固有标注、泛型参数标注都应贯穿整个方法体。D 组场景闭包类与方法命名的行为当 Roslyn 生成闭包并把方法中的代码移入闭包方法时剪裁工具应具备足够信息对警告的来源提供正确描述如果存在警告。D1 - Lambda 方法static void TestInLambda () { Action a () MethodRequiresUnreferencedCode (); // 警告应指向这一行 }在存在符号PDB的情况下警告应指向源文件位置不应包含类似c.TestInLambdab__1_0()的方法名。期望输出Source.cs(3,22): Trim analysis warning IL2026: Using method MethodRequiresUnreferencedCode() which has RequiresUnreferencedCodeAttribute can break functionality when trimming application code.所有源自剪裁分析的警告同理。开放问题 Q2a当程序集没有符号时是否应为 lambda 显示更好的名称D2 - 局部函数static void TestInLocalFunction () { LocalFunction (); void LocalFunction() { MethodRequiresUnreferencedCode (); // 警告应指向这一行 } }与 lambda 一样若有符号则警告指向源位置不包含编译器生成名类似Type.TestInLocalFunctiong__LocalFunction|2_0()。期望输出Source.cs(7,9): Using method MethodRequiresUnreferencedCode() which has RequiresUnreferencedCodeAttribute can break functionality when trimming application code.开放问题 Q2b当程序集没有符号时是否应为局部函数显示更好的名称D3 - 迭代器static IEnumerableint TestIterator () { MethodRequiresUnreferencedCode (); // 警告应指向这一行 yield return 1; }trimmer 应把警告指向源位置避免包含编译器生成名TestIteratord__3.MoveNext()。期望输出Source.cs(3,5): Using method MethodRequiresUnreferencedCode() which has RequiresUnreferencedCodeAttribute can break functionality when trimming application code.开放问题 Q2c当程序集没有符号时是否应为迭代器方法显示更好的名称D4 - Asyncstatic async void TestAsync () { MethodRequiresUnreferencedCode (); await AsyncMethod (); }同样警告应指向源位置不包含编译器生成名TestAsyncd__4.MoveNext()。期望输出Source.cs(3,5): Using method MethodRequiresUnreferencedCode() which has RequiresUnreferencedCodeAttribute can break functionality when trimming application code.开放问题 Q2d当程序集没有符号时是否应为 async 方法显示更好的名称E 组场景数据流分析报告用户可见的名称trimmer 的数据流分析会报告指向数据值来源如方法参数、字段等的警告。有了编译器生成的闭包类后警告可能出现的位置是立即值从闭包类的字段中读取之处而该字段是编译器生成的。理想情况下警告应指向该字段值的用户代码中可见的实际来源。E1lambdastatic void TestWarningInLambda (Type typeParameter)内() typeParameter.GetMethod (InLambdaMethod)应把警告指向typeParameter作为未标注值的来源E2局部函数TestWarningInLocalFunctionTInput内typeof(TInput).GetMethod (InLambdaMethod)应把警告指向TInputE3迭代器TestWarning(Type typeParameter)内typeParameter.GetMethod (InIteratorMethod)应指向typeParameterE4asyncTestWarningTInput()内typeof (TInput).GetMethod (InAsyncMethod)应指向TInput。推荐解决方案解决方案需要解决两个问题抑制传播suppression propagation如何把警告抑制和RequiresUnreferencedCode抑制从用户方法传播到该调用的所有编译器生成的方法/类型数据流分析data flow analysis如何跨用户方法体及所有编译器生成的方法、类型、字段执行数据流分析。两种情况的第一步都是对给定用户方法找出 IL 中用于实现该方法的所有编译器生成项包括新类型——例如闭包类型新方法——例如闭包类型上的方法或父类型上为局部函数生成的方法新字段——闭包类型上的字段新泛型参数——若用户方法是泛型的闭包类型和方法也可能带泛型。为正确处理警告抑制trimmer 需要把因所有编译器生成项产生的警告视同直接来自用户方法体施加相同的抑制。为正确处理数据流分析trimmer 需要把值跨所有编译器生成项跟踪如同它们是一个整体单元——这几乎必然导致跨方法数据流分析。跨方法分析代价高昂因此把分析限制在某个用户方法的编译器生成代码范围内几乎必不可少。此外trimmer 还需要跟踪值穿过局部变量、方法参数和闭包类型字段的过程。长期方案需要编译器在 IL 中打标记目前判断某个用户方法用到了哪些编译器生成项仍然比较棘手IL 中没有决定性的标记让 trimmer 能自信地对上述所有情况确定该信息。好的长期方案需要编译器在 IL 中产出某种标记使静态分析工具能可靠地检测所有编译器生成项。这个诉求可以描述为对给定用户方法能够确定编译器为把该方法的功能生成到 IL 程序集中而使用的所有项方法、字段、类型、IL 代码。这些应当是从用户代码直接生成的东西不应包含编译器辅助设施helpers及其他可能需要的、但不能直接归因于用户代码的基础设施。这足以实现抑制传播和数据流分析两种方案。对于DynamicallyAccessedMembersAttribute长期方案正是前文所述的[CompilerLoweringPreserve]——它指示 Roslyn 把DynamicallyAccessedMembers标注传播到编译器生成代码。短期方案一基于启发式Heuristic的方案没有编译器标记时trimmer 无法 100% 可靠地实现所需功能但可以实现相当好的近似。该近似必然对分析代码所用编译器做出假设短期方案中Roslyn C# 编译器优先级最高因为它是产出被分析 IL 最常见的编译器其次才是 F# 编译器。实现抑制传播更简单因此应先行实现。检测某个用户方法编译器生成代码的大致思路检测编译器生成代码通过检测在给定语言中非法的标识符实现。对 C#编译器生成项标识符使用和字符对 F#这一角色由字符承担用户方法引用的任何编译器生成项按上述方法检测都被视为属于该用户方法。优点可以处理所有警告抑制场景——async、迭代器、lambda 和局部函数不仅限于 Roslyn 生成的 IL。缺点属于启发式可能判断错误导致 trimmer 报告的警告少于应报的数量实现复杂主要源于反射访问代码的问题——例如闭包类仅因反射访问如DynamicallyAccessedMemberType.All被标记而实际用户方法未被标记时没有好办法确定用户方法以推断抑制上下文同样可能导致少报警告。短期方案二基于特性的确定性部分方案对于状态机Roslyn 编译器目前会在用户方法上生成IteratorStateMachineAttribute、AsyncStateMachineAttribute和AsyncIteratorStateMachineAttribute特性特性指向生成的状态机类型。这可以用来完全确定性地找出状态机代码与用户方法之间的映射。优点确定性且可靠——只要 Roslyn 生成这些特性这被认为是内部行为trimmer 中抑制的实现相对简单。缺点属于部分方案——只适用于 async 和迭代器方法不适用于 lambda 和局部函数。推荐采用基于特性的确定性方案它没有反射访问问题这个问题非常麻烦。它不解决 lambda 和局部函数这一点有合理变通开发者可以手动给它们添加特性来标注。这会在分析器analyzer和 trimmer 之间造成差异分析器不会有此问题但这是 .NET 6 可接受的行为。仓库中的实现印证CompilerGeneratedState启发式映射的落地实现CompilerGeneratedState.cs 是当前实现的核心注释明确写着Currently this is implemented using heuristics即目前用启发式实现。它维护多张映射表_compilerGeneratedTypeToUserCodeMethod编译器生成类型状态机→ 用户代码方法_compilerGeneratedMethodToUserCodeMethod编译器生成方法lambda/局部函数→ 用户方法_cachedTypeToCompilerGeneratedMembers按类型缓存方法 → 其编译器生成成员列表。关键方法TryGetStateMachineType第 85-107 行正是前文确定性部分方案的实现遍历方法自定义特性在System.Runtime.CompilerServices命名空间下匹配AsyncIteratorStateMachineAttribute、AsyncStateMachineAttribute、IteratorStateMachineAttribute三个特性名从构造参数中取出状态机类型。识别编译器生成名的辅助逻辑通过CompilerGeneratedNames.IsStateMachineOrDisplayClass、IsLambdaDisplayClass、IsLambdaOrLocalFunction完成调用点散布于 CompilerGeneratedState.cs 与 CompilerGeneratedCallGraph.cs并通过CompilerGeneratedCallGraph建立调用图把嵌套函数和状态机关联回用户方法。IsHoistedLocal第 58-71 行用于判断闭包/状态机类型上的字段是否为被提升的局部变量hoisted local其中状态机的current字段因跟踪成本高而被排除。TryGetOwningMethodForCompilerGeneratedMember第 561-599 行实现了 D/E 组场景所要求的从编译器生成成员回溯到用户方法的能力——对状态机类型/成员映射回状态机方法对 lambda 和局部函数映射回用户代码中的宿主方法。与[CompilerLoweringPreserve]的联动第 539-545 行 展示了 .NET 10 长期方案的接入点当DisableGeneratedCodeHeuristics开启且程序集目标框架版本 ≥ 10.0 时仍会运行现有逻辑做覆盖率验证帮助发现 bug但不使用其结果——因为此时编译器已通过CompilerLoweringPreserve把标注传播到了生成代码中无需再依赖启发式映射。该开关对应 trimmer 命令行参数--disable-generated-code-heuristics在 Driver.cs 中解析并写入LinkContext.DisableGeneratedCodeHeuristics--disable-generated-code-heuristics bool测试方面CompilerGeneratedTypes.cs 使用[SetupLinkerArgument(--disable-generated-code-heuristics)]结合[ExpectedNoWarnings]验证新行为其Main覆盖了迭代器、async、闭包、嵌套 lambda/局部函数、嵌套 async 等大量组合场景如BasicIteratorstring、LambdaInsideIteratorint、CapturingLambdaInsideIteratorint、LambdaInsideAsyncint、NestedAsyncLambda.Testint、PartialAsyncMethodWithLambda.Test()等。总结与实操建议对库作者与开发者以下几点最具实用价值理解警告来源看到Methodd__N.MoveNext()、c.Methodb__M_K()这类编译器生成名时先回到用户源码定位对应方法再判断是否缺少标注或抑制善用方法体特性RequiresUnreferencedCode与UnconditionalSuppressMessage能抑制整个方法体含编译器拆分出的状态机代码是处理 async/迭代器方法内反射的最直接手段为 lambda/局部函数显式加标注由于确定性方案不覆盖它们手动在 lambda 或局部函数上加DynamicallyAccessedMembers等特性是最可靠的变通方式.NET 10 优先靠编译器传播对net10.0及以后trimmer 依赖 Roslyn 通过[CompilerLoweringPreserve]传播DynamicallyAccessedMembers因此尽量多重目标到net10.0、保持GenerateTargetFrameworkAttributetrue可避开对旧启发式的依赖开启新行为验证可通过--disable-generated-code-heuristics配合[ExpectedNoWarnings]测试验证程序集在 .NET 10 下不再依赖启发式也能正确分析。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表