Unity 托管代码剥离与Linker

这个周在引入新的插件进入项目后,本地调试是正常的,但是一大包出来就会丢失一些文件,查了半天,发现是因为该文件没有被直接引用,是通过ScriptableObject.CreateInstance(typeName)这种形式动态创建的,所以最后打包之后被代码裁剪的过程去掉了。

解决方案也比较简单,在Assets目录下加一个link.xml,然后声明该程序集不可被裁减就好。

但是回过头来,我们还是要看一下,这个代码裁剪的逻辑以及为什么link.xml可以避免代码被裁减。

托管代码剥离

在构建过程中,Unity会通过托管代码剥离移除没有使用或者无法访问的代码,这样可以显著降低最终产物的大小。托管代码剥离会从托管程序集中进行代码移除,这个包括项目中C#代码所在程序集,插件或者第三方包所在程序集以及.NET Framework所在程序集。

Unity使用一个叫做Unity Linker的工具去进行静态的代码检测,静态分析能够识别任何类、类的部分、函数的部分或者在执行过程中无法到达的函数的部分。这个检测只会对构建时就存在的代码进行,运行时动态生成的代码并不会被检测到。

配置代码裁剪

我们可以通过使用Managed Stripping Level来控制Unity执行代码裁剪的程度,为了避免Unity移除我们特别指定的某些部分代码,我们可以使用某种注释的方式来声明哪一部分应该被Unity Linker保留。

我们可以在Player Settings中修改代码裁剪的级别,可选的值有以下几种:

  • Disabled:Unity不做任何的代码裁剪。这个配置只在我们使用Mono的时候可以选择,并且是默认配置
  • Minimal:Unity只会检测UnityEngine和.NET的代码。并不会移除任何用户代码。这个配置最不可能会导致意料之外的运行时表现,此配置多用于可用性优先级高于包体积的产品中。此配置是IL2CPP的默认配置
  • Low:Unity搜索部分用户程序集以及所有的UnityEngine和.NET代码。此设置应用一组规则,删除一些未使用的代码,但最大限度地减少出现意外后果的可能性,例如使用反射的运行时代码的行为变化。
  • Medium:Unity 部分地搜索所有程序集以查找无法访问的代码。此设置应用一组规则,该规则去除更多类型的代码模式,以减少生成大小。尽管 Unity 不会去掉所有可能的无法访问的代码,但此设置确实增加了不希望或意外行为更改的风险。
  • High:Unity对所有的程序集进行最广泛的检测,Unity 优先考虑缩小代码的大小,而不是代码的稳定性,并且尽可能多地删除代码。

通过注解的方式保留代码

我们可以通过注解的方式避免Unity Linker去裁剪我们制定的部分代码。这在我们的应用会产生运行时代码的时候是有用的,例如我们的代码里用到了reflection。注释要么为 Unity 链接器提供一般指导,告诉它不应该去掉哪些代码模式,要么告诉它不要去掉特定的、已定义的代码段

可以使用两种主要方法对代码进行注释,以便在托管代码剥离过程中保存代码:

  • 根注释(Root annotations)将代码的某些部分标识为根。Unity 链接器不会去掉任何标记为根的代码。根注释使用起来不那么复杂,但是也会导致 Unity 链接器保留一些应该去掉的代码
  • 注释定义了代码元素之间的连接。与根注释相比,依赖项注释可以减少代码过度保存的数量

这些技术中的每一个都提供了对 Unity 链接器在更高剥离级别剥离的代码量的更多控制,并减少了重要代码被剥离的机会。当您的代码通过反射引用其他代码时,注释特别有用,因为 Unity 链接器不能总是检测反射的使用。

Root Annotations

根注释强制Unity Linker把代码元素当作根元素,这样改代码就不会被裁减。

我们有两种方式将一段代码标记为根元素,两种方式的选择取决于你是否想要保留程序集的构造函数或者单个类型

  • Preserve Attribute : 将单个类型作为根元素进行保留
  • Link.xml : 将单整个程序集以及被改程序集引用的其他程序集都作为根来进行保留

使用Preserve属性来标记根

使用 Preserve 属性从 Unity 链接器的静态分析中单独排除代码的特定部分。若要使用此属性对代码段进行注释,请在要保留的代码的第一部分之前立即添加[ Preserve ]。下面的列表描述了当您使用[ Preserve ]属性注释不同的代码元素时,Unity 链接器保留的实体

  • Assembly: Preserves all types that are used and defined in the assembly. To assign the [Preserve] attribute to an assembly, place the attribute declaration in any C# file included in the assembly, before any namespace declarations.
  • Type: Preserves a class or type and its default constructor.
  • Method: Preserves the method, the type that declares the method, the type the method returns, and the types of all of its arguments.
  • Property: Preserves the property, the type that declares the property, the value type of the property, and methods that get and set the property value.
  • Field: Preserves the field, the field type, and the type that declares the field.
  • Event: Preserves the event, the type that declares the event, type, the type the event returns, the [add] accessor, and the [remove] accessor.
  • Delegate: Preserves the delegate type and all methods that the delegate invokes.

如果你想同时保留一个类型和它的缺省构造函数,可以使用[ Preserve ]属性。如果希望保留其中一个,但不希望同时保留两个,请使用 link.xml 文件

使用XML文件来标记

在项目中可以使用名称为 link.xml 的 xml 文件,以保留特定程序集或程序集部分的列表。Xml 文件必须出现在项目中的 Assets 文件夹或 Assets 文件夹的子目录中,并且必须在文件中包含 < linker > 标记。Unity 链接器将 link.xml 文件中保存的任何程序集、类型或成员视为根类型。

您可以在项目中使用任意数量的 link.xml 文件。因此,您可以为每个插件提供单独的保存声明。不能在Package中包含 link.xml 文件,但是可以从非Package的 link.xml 文件中引用Package程序集。

下面贴一个官方的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<linker>
<!--Preserve types and members in an assembly-->
<assembly fullname="AssemblyName">
<!--Preserve an entire type-->
<type fullname="AssemblyName.MethodName" preserve="all"/>

<!--No "preserve" attribute and no members specified means preserve all members-->
<type fullname="AssemblyName.MethodName"/>

<!--Preserve all fields on a type-->
<type fullname="AssemblyName.MethodName" preserve="fields"/>

<!--Preserve all fields on a type-->
<type fullname="AssemblyName.MethodName" preserve="methods"/>

<!--Preserve the type only-->
<type fullname="AssemblyName.MethodName" preserve="nothing"/>

<!--Preserving only specific members of a type-->
<type fullname="AssemblyName.MethodName">

<!--Fields-->
<field signature="System.Int32 FieldName" />

<!--Preserve a field by name rather than signature-->
<field name="FieldName" />

<!--Methods-->
<method signature="System.Void MethodName()" />

<!--Preserve a method with parameters-->
<method signature="System.Void MethodName(System.Int32,System.String)" />

<!--Preserve a method by name rather than signature-->
<method name="MethodName" />

<!--Properties-->

<!--Preserve a property, it's backing field (if present),
getter, and setter methods-->
<property signature="System.Int32 PropertyName" />

<property signature="System.Int32 PropertyName" accessors="all" />

<!--Preserve a property, it's backing field (if present), and getter method-->
<property signature="System.Int32 PropertyName" accessors="get" />

<!--Preserve a property, it's backing field (if present), and setter method-->
<property signature="System.Int32 PropertyName" accessors="set" />

<!--Preserve a property by name rather than signature-->
<property name="PropertyName" />

<!--Events-->

<!--Preserve an event, it's backing field (if present), add, and remove methods-->
<event signature="System.EventHandler EventName" />

<!--Preserve an event by name rather than signature-->
<event name="EventName" />

</type>
</assembly>
</linker>

XML的属性

link.xml文件的<assembly>标签有三个属性可以让我们更加精细的控制代码裁剪

  • ignoreIfMissing: Use this attribute if you need to declare preservations for an assembly that doesn’t exist during all Player builds.
  • ignoreIfUnreferenced: In some cases, you might want to preserve entities in an assembly only when that assembly is referenced by another assembly. Use this attribute to preserve the entities in an assembly only when at least one type is referenced in an assembly.
  • windowsruntime: When you define preservations for a Windows Runtime Metadata (.winmd) assembly, you must add the windowsruntime attribute to the <assembly>element in the link.xml file:

Dependency Annotations

依赖项注释定义了不同代码元素之间的依赖关系。这些注释对于保留 Unity 链接器不能静态分析的代码模式(比如反射)很有用。这些注释还确保当没有根元素使用这些代码元素时,不会错误地保留这些代码元素。有两种方法可以用来改变 Unity 链接器处理代码元素的方式:

  • Annotation attributes: 这些属性表明 Unity 链接器应该保留特定的代码模式,例如从注释类型派生的任何类型。
  • AlwaysLinkAssemblyAttribute: 使用此属性指示 Unity 链接器应处理程序集,即使该程序集没有被项目中的任何其他程序集引用。

Annotation attributes

[ Preserve ]属性对于总是需要 API 的情况非常有用。其他属性可以用于更一般的保存。例如,您可以通过使用 RequreComplementorsAttribute 对接口进行注释来保留实现特定接口的所有类型。

要注释特定的编码模式,请使用下列一个或多个属性:

  • RequireImplementorsAttribute: marks all types that implement this interface as dependencies.
  • RequireDerivedAttribute: marks all types that derive from this type as dependencies.
  • RequiredInterfaceAttribute: marks interface implementations on types as dependencies.
  • RequiredMemberAttribute: marks all members of a type as dependencies.
  • RequireAttributeUsagesAttribute: marks custom attributes as dependencies.

AlwaysLinkAssembly Attribute

[ AlwaysLinkAssembly ]属性强制 Unity 链接器搜索程序集,而不管该程序集是否被生成中包含的另一个程序集引用。只能将 AlwaysLinkAssembly 属性应用于程序集。

该属性不直接保存程序集内的代码。相反,此属性指示 Unity 链接器将根标记规则应用于程序集。如果没有代码元素匹配程序集的根标记规则,Unity 链接器仍然会从生成中删除程序集

对包含一个或多个具有[ RuntimeInitializeOnLoadMethod ]属性但可能不包含在任何场景中直接或间接使用的类型的预编译或包程序集使用此属性

Unity Linker (Unity 链接器)

Unity 构建过程使用一个名为 Unity 链接器的工具来剥离托管代码。Unity 链接器是定制的用于 Unity 的 IL 链接器的一个版本。自定义 Unity 引擎的 Unity 链接器的特定部分不公开可用。

Unity 链接器负责托管代码剥离和引擎代码剥离过程的一部分,后者是通过 IL2CPP 提供的一个独立过程。

链接器是如何工作的

Unity 链接器分析项目中的所有程序集。首先,它标记根类型、方法、属性和字段。例如,添加到 GameObjects 中的 MonoBehavior 派生类在一个场景中是根类型。然后 Unity 链接器分析它标记的根以标识,并标记这些根所依赖的任何托管代码。在完成这个静态分析后,任何剩余的未标记代码都不能通过应用程序代码的任何执行路径访问,Unity 链接器会从程序集中删除它。

Unity 编辑器创建一个程序集列表,其中包含在 Unity 项目的任何场景中使用的类型,并将该列表传递给 Unity 链接器。然后 Unity 链接器处理这些程序集、这些程序集的任何引用、在 link.xml 文件中声明的任何程序集以及具有 AlwaysLinkAssembly 属性的任何程序集。通常,Unity 链接器不会处理项目中包含的不属于这些类别的程序集,并将它们排除在播放器构建之外。

对于 Unity 链接器处理的每个程序集,它都遵循一组基于程序集分类、程序集是否包含场景中使用的类型以及为生成选择的托管剥离级别的规则。

就本规则而言,程序集分为以下类别:

  • .NET Class Library assemblies — Includes the Mono class libraries such as mscorlib.dll and System.dll, as well as .NET class library facade assemblies like netstandard.dll.
  • Platform SDK assemblies — Includes the managed assemblies specific to a platform SDK. For example, the windows.winmd assembly that is part of the Universal Windows Platform SDK.
  • Unity Engine Module assemblies — Includes the managed assemblies that make up the Unity Engine, such as UnityEngine.Core.dll.
  • Project assemblies — Includes the assemblies specific to a project such as:
    • Script assemblies such as Assembly-CSharp.dll
    • Precompiled assemblies
    • Assembly Definition Assemblies
    • Package assemblies

当您在 Unity 中构建项目时,构建过程将您的 C # 代码编译为。NET 字节码格式,称为通用中间语言(CIL)。Unity 将这个 CIL 字节码打包成称为程序集的文件。那个。NET 框架库和项目中使用的插件中的任何 C # 库也被预先打包为 CIL 字节码的程序集。

当Unity链接器执行其静态分析时,它遵循一组规则来确定 CIL 字节码的哪些部分被 Unity 链接器标记为构建所必需的。根标记(Root Annotations)规则确定 Unity 链接器如何标识和保留构建中的顶级程序集。依赖性标记(Dependency Annotations)规则确定 Unity 链接器如何标识和保留根程序集所依赖的任何代码。

具体的那些代码会被作为根,哪些代码会被作为依赖可以看一下官方文档:https://docs.unity3d.com/Manual/unity-linker.html

Link.xml文件的feature tag

XML 文件支持一个不常用的“特性”XML 属性。在本例中,嵌入在 mscolib.dll 中的 mscolib.xml 文件使用了这个属性,但是您可以在适当的时候在任何 link.xml 文件中使用它。

当你使用高级剥离级别时,Unity 链接器会排除那些基于当前版本设置不支持的特性的保留:

  • Remoting ー在针对 IL2CPP 脚本后端时排除
  • Sre ー在针对 IL2CPP 脚本后端时排除。
  • COM ー在瞄准不支持 COM 的平台时排除。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<linker>

<assembly fullname="Foo">

<type fullname="Type1">

<!--Preserve FeatureOne on platforms that support COM-->

<method signature="System.Void FeatureOne()" feature="com"/>

<!--Preserve FeatureTwo on all platforms-->

<method signature="System.Void FeatureTwo()"/>

</type>

</assembly>

</linker>

修改Method的代码

当您使用 High 剥离级别时,Unity 链接器编辑方法体以进一步减少代码大小。本节总结了 Unity 链接器对方法主体所做的一些值得注意的编辑。

Unity Linker只会对.NET 类库程序集中的方法主体进行编辑。方法体编辑后,程序集的源代码不再与程序集中已编译的代码匹配,这会增加调试的难度。

下面的列表描述 Unity 链接器可以执行的编辑方法主体的操作:

  • 删除不可到达的分支-Unity 链接器删除检查 System 的 If 语句块。环境。平台,并且当前目标平台无法访问。
  • 只访问字段的内联方法—— Unity 链接器替换对获取或设置字段的方法的调用,这些方法可以直接访问字段。这通常使得完全去除该方法成为可能。当您使用 Mono 后端时,Unity 链接器仅在允许方法的调用方根据字段的可见性直接访问该字段时才进行此更改。对于 IL2CPP,可见性规则不适用,因此 Unity 链接器会在适当的地方进行此更改。
  • 返回常量值的内联方法-Unity 链接器内联调用只返回常量值的方法。
  • 删除空的不返回调用—— Unity 链接器删除对空的并且返回类型为 void 的方法的调用。
  • 删除空作用域-Unity 链接器在 Last 块为空时删除 Try/Finally 块。删除空调用可以创建空的 Finally 块。如果在方法编辑期间发生这种情况,Unity 链接器将删除整个 Try/Finally 块。可能发生这种情况的一个场景是,编译器为了调用 Dispose ()而在 foreach 循环中生成 Try/Finally 块。