接 上一篇 继续了解,重点是 IL2CPP。
一、Unity 的脚本后端
Unity 使用开源 .NET 平台,以确保使用 Unity 创建的应用程序可以跨平台运行。
脚本后端(scripting backend),可以理解为 脚本运行时环境。
Unity 目前具有两个脚本后端 Mono 和 IL2CPP (Intermediate Language To C++)、
它们各自使用不同的编译技术:
Mono 使用即时 (JIT) 编译,在运行时按需编译代码。
IL2CPP 使用提前 (AOT) 编译,在运行之前编译整个应用程序。
使用 Mono 时,在默认情况下会禁用代码剥离,但无法为 IL2CPP 禁用代码剥离。
Mono 和 IL2CPP 后端 均使用 Boehm 垃圾回收器,默认情况下使用增量模式。
Mono 和 IL2CPP 会在内部缓存所有 C# 反射 (System.Reflection) 对象,并且不会对它们进行垃圾收集。此行为的结果是垃圾回收器在应用程序生命周期内持续扫描缓存的 C# 反射对象,这会导致不必要和潜在的大量垃圾回收器开销。
JIT 与 AOT 简单对比:
JIT 在运行时会基于它运行的平台进行调整,这可以提高运行性能,但代价是可能会延长应用程序启动时间。
JIT 允许在应用程序运行时动态生成 C# / IL 代码,而 AOT 不支持。
AOT 通常会减少启动时间,因此适用于较大的应用程序,但会增加二进制文件大小以容纳已编译代码。
AOT 在开发过程中也需要较长时间进行生成,无法更改已编译代码的行为以针对任何特定平台。
二、系统库
unity 支持多个 .NET API 配置文件,不过对于所有新项目都应该使用 .NET Standard 2.0 API 兼容性级别(初始时先用它,不满足需求再说)。
因为其 较小、有更好的跨平台支持、可以跨更多VM/运行时、它将更多异常提前至编译时。
仅当需要确保与外部库的兼容性时,或者需要的功能在 .NET Standard 2.0 中不可用时,才应使用 .NET 4.x 配置文件。
三、C# 脚本编译
为了在 Unity 项目中编译 C# 源代码,Unity Editor 使用 C# 编译器。
脚本运行版本: 等效 .NET 4.6
C# 编译器: Roslyn
C# 语言版本: C# 8.0
1、Unity 特殊文件夹对脚本编译顺序的影响
Unity 保留了一些项目文件夹名称来指示内容具有特殊用途。
其中一些文件夹会影响脚本编译的顺序。
Unity 根据脚本文件在项目文件夹结构中的位置,以四个不同的阶段编译脚本。
Unity 为每个阶段创建一个单独的 CSharp 项目文件 (.csproj) 和一个预定义的程序集。
(如果没有符合编译阶段的脚本,Unity 不会创建相应的项目文件或程序集。)
当脚本引用在不同阶段编译的类(因此位于不同的程序集中)时,编译顺序很重要。
基本规则是无法引用在当前阶段之后的阶段编译的任何内容。(前阶段不能引用后阶段内容)
在当前阶段或早期阶段编译的所有内容则是完全可用的。(后阶段可以引用前阶段内容)
编译阶段如下:
1 Assembly-CSharp-firstpass 名为 Standard Assets、Pro Standard Assets 和 Plugins 的文件夹中的运行时脚本。
2 Assembly-CSharp-Editor-firstpass 名为 Editor 的文件夹(位于名为 Standard Assets、Pro Standard Assets 和 Plugins 的顶级文件夹中的任意位置)中的 Editor 脚本。
3 Assembly-CSharp 不在名为 Editor 的文件夹中的所有其他脚本。
4 Assembly-CSharp-Editor 其余所有脚本(位于名为 Editor 的文件夹中的脚本)。
2、自定义程序集
可以创建程序集定义文件,从而使用自己的程序集来组织项目中的脚本。
定义自己的程序集可以减少在进行不相关的代码更改时需要重新编译的代码量,并可提供对其他程序集的依赖性的更多控制。
通过定义程序集,可以组织代码以促进模块化和可重用性。
为项目定义的程序集中的脚本不再添加到默认程序集中,并且只能访问指定的其他程序集中的脚本。
3、预处理/条件编译
可以使用指令(directives),有选择地(是否定义某些脚本符号(scripting symbols))从编译中包含或排除代码。
C# 预处理器指令https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/preprocessor-directives
在 C# 中,还可以使用 Conditional 特性。这是一种更简洁、更不容易出错的函数剥离方式。
在Unity 中,脚本符号可分为 内置脚本符号 和 用户自定义脚本符号:
⑴、Unity内置脚本符号。
①、平台脚本符号:Unity会根据创作内容和当前构建平台自动定义这些脚本符号。
②、编辑器版本脚本符号:Unity会根据当前使用的编辑器版本自动定义这些脚本符号。
③、其他脚本符号。
⑵、自定义脚本符号。
①、通过Unity编辑器设置脚本符号。
②、通过脚本设置脚本符号
可以通过以下三种 API 设置脚本符号。
PlayerSettings.SetScriptingDefineSymbolsForGroup
BuildPlayerOptions.extraScriptingDefines
Build.Player.ScriptCompilationSettings-extraScriptingDefines
但注意:
不应该使用 Editor 脚本在批处理模式持续集成时设置脚本符号,因为这些脚本不会被重新编译,因此不会立即应用它们(改完还需要异步编译(回到编辑器中重新加载脚本(转圈)))。
应该在启动编辑器时使用从一开始就定义好的正确符号。可以通过使用 csc.rsp 资产文件来做到这一点。
Unity 会在启动时读取项目Assets根目录中名为 csc.rsp 的文件,并在编译任何代码之前应用该文件中定义的脚本符号。
(这意味着,想要修改脚本符号的需求处,其实都应该重启Unity)
四、Mono 和 IL2CPP 概述
Mono 脚本后端在运行时通过即时编译(JIT)的方式编译代码。
IL2CPP 脚本后端在运行时通过提前编译(AOT)的方式编译代码。
有些平台不支持 JIT 编译,所以 Mono 后端并不是在每个平台上都能工作。
有些平台不支持 AOT 编译,因此 IL2CPP 后端并不是在每个平台上都能工作。
当一个平台可以同时支持两个后端时,Mono是默认的。
Mono 和 IL2CPP 都以相同的方式支持托管代码的调试。
Mono 和 IL2CPP 都提供了一些有用的条件编译选项,可以通过脚本中的特性(Attribute)来控制这些选项。
Mono 和 IL2CPP 脚本后端都需要针对每个目标平台进行新的构建。
例如,为了同时支持 Android 和 iOS 平台,需要构建两次应用程序,并生成两个二进制文件,一个用于Android,一个用于iOS。
1、Mono
Unity 使用了开源 Mono项目 的一个分支。
Home | Monohttps://www.mono-project.com/
2、IL2CPP
IL2CPP (Intermediate Language To c++)脚本后端是 Mono 后端的替代方案。
IL2CPP 为跨更广泛平台的应用程序提供了更好的支持。
IL2CPP 后端将 MSIL(微软中间语言)代码(例如,脚本中的 c# 代码)转换为 c++ 代码,然后使用 c++ 代码为所选平台创建本机二进制文件(例如,.exe, .apk,或.xap)。
这种类型的编译,即 Unity 在构建本机二进制文件时专门为目标平台编译代码,被称为提前编译(AOT)。
IL2CPP 可以跨各种平台提高性能,但需要在构建的应用程序中包含机器代码,这增加了构建时间和最终构建的应用程序的大小。
更多信息,可参见 IL2CPP 内部介绍系列博客。
五、IL2CPP 进一步了解
1、IL2CPP 组成部分
IL2CPP 主要包含:AOT编译器 和 运行时库。
IL2CPP 实现时解决的问题:
代码生成、方法调用(普通方法、虚拟方法等)、通用泛型实现、类型和方法 的 P/Invoke 包装器、垃圾收集整合等。
2、IL2CPP 如何工作?
当使用IL2CPP开始构建时,Unity会自动执行以下步骤:
⑴、Roslyn c# 编译器将应用程序的 c# 代码 和 任何所需的包代码(package code) 编译为 .net dll(托管程序集)。
⑵、Unity 应用托管代码剥离。这一步可以显著减少构建应用程序的大小(删除不使用的字节码,减少最终的二进制文件大小)。
⑶、IL2CPP 后端将所有托管程序集转换为标准的 c++ 代码。
⑷、c++ 编译器用本机平台编译器(native platform compiler)编译 上一步生成的 c++代码 和 IL2CPP的运行时部分。
⑸、Unity 将上一步结果创建为一个目标平台的可执行文件或dll。
C++ 输出路径为:Temp\StagingArea\Data\il2cppOutput。
IL2CPP 使 Unity 能够预编译特定平台的代码。
Unity 在这个过程结束时生成的二进制文件已经包含了目标平台所需的机器码,而 Mono 必须在执行时编译这些机器码。
AOT 编译确实增加了构建时间,但它也提高了与目标平台的兼容性,可以提高性能。
3、IL2CPP 的泛型共享
泛型共享允许许多泛型方法共享一个公共实现。
这会使 IL2CPP 脚本后端可执行文件的大小显著减小。
(注意泛型共享不是一个新想法,Mono和.net运行时也使用泛型共享)
共享泛型方法实现的能力几乎完全取决于类型T的大小。
如果T是任何引用类型(如字符串或对象),那么它总是指针的大小。
如果T是一个值类型(如int或DateTime),它的大小可能会有所不同,
目前,IL2CPP 为以下两种情况共享泛型方法实现(泛型类型 SomeGenericType<T> 的 T 为):
⑴、T 为 任意引用类型(例如字符串、对象或任何用户定义的类)
⑵、T 为 任意 int 或 enum 类型
当 T 为值类型时,IL2CPP不共享泛型方法实现,因为每个值类型的大小不同(基于其字段的大小)。
这意味着 如果T是引用类型,对可执行文件大小产生最小的影响。
但是,如果T是值类型,则可执行文件的大小将受到影响。
(这种行为对于Mono和IL2CPP脚本后端是相同的。)
4、优化IL2CPP构建时间
使用 IL2CPP 的项目构建时间会明显比 Mono 长。然而可以做几件事来减少构建时间。
⑴、将项目从反恶意软件扫描中排除
在构建项目之前,可以从反恶意软件扫描中排除Unity项目文件夹和目标构建文件夹。
⑵、将项目和目标构建文件夹存储在固态硬盘(SSD)上
固态驱动器(ssd)比传统硬盘驱动器(HDD)有更快的读/写速度。
将IL代码转换为c++并编译需要大量的读/写操作,因此速度更快的存储设备可以加快这一过程。
⑶、使用 Il2CppSetOption 启用运行时检查
当使用 IL2CPP 脚本后端时,可以控制 IL2CPP.exe 如何生成 c++ 代码。
可以使用 Il2CppSetOption 特性来启用或禁用以下运行时检查:
(可将 Il2CppSetOption 特性应用于类型、方法和属性。Unity 从最局部的范围使用特性。)
①、Null checks (默认打开)
此选项决定 IL2CPP 生成的 c++ 代码是否包含空检查并在必要时抛出托管的 NullReferenceException 异常。
禁用此选项可能会提高运行时性能,但在解引用空值后很可能很快崩溃,Unity建议不要禁用这个选项。
②、Array bounds checks (默认打开)
此选项决定 IL2CPP 生成的 c++ 代码是否包含数组边界检查并在必要时抛出托管的 IndexOutOfRangeException 异常。
禁用此选项可能会提高运行时性能,但可能破坏应用程序的状态,使调试这些错误变得极为困难。Unity建议你保持启用这个选项。
③、Divide by zero checks(默认关闭)
此选项决定 IL2CPP 生成的 c++ 代码是否将包含除零检查用于整数除法,并根据需要抛出托管的 DivideByZeroException 异常。
启用此选项可能对运行时性能产生影响,所以应该只在需要运行除零检查时启用。
5、设置平台特定的 IL2CPP 附加参数
IL2CPP 附加参数是平台特定的,应确保其只为需要它们的平台设置。
若存在环境变量 IL2CPP_ADDITIONAL_ARGS 且 ProjectSettings/ProjectSettings.asset 中存在 additionalIl2CppArgs,
则表明已经设置了应用于所有平台的某些 IL2CPP 参数。(要注意别出特定平台的问题)
可实现 IPreprocessBuildWithReport 接口,在其 OnPreprocessBuild(BuildReport report) 方法中,
调用 PlayerSettings.SetAdditionalIl2CppArgs(addlArgs) 进行特定平台设置。(先判断平台)
6、Linux IL2CPP 交叉编译器
Linux IL2CPP 交叉编译器是一组 sysroot 和工具链包。
它允许您在任何独立平台上构建 Linux IL2CPP Players,而无需使用 Linux Unity 编辑器或依赖 Mono。
7、Windows 运行时支持
Unity 包含对通用 Windows 平台和 Xbox One 平台上的 IL2CPP 的 Windows 运行时支持。
通过使用 Windows 运行时支持,可直接从托管代码(脚本和 DLL)调用本机系统 Windows 运行时 API 以及自定义的 .winmd 文件。(和其他平台运行时不同)
8、托管堆栈跟踪与 IL2CPP
可以帮助您了解发生异常的原因,但某些情况下,可能不会按预期显示。
⑴、使用调试版本配置时,IL2CPP 会报告可靠的托管堆栈跟踪,并在调用堆栈中包含每个托管方法。该堆栈跟踪不包含原始 C# 源代码中的行号。
⑵、使用发布版本配置时,IL2CPP 可能会生成缺少一个或多个托管方法的调用堆栈。
这是因为 C++ 编译器已经内联了缺少的方法。
方法内联通常对运行时的性能有好处,但可能会使调用堆栈更难理解。
IL2CPP 始终在调用堆栈上提供至少一个托管方法。
此方法便是发生异常的方法。
调用堆栈上还包括其他未内联的方法。
⑶、在调试或发布配置中,IL2CPP 调用堆栈不包含源代码行号信息。
六、脚本限制
Unity 在支持的所有平台之间提供通用的脚本 API 和体验。
但是,有些平台存在固有的限制。
1、提前编译(AOT)的限制
有些平台不允许生成运行时代码。
因此,任何依赖于在目标设备上即时 (JIT) 编译的托管代码都将失败。
这时,必须提前 (AOT) 编译所有托管代码。
⑴、System.Reflection.Emit
AOT 平台无法实现 System.Reflection.Emit 命名空间中的任何方法。
⑵、序列化
由于使用了反射,AOT平台可能会遇到序列化和反序列化的问题。
如果类型或方法仅通过反射作为序列化或反序列化的一部分使用,AOT编译器就无法检测到它需要为类型或方法生成所需的代码。
⑶、通用的虚拟方法(Generic virtual methods)(重要!)
如果使用泛型方法,编译器必须做一些额外的工作,才能将 编写的代码 扩展为 设备上执行的代码。
例如,对于具有 int 或 double 类型的 List,需要不同代码。
如果使用虚拟方法,其行为是在运行时而不是编译时确定的。
存在虚拟方法时,编译器可以在不完全明显的地方轻松地要求从源代码生成运行时代码。(没懂)
AOT 编译器不会意识到自己应该为 “T 为 自定义枚举 的泛型方法” 生成代码,它会继续往下,跳过该方法。
当调用该方法时,运行时无法找到要执行的正确代码,因此会返回错误消息。
要解决像这样的 AOT 问题,可以强制编译器生成适当的代码。
可以放在一个空方法里而不实际调用;只让编译器看到即可。
⑷、从原生代码调用托管方法
需要编组到 C 函数指针以便可以从原生代码调用的托管方法会在 AOT 平台上有一些限制:
托管方法必须是静态方法
托管方法必须具有 [MonoPInvokeCallback] 特性
2、线程限制
有些平台不支持使用线程,因此任何使用 System.Threading 命名空间的托管代码都将在运行时失败。
此外,.NET 类库的某些部分存在对线程的隐式依赖。一个常用的例子是 System.Timers.Timer 类,它依赖于对线程的支持。
3、使用 IL2CPP 的 其他限制
⑴、异常过滤器
IL2CPP 不支持 C# 异常过滤器。应该将依赖于异常过滤器的代码修改为正确的 catch 块。
⑵、TypedReference
IL2CPP 不支持 System.TypedReference 类型和 __makeref C# 关键字。
⑶、MarshalAs 和 FieldOffset 属性
IL2CPP 不支持在运行时反射 MarhsalAs 和 FieldOffset 属性。但在编译时支持。
应正确使用它们以进行正确的 平台调用编组。
⑷、动态关键字
IL2CPP 不支持 C# dynamic 关键字。此关键字需要 JIT 编译,而 IL2CPP 无法实现。
⑸、Marshal.Prelink
IL2CPP 不支持 Marshal.Prelink 或 Marshal.PrelinkAll API 方法。
七、托管代码剥离
托管代码剥离将从构建中删除未使用的代码,从而可以显著减小最终构建大小。
使用 IL2CPP 脚本后端时,托管代码剥离还可以减少构建时间,因为需要转换为 C++ 并进行编译的代码减少。
托管代码剥离将从托管程序集(包括从项目中的 C# 脚本构建的程序集、包含在包和插件中的程序集以及 .NET 框架中的程序集)中删除代码。
托管代码剥离的工作方式是对项目中的代码进行静态分析,检测出在执行过程中永远无法访问的类、类成员甚至函数的某些部分。
可以通过 Player Settings 窗口中的 Managed Stripping Level 设置(在 Optimization 部分)来控制 Unity 删除无法访问的代码的激进程度。
托管剥离级别分为:Disabled、Low、Medium、High。
Low选项是 IL2CPP 的默认剥离级别(并且已用于 Unity Editor 的许多发行版)。
重要提示:当您的代码(或插件中的代码)使用反射动态查找类或成员时,代码剥离工具不能总是检测到项目正在使用这些类或成员,可能会删除它们。
要声明一个项目正在使用这样的代码,请使用 link.xml 文件或 Preserve 特性。
Unity 构建过程使用一个名为 UnityLinker 的工具来剥离托管代码。
请注意,相比使用 Preserve 特性,在 link.xml 文件中标记代码实体可以提供更强的控制。
使用 [assembly: UnityEngine.Scripting.AlwaysLinkAssembly] 属性可强制 UnityLinker 处理程序集(无论程序集是否被构建中包含的另一个程序集引用)。
AlwaysLinkAssembly 属性只能在程序集上定义。
link.xml 文件是一个基于项目的列表,其中声明如何保留程序集以及程序集中的类型和其他代码实体。要使用 link.xml 文件,请创建此文件(请参阅下面的示例),并将其放入项目的 Assets 文件夹(或者 Assets 文件夹的任何子目录)。可以在项目中使用任意数量的 link.xml 文件,因此插件可以提供自己的保留声明。
UnityLinker 会将 link.xml 文件中保留的任何程序集、类型或成员都视为根类型。
link.xml 文件的 <assembly> 元素有三个特殊用途的属性:ignoreIfMissing、ignoreIfUnreferenced、windowsruntime。
八、参考
Unity 架构 - Unity 手册Unity 引擎是用原生 C/C++ 在内部构建的,不过它有一个 C# 封装器可用来与之交互。因此,您需要了解一些 C# 脚本的重要概念。用户手册的这一部分包含有关 Unity 如何实现 .NET 和 C# 的信息,以及在编写代码时可能遇到的任何异常。https://docs.unity3d.com/cn/2022.2/Manual/unity-architecture.htmlAn introduction to IL2CPP internals | Unity BlogAlmost a year ago now, we started to talk about the future of scripting in Unity. The new IL2CPP scripting backend promised to bring a highly-performant, highly-portable virtual machine to Unity. In January, we shipped our first platform using IL2CPP, iOS 64-bit. The Unity 5 release brought another platform, WebGL. Thanks to the input from our tremendous community of users, we have shipped many patch release updates for IL2CPP, steadily improving its compiler and runtime.We have no plans to stop improving IL2CPP, but we thought it might be a good idea to take a step back and tell you a little bit about how IL2CPP works from the inside out. Over the next few months, we’re planning to write about the following topics (and maybe others) in this IL2CPP Internals series of posts:The basics - toolchain and command line arguments (this post)A tour of generated codeDebugging tips for generated codeMethod calls (normal methods, virtual methods, etc.)Generic sharing implementationP/invoke wrappers for types and methodsGarbage collection integrationTesting frameworks and usageIn order to make this series of posts possible, we’re going to discuss some details about the IL2CPP implementation that will surely change in the future. Hopefully we can still provide some useful and interesting information.What is IL2CPP?The technology that we refer to as IL2CPP has two distinct parts.An ahead-of-time (AOT) compilerA runtime library to support the virtual machineThe AOT compiler translates Intermediate Language (IL), the low-level output from .NET compilers, to C++ source code. The runtime library provides services and abstractions like a garbage collector, platform-independent access to threads and files, and implementations of internal calls (native code which modifies managed data structures directly).The AOT compilerThe IL2CPP AOT compiler is named il2cpp.exe. On Windows you can find it in the Editor\Data\il2cpp directory. On OSX it is in the Contents/Frameworks/il2cpp/build directory in the Unity installation. The il2cpp.exe utility is a managed executable, written entirely in C#. We compile it with both .NET and Mono compilers during our development of IL2CPP.The il2cpp.exe utility accepts managed assemblies compiled with the Mono compiler that ships with Unity and generates C++ code which we pass on to a platform-specific C++ compiler.You can think about the IL2CPP toolchain like this:
https://blog.unity.com/technology/an-introduction-to-ilcpp-internals