Unity的脚本如何跨平台
想要了解Unity的热更原理,必须要先了解Unity脚本的编译和跨平台机制。通常游戏的跨平台主要指安卓和IOS端。Unity的官方脚本语言是C#,但也有不少项目会采用C# + Lua语言的方式进行开发。它们主要有三种跨平台的形式:JIT、AOT、脚本语言。
Unity的C#代码在代码被打包时会被编译器变为成为中间语言IL(Intermediate Language),而不是机器码(NativeCode,机器的可执行代码)。后续对这些IL的编译方式不同可以分为AOT和JIT。
JIT(Just In Time)
JIT是一种动态编译技术,是指Unity打包时将C#编译成IL后,在运行时.NET JIT编译器将IL翻译NativeCode的过程。通常IL由Mono VM编译执行,这是因为Mono VM中包含了.NET JIT编译器。
下面是MonoVM的运行流程图,它就在运行时将IL编译成机器码并保存到内存中并执行。
AOT(Ahead Of Time)
指在程序程序运行前将代码变成称机器码,它不需要再运行时对代码进行解释和编译,这样可以提高程序的执行速度和安全性。Unity AOT的跨平台原理是将程序的C#源代码在打包时编译成与平台无关的IL,然后通过特定的编译器将IL代码编译成特定平台的Native Code。不同平台只需要提供对应的编译器即可,无需在运行时对代码进行解释和编译,从而实现跨平台。
下面是Unity推荐的IL2CPP编译器原理图。在打包时把C#代码先编译成IL,再由IL2CPP编译器编译成C++代码,再由特定平台的C++编译器编译成NativeCode。
最后需要L2CPP VM的原因是虽然代码转换成了C++代码,但C#中的内存是由GC自动管理,而C++需要手动管理内存,因此还需要一个IL2CPP VM用于GC管理等操作。
脚本语言
Lua是一种跨平台的脚本语言,它主要依赖解释器和虚拟机实现跨平台功能。正常的lua脚本跨平台流程是:
- 编写lua脚本
- 使用lua解释器将lua脚本解释称字节码
- 由lua虚拟机执行字节码
由于解释器和虚拟机都是跨平台的,lua脚本也就可以在不同的平台上运行了。
字节码(bytecode)指的是一种中间码,它是一种介于源代码和机器码之间的一种代码形式。字节码是针对特定虚拟机(如Java虚拟机、.NET CLR虚拟机)的指令集,每条指令都比较简单,并且都能够被轻易地转换成机器码。字节码通常是在解释执行或者即时编译的过程中生成的,可以有效地提高程序的执行效率和跨平台能力。
在编译型语言中,源代码会被直接编译成机器码,而在解释型语言中,源代码则会被解释器逐行执行。相比之下,字节码的执行效率通常比解释器高,但比直接执行机器码要低。但是,字节码的好处在于它可以在多个平台上运行,只需要在特定平台上实现一个对应的解释器或者即时编译器即可。这也是为什么很多跨平台的语言(如Java和Lua)都采用了字节码的形式。
举个例子,Java源代码在编译时会被转换成Java字节码,然后在JVM上解释执行或者即时编译成机器码。这样,Java程序就可以在不同的操作系统和硬件上运行,只需要在不同平台上实现对应的JVM即可。
另外,lua也提供了JIT版本,以便在运行时将lua代码编译成NativeCode执行,与普通的lua解释器相比,可以显著地提升lua代码的执行速度。
C#如何热更新
Lua脚本语言是解释执行的,运行前加载更新后的代码就可以达到热更新的效果。在本文中要说明的是C#的热更新。
理想化的热更新流程是:
- 把需要更新的代码编译成动态链接库
- 游戏启动时加载新的动态链接库
- 用反射的形式获取动态链接库中的实例或方法
这种模式在PC和Android平台是可以的,但在IOS平台是不可行的。因为IOS对申请的内存禁止了可执行权限,所以运行时创建/加载的NativeCode是无法执行的。
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offsize)函数是申请内存的函数,它的第三个参数是代表了申请内存的保护方式:
- PROT_EXEC 映射区域可被执行
- PROT_READ 映射区域可被读取
- PROT_WRITE 映射区域可被写入
而IOS平台是不支持PROT_EXEC的。运行时申请的内存不可执行,所以这种理想化的热更新流程无法在IOS平台起作用。
详细原因可以看下这篇文章。
为了解决IOS上的热更新问题,有两个主流方案:ILRuntime 和 HybridCLR。
ILRuntime
Unity会把C#代码打包成DLL,ILRuntime在运行时用自己的解释器来解释IL并执行,而不是直接调用.NET FrameWork或Mono虚拟机来运行代码。它借助Mono.Cecil库来读取DLL的PE信息,以及当中类型的所有信息,最终得到方法的IL汇编码,然后通过内置的IL解译执行虚拟机来执行DLL中的代码。
但是ILRuntime会有一些限制,见https://ourpalm.github.io/ILRuntime/public/v1/guide/FastQA.html。
- ILRuntime和原始的 compiler是两套东西,也就是说你的热更DLL和主工程的DLL实质是不互通的(如热更DLL中一个类要继承主工程DLL的一个类),所以就存在跨域问题,需要写委托适配器,委托转换器。在发布版本后这些不能热更,使用之前一定要预留好可能会使用的
- 部分 C# 语法不支持:由于 ILRuntime 是基于 Mono 实现的,而 Mono 不支持所有 C# 语法,所以 ILRuntime 在某些 C# 语法方面也有限制,比如属性、泛型委托、可选参数等
- 需要特殊处理的代码:由于 ILRuntime 的实现方式,一些特殊的代码需要进行特殊处理,比如反射、LINQ、协程等
- 性能问题:由于 ILRuntime 需要动态解析和执行代码,相对于编译时静态绑定的方式,其性能会有一定程度的下降。同时,在使用过程中也需要注意避免频繁的跨域调用和反射操作,以免影响性能
- ILRuntime对多线程Thread不兼容,在热更代码里使用多线程会导致Unity崩溃闪退
HybridCLR
是一个特性完整、零成本、高性能、低内存的近乎完美的Unity全平台原生c#热更方案。
IL2CPP是一个纯静态的AOT运行时,不支持运行时加载dll,因此不支持热更新。HybridCLR扩充了IL2CPP的代码,使其由纯AOT Runtime变成“AOT+Interpreter”混合Runtime,进而原生支持动态加载Assembly,使得基于IL2CPP打包的游戏不仅能在Android平台,也能在IOS、Consoles等限制了JIT的平台上高效地以AOT+interpreter混合模式执行。
ILRuntime是引入一个第三方VM(Virtual Machine),在VM中解释执行代码,来实现热更新。这些热更新方案的VM与IL2CPP是独立的,意味着它们的元数据是不相通的,在热更新里新增一个类型是无法被IL2CPP所识别的例如通过System.Activator.CreateInstance是不可能创建出这个热更新类型的实例),这种看起来像、实际上却又不是的伪CLR虚拟机,在与IL2CPP这种复杂的CLR运行时交互时,产生极大量的兼容性问题,另外还有严重的性能问题。
HybridCLR对IL2CPP运行时进行扩充,添加interpreter模块,进而实现像Mono一样的混合执行模式。这样一来就能彻底支持热更新了,并且兼容性极佳。对开发者来说,除了以解释模式运行的部分执行得比较慢,其他方面跟标准的运行时没有区别。
通俗地说,il2cpp相当于Mono的AOT模块,HybridCLR相当于Mono的interpreter模块,两者合一成为完整Mono。HybridCLR使得IL2CPP变成一个全功能的Runtime,原生(即通过System.Reflection.Assembly.Load)支持动态加载dll,从而支持ios平台的热更新。
正因为HybridCLR是原生Runtime级别实现,热更新部分的类型与主工程AOT部分类型是完全等价并且无缝统一的。可以随意调用、继承、反射、多线程,不需要生成代码或者写适配器。而其他热更新方案则是独立VM,与IL2CPP的关系本质上相当于Mono中嵌入lua的关系。因此类型系统不统一,为了让热更新类型能够继承AOT部分类型,需要写适配器,并且解释器中的类型不能为主工程的类型系统所识别。特性不完整、开发麻烦、运行效率低下。
HybirdCLR的原理细节Walon有分享,可以在其知乎专栏查看。
参考
- ILRuntime中文官网,https://ourpalm.github.io/ILRuntime/public/v1/guide/principle.html
- 曾志伟, 【Unity游戏开发】Mono和IL2CPP的区别, https://zhuanlan.zhihu.com/p/352463394
- HybridCLR介绍,walon,https://www.zhihu.com/question/519548488