准备 .NET 库以进行剪裁

.NET SDK 让用户可以通过剪裁来减小自包含应用的大小。 剪裁会从应用及其依赖项中移除未使用的代码。 并非所有代码都与剪裁功能兼容。 .NET 6 提供剪裁分析警告来检测可能会使已剪裁的应用发生中断的模式。 本文:

先决条件

.NET 6 SDK 或更高版本。

若要获取最新的剪裁警告和分析器覆盖范围:

  • 安装并使用 .NET 8 SDK 或更高版本。
  • net8.0 或更高版本为目标。

.NET 7 SDK 或更高版本。

若要获取最新的剪裁警告和分析器覆盖范围:

  • 安装并使用 .NET 8 SDK 或更高版本。
  • net8.0 或更高版本为目标。

.NET 8 SDK 或更高版本。

启用库剪裁警告

可以使用以下任一方法在库中找到剪裁警告:

  • 使用 IsTrimmable 属性启用特定于项目的剪裁。
  • 创建使用库的剪裁测试应用并启用测试应用的剪裁。 无需引用库中的所有 API。

建议同时使用两种方法。 特定于项目的剪裁操作简便,可以显示一个项目的多个剪裁警告,但要查看所有警告,就只能依赖于标记有剪裁兼容的引用。 剪裁测试应用虽然增加了工作量,但会显示所有警告。

启用特定于项目的剪裁

在项目文件中设置 <IsTrimmable>true</IsTrimmable>

<PropertyGroup>
    <IsTrimmable>true</IsTrimmable>
</PropertyGroup>

如果将 MSBuild 属性 IsTrimmable 设置为 true,会将程序集标记为“可剪裁”并启用剪裁警告。 “可剪裁”表示项目:

  • 被视为与剪裁功能兼容。
  • 编译时不应生成与剪裁相关的警告。 在经过剪裁的应用中使用时,程序集可以在最终输出中剪裁掉其未使用的成员。

通过 <IsAotCompatible>true</IsAotCompatible> 将项目配置为兼容 AOT 时,该 IsTrimmable 属性默认为 true。 有关详细信息,请参阅 AOT 兼容性分析器

若要在不将项目标记为剪裁兼容的情况下生成剪裁警告,请使用 <EnableTrimAnalyzer>true</EnableTrimAnalyzer> 而不是 <IsTrimmable>true</IsTrimmable>

显示有关测试应用的所有警告

若要显示库的所有分析警告,剪裁器必须分析该库及其使用的所有依赖项的实现。

编译和发布库时:

  • 依赖项的实现不可用。
  • 可用的引用程序集没有足够的信息可供剪裁器确定它们是否与剪裁兼容。

由于依赖项限制,必须创建使用库及其依赖项的自包含测试应用。 测试应用包括剪裁程序在以下情况下对剪裁不兼容发出警告所需的全部信息:

  • 库代码。
  • 库从其依赖项引用的代码。

注意

如果库具有不同的行为(根据目标框架确定),请为每个支持剪裁的目标框架创建剪裁测试应用。 例如,如果库使用条件编译(例如 #if NET7_0)来更改行为。

若要创建剪裁测试应用,请执行以下操作:

  • 创建单独的控制台应用程序项目。
  • 添加对库的引用。
  • 使用以下列表修改与如下所示的项目类似的项目:

如果库面向不可剪裁的 TFM,例如 net472netstandard2.0,则创建剪裁测试应用没有好处。 仅 .NET 6 及更高版本支持剪裁。

  • <TrimmerDefaultAction> 设置为 link
  • 添加 <PublishTrimmed>true</PublishTrimmed>
  • 使用 <ProjectReference Include="/Path/To/YourLibrary.csproj" /> 向库项目添加引用。
  • 使用 <TrimmerRootAssembly Include="YourLibraryName" /> 将库指定为剪裁器根程序集。
    • TrimmerRootAssembly 可确保对库中的每个部分进行分析。 它告诉剪裁器此程序集是“root”。 “root”程序集表示剪裁器会分析库中的每个调用,并遍历源自该程序集的所有代码路径。
  • 添加 <PublishTrimmed>true</PublishTrimmed>
  • 使用 <ProjectReference Include="/Path/To/YourLibrary.csproj" /> 向库项目添加引用。
  • 使用 <TrimmerRootAssembly Include="YourLibraryName" /> 将库指定为剪裁器根程序集。
    • TrimmerRootAssembly 可确保对库中的每个部分进行分析。 它告诉剪裁器此程序集是“root”。 “root”程序集表示剪裁器会分析库中的每个调用,并遍历源自该程序集的所有代码路径。
  • 添加 <PublishTrimmed>true</PublishTrimmed>
  • 使用 <ProjectReference Include="/Path/To/YourLibrary.csproj" /> 向库项目添加引用。
  • 使用 <TrimmerRootAssembly Include="YourLibraryName" /> 将库指定为剪裁器根程序集。
    • TrimmerRootAssembly 可确保对库中的每个部分进行分析。 它告诉剪裁器此程序集是“root”。 “root”程序集表示剪裁器会分析库中的每个调用,并遍历源自该程序集的所有代码路径。

.csproj 文件

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <PublishTrimmed>true</PublishTrimmed>
    <!-- Prevent warnings from unused code in dependencies -->
    <TrimmerDefaultAction>link</TrimmerDefaultAction>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="path/to/MyLibrary.csproj" />
    <!-- Analyze the whole library, even if attributed with "IsTrimmable" -->
    <TrimmerRootAssembly Include="MyLibrary" />
  </ItemGroup>

</Project>
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <PublishTrimmed>true</PublishTrimmed>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyLibrary\MyLibrary.csproj" />
    <TrimmerRootAssembly Include="MyLibrary" />
  </ItemGroup>

</Project>

注意:在前面的项目文件中,使用 .NET 7 时,将 <TargetFramework>net8.0</TargetFramework> 替换为 <TargetFramework>net7.0</TargetFramework>

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <PublishTrimmed>true</PublishTrimmed>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyLibrary\MyLibrary.csproj" />
    <TrimmerRootAssembly Include="MyLibrary" />
  </ItemGroup>

</Project>

更新项目文件后,使用目标运行时标识符 (RID) 运行 dotnet publish

dotnet publish -c Release -r <RID>

对多个库遵循前面的模式。 要同时查看多个库的剪裁分析警告,请将它们作为 ProjectReferenceTrimmerRootAssembly 项全部添加到同一个项目中。 如果任何根库在依赖项中使用不易于剪裁的 API,在使用 ProjectReferenceTrimmerRootAssembly 项将所有库添加到相同的项目时,会发出有关依赖项的警告。 若要查看只与特定库有关的警告,应只引用该库。

注意:分析结果取决于依赖项的实现细节。 更新到新版本的依赖项可能会引入分析警告:

  • 如果新版本添加了非理解的反射模式。
  • 即使无 API 更改。
  • 将库与 PublishTrimmed 一起使用时,引入剪裁分析警告是一项中断性变更。

消除剪裁警告

前面的步骤将生成有关代码的警告,在剪裁的应用中使用这些代码时,可能会导致问题。 下面的示例显示了最常见的警告,并提供相关的修复建议。

RequiresUnreferencedCode

考虑使用 [RequiresUnreferencedCode] 的以下代码,指示指定的方法需要动态访问未通过静态方式引用的代码,例如通过 System.Reflection 进行。

public class MyLibrary
{
    public static void MyMethod()
    {
        // warning IL2026 :
        // MyLibrary.MyMethod: Using 'MyLibrary.DynamicBehavior'
        // which has [RequiresUnreferencedCode] can break functionality
        // when trimming app code.
        DynamicBehavior();
    }

    [RequiresUnreferencedCode(
        "DynamicBehavior is incompatible with trimming.")]
    static void DynamicBehavior()
    {
    }
}

前面高亮显示的代码表明库调用被显式注释为“与剪裁功能不兼容”的方法。 若要消除此警告,请考虑 MyMethod 是否需要调用 DynamicBehavior。 如果需要,请使用 [RequiresUnreferencedCode] 注释调用方 MyMethod;这样会传播警告,使 MyMethod 的调用方可以收到警告:

public class MyLibrary
{
    [RequiresUnreferencedCode("Calls DynamicBehavior.")]
    public static void MyMethod()
    {
        DynamicBehavior();
    }

    [RequiresUnreferencedCode(
        "DynamicBehavior is incompatible with trimming.")]
    static void DynamicBehavior()
    {
    }
}

将属性一路传播到公共 API 后,调用库的应用:

  • 仅针对不可修整的公共方法获取警告。
  • 不要收到 IL2104: Assembly 'MyLibrary' produced trim warnings 等警告。

DynamicallyAccessedMembers

public class MyLibrary3
{
    static void UseMethods(Type type)
    {
        // warning IL2070: MyLibrary.UseMethods(Type): 'this' argument does not satisfy
        // 'DynamicallyAccessedMemberTypes.PublicMethods' in call to
        // 'System.Type.GetMethods()'.
        // The parameter 't' of method 'MyLibrary.UseMethods(Type)' doesn't have
        // matching annotations.
        foreach (var method in type.GetMethods())
        {
            // ...
        }
    }
}

在前面的代码中,UseMethods 调用具有 [DynamicallyAccessedMembers] 要求的反射方法。 该要求说明类型的公共方法可用。 向 UseMethods 参数施加相同的要求来满足此要求。

static void UseMethods(
   // State the requirement in the UseMethods parameter.
   [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
    // ...
}

此时,如果传入的值不满足 PublicMethods 要求,那么对 UseMethods 的任何调用都会生成警告。 类似于 [RequiresUnreferencedCode],向公共 API 传播这类警告后,就算完成了。

在下面的示例中,一个未知的类型流入注释的方法参数中。 未知的 Type 来自字段:

static Type type;
static void UseMethodsHelper()
{
    // warning IL2077: MyLibrary.UseMethodsHelper(Type): 'type' argument does not satisfy
    // 'DynamicallyAccessedMemberTypes.PublicMethods' in call to
    // 'MyLibrary.UseMethods(Type)'.
    // The field 'System.Type MyLibrary::type' does not have matching annotations.
    UseMethods(type);
}

同样,这里的问题在于,字段 type 被传入具有这些要求的参数。 将 [DynamicallyAccessedMembers] 添加到字段中即可解决此问题。 如果代码将不兼容的值分配给字段,[DynamicallyAccessedMembers] 会发出警告。 有时,此过程将一直持续到对公共 API 添加注释为止,在其他时候,当具体类型流入具有这些要求的位置时,这个过程就会结束。 例如:

[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)]
static Type type;

static void UseMethodsHelper()
{
    MyLibrary.type = typeof(System.Tuple);
}

在这种情况下,剪裁分析会保留公共方法 Tuple,并生成进一步的警告。

建议

  • 尽可能避免 反射。 使用反射时,尽量控制反射范围,以便只能从库的一小部分位置访问它。
  • 使用 DynamicallyAccessedMembers 批注代码,以尽可能静态表达剪裁要求。
  • 考虑重新组织代码,使其遵循可分析的模式,可以使用 DynamicallyAccessedMembers进行批注
  • 当代码与剪裁不兼容时,请使用 RequiresUnreferencedCode 对其进行批注,并将此批注传播到调用方,直到对相关的公共 API 进行批注。
  • 避免使用反射方式无法被静态分析理解的代码。 例如,应避免静态构造函数中的反射。 若在静态构造中使用静态方式无法分析的反射,将导致警告传播到类的所有成员。
  • 避免批注虚拟方法或接口方法。 若对虚拟方法或接口方法进行注释,则要求所有替代都具有匹配的注释。
  • 如果 API 大多不兼容剪裁,可能需要考虑 API 的替代编码方法。 常见示例是基于反射的序列化程序。 在这些情况下,请考虑采用其他技术(如源生成器)来生成更易于静态分析的代码。 例如,请参阅如何在 System.Text.Json 中使用源生成

消除有关不可分析的模式的警告

最好使用 [RequiresUnreferencedCode]DynamicallyAccessedMembers(如可能)来表达代码意图,以消除警告。 但在某些情况下,你可能想要为使用无法通过这些属性表示的模式的库或无需重构现有代码的库启用剪裁功能。 本部分介绍了消除剪裁分析警告的一些高级方法。

警告

这些技术可能会更改代码的行为,或者在未正确使用时导致运行时异常。

UnconditionalSuppressMessage

请考虑以下代码:

  • 意向不能用批注表示。
  • 生成警告,但不表示运行时的实际问题。

警告无法禁止显示 UnconditionalSuppressMessageAttribute。 这与 SuppressMessageAttribute 非常类似,但它保留在 IL 中且在剪裁分析过程中被采用。

警告

如果禁止显示警告,应负责根据已知的检查和测试结果正确的不变量来保证代码的剪裁功能兼容性。 请谨慎对待这些注释,因为如果它们不正确,或者代码的不变量发生更改,则最终可能会隐藏真正的问题。

例如:

class TypeCollection
{
    Type[] types;

    // Ensure that only types with preserved constructors are stored in the array
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
    public Type this[int i]
    {
        // warning IL2063: TypeCollection.Item.get: Value returned from method
        // 'TypeCollection.Item.get' can't be statically determined and may not meet
        // 'DynamicallyAccessedMembersAttribute' requirements.
        get => types[i];
        set => types[i] = value;
    }
}

class TypeCreator
{
    TypeCollection types;

    public void CreateType(int i)
    {
        types[i] = typeof(TypeWithConstructor);
        Activator.CreateInstance(types[i]); // No warning!
    }
}

class TypeWithConstructor
{
}

在前面的代码中,已对索引器属性进行注释,使返回的 Type 满足 CreateInstance 的要求。 这已经确保保留了 TypeWithConstructor 构造函数,而且对 CreateInstance 的调用不会发出警告。 索引器资源库注释也确保了存储在 Type[] 中的任何类型都有一个构造函数。 但分析功能无法看到这一点,并且会生成 Getter 警告,因为它不知道返回的类型已保留其构造函数。

如果确定满足要求,可将 [UnconditionalSuppressMessage] 添加到 Getter 来禁止显示此警告:

class TypeCollection
{
    Type[] types;

    // Ensure that only types with preserved constructors are stored in the array
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
    public Type this[int i]
    {
        [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2063",
            Justification = "The list only contains types stored through the annotated setter.")]
        get => types[i];
        set => types[i] = value;
    }
}

class TypeCreator
{
    TypeCollection types;

    public void CreateType(int i)
    {
        types[i] = typeof(TypeWithConstructor);
        Activator.CreateInstance(types[i]); // No warning!
    }
}

class TypeWithConstructor
{
}

需要强调的是,仅当存在可确保反射成员是可见反射目标的注释或代码时,禁止显示警告才有效。 成员是调用、字段或属性访问的目标是不够的。 有时可能会出现这种情况,但随着更多裁剪优化的添加,此类代码最终必然会中断。 对于不可见反射目标的属性、字段和方法,可以对其进行内联、删除其名称、移动到不同的类型,或以中断反射的方式进行优化。 禁止显示警告时,仅允许对在其他地方的裁剪分析器可见的反射目标进行反射。

// Invalid justification and suppression: property being non-reflectively
// used by the app doesn't guarantee that the property will be available
// for reflection. Properties that are not visible targets of reflection
// are already optimized away with Native AOT trimming and may be
// optimized away for non-native deployment in the future as well.
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2063",
    Justification = "*INVALID* Only need to serialize properties that are used by"
                    + "the app. *INVALID*")]
public string Serialize(object o)
{
    StringBuilder sb = new StringBuilder();
    foreach (var property in o.GetType().GetProperties())
    {
        AppendProperty(sb, property, o);
    }
    return sb.ToString();
}

DynamicDependency

[DynamicDependency] 属性可用于指示某个成员动态依赖于其他成员。 这会导致每次保留具有该属性的成员时都会保留被引用的成员,但不会自行消除警告。 与向剪裁分析功能告知代码反射行为的其他属性不同,[DynamicDependency] 仅保留其他成员。 可将此属性与 [UnconditionalSuppressMessage] 一起使用,以修复某些分析警告。

警告

仅当其他方法都不可行时,才使用 [DynamicDependency] 特性作为最终手段。 最好使用 [RequiresUnreferencedCode][DynamicallyAccessedMembers] 来表达反射行为。

[DynamicDependency("Helper", "MyType", "MyAssembly")]
static void RunHelper()
{
    var helper = Assembly.Load("MyAssembly").GetType("MyType").GetMethod("Helper");
    helper.Invoke(null, null);
}

如果没有 DynamicDependency,剪裁可能会从 MyAssembly 中删除 Helper,或者完全删除 MyAssembly(如果没有在其他位置引用它),这会生成警告,指出在运行时可能发生了故障。 该属性确保 Helper 会保留。

该属性指定要通过 stringDynamicallyAccessedMemberTypes 保留的成员。 类型和程序集要么隐含在属性上下文中,要么在属性中显式指定(按照分别表示类型和程序集名称的 Typestring)。

类型和成员字符串使用 C# 文档注释 ID 字符串格式的变体,不带成员前缀。 成员字符串不应包含声明类型的名称,可以省略参数以保留指定名称的所有成员。 以下代码示例显示了一些示例格式:

[DynamicDependency("MyMethod()")]
[DynamicDependency("MyMethod(System,Boolean,System.String)")]
[DynamicDependency("MethodOnDifferentType()", typeof(ContainingType))]
[DynamicDependency("MemberName")]
[DynamicDependency("MemberOnUnreferencedAssembly", "ContainingType"
                                                 , "UnreferencedAssembly")]
[DynamicDependency("MemberName", "Namespace.ContainingType.NestedType", "Assembly")]
// generics
[DynamicDependency("GenericMethodName``1")]
[DynamicDependency("GenericMethod``2(``0,``1)")]
[DynamicDependency(
    "MethodWithGenericParameterTypes(System.Collections.Generic.List{System.String})")]
[DynamicDependency("MethodOnGenericType(`0)", "GenericType`1", "UnreferencedAssembly")]
[DynamicDependency("MethodOnGenericType(`0)", typeof(GenericType<>))]

当方法包含即使借助 DynamicallyAccessedMembersAttribute 也无法分析的反射模式时,适合使用 [DynamicDependency] 属性。