Unity3D高级编程:主程手记
上QQ阅读APP看书,第一时间看更新

第2章 C#技术要点

2.1 Unity3D中C#的底层原理

Unity底层在运行C#程序时有两种机制,一种是Mono,另一种是IL2CPP。这两种机制到底有什么区别?又是如何运作的呢?下面来简单介绍一下。

我们知道,在Unity中使用C#语言的一个重要好处就是编译快且开发效率高。但是.NET虽好却只能运行在Windows上(不过现在NetCore可以实现多平台了,只是还不够完善),主要是因为微软没有考虑跨平台而且没有将其开源。后来微软公司向ECMA申请将C#作为一种标准。C#在2003年成为一个ISO标准(ISO/IEC 23270)。这意味着只要是遵守CLI(Common Language Infrastructure)的第三方就可以将任何一种语言实现到.NET平台上。Mono就是在这种环境下诞生的,Mono的目标就是达成跨平台.NET 4.0的完整功能支持。

与微软的.NET Framework(共通语言运行平台)不同,Mono项目不仅可以运行于Windows系统上,还可以运行于Linux、FreeBSD、Unix、OS X和Solaris上,甚至是一些游戏平台上,例如:Playstation 3、Wii或XBox 360。Mono使得C#这门语言有了很好的跨平台能力。

这里要引出一个很重要的概念“IL”。IL的全称是Intermediate Language,但很多时候我们看到的是CIL(Common Intermediate Language,特指在.NET平台下的IL标准),其实大部分文章中提到的IL和CIL表示的是同一个东西,即中间语言。

IL是一种低阶(lowest-level)的人类可读的编程语言。我们可以将通用语言翻译成IL,然后汇编成字节码,最后运行在虚拟机上;也可以把IL看作一个面向对象的汇编语言,只是它必须运行在虚拟机上,而且是完全基于堆栈的语言。也就是说,C#、VB、J#这种遵循CLI规范的高级语言,会被各自的编译器编译成中间语言IL(CIL),当需要运行它们时就会被实时地加载到运行时库中,由虚拟机动态地编译成汇编代码(JIT)并执行。

值得注意的是,在Unity中,其他两门脚本语言Boo和Unity Script也同样是被各自的编译器编译成遵循CLI规范的IL后,再由Mono虚拟机解释并执行。

其实IL有三种转译模式。

·Just-in-time(JIT)编译:在程序运行过程中将CIL转译为机器码。

·Ahead-of-Time(AOT)编译:将IL转译成机器码并存储在文件中,此文件并不能完全独立运行。通常此种模式可产生出绝大部分JIT模式所产生的机器码,只是有部分例外,例如trampolines或是控管监督相关的代码仍旧需要JIT来运行。

·完全静态编译:这个模式只支持少数平台,它基于AOT编译模式更进一步产生所有的机器码。完全静态编译模式可以让程序在运行期完全不需要用到JIT,这个做法适用于iOS操作系统、PlayStation 3以及XBox 360等不允许使用JIT的操作系统。

Unity在打包iOS操作系统的时候就使用了第三种方式,而在Android和Windows上则使用JIT实时编译来运行代码。

Mono内包含三类组件,分别是核心组件、Mono/Linux/GNOME开发堆栈、微软兼容堆栈。下面简单介绍一下它们:

·核心组件包含C#编译器、Common Language Infrastructure虚拟机,以及核心类别程序库。

·Mono/Linux/GNOME开发堆栈提供了工具用于开发应用软件。这些工具使用了既有的GNOME以及自由且开放源代码的程序库,它们包含了针对图形用户界面开发的Gtk#、可套用Gecko rendering engine的Mozilla程序库、Unix集成程序库(Mono.Posix)、安全堆栈,以及XML schema语言RelaxNG。

·微软兼容堆栈提供了一种方式以使Windows .NET应用程序可以被移植到GNU/Linux上,堆栈包含了ADO.NET、ASP.NET以及Windows Forms等。

Mono使用垃圾回收机制来管理内存,应用程序向垃圾回收器申请内存,最终由垃圾回收器决定是否回收。当我们向垃圾回收器申请内存时,如果发现内存不足,就会自动触发垃圾回收,或者也可以主动触发垃圾回收,垃圾回收器此时会遍历内存中所有对象的引用关系,如果没有被任务对象引用则会释放内存。

在3.1.1版之后Mono正式将Simple Generational GC(SGen-GC)设置为默认的垃圾回收器,它比前面几代的垃圾回收器要好用得多。SGen-GC的主要思想是将对象分为两个内存池,一个较新,一个较老,那些存活时间长的对象都会被转移到较老的内存池中去。这种设计是基于这样的一个事实:程序经常会申请一些小的临时对象,用完了马上就释放。而如果某个对象一段时间没被释放,往往很长时间都不会释放。

前面说到IL编码,其实C#代码生成的IL编码我们称为托管代码,由虚拟机的JIT编译执行,其中的对象无须手动释放,它们由GC管理。C/C++或C#中以不安全类型写的代码我们称为非托管代码,虚拟机无法跟踪到这类代码对象,因此在Unity中有托管代码和非托管代码之分。

一般情况下,我们使用托管代码来编写游戏逻辑,非托管代码通常用于更底层的架构、第三方库或者操作系统相关接口,非托管代码使用这部分的内存必须由程序员自己来管理,否则会造成运行时错误或者内存泄漏。这就像Android中的Java和Native code的区别一样,Java的bytecode是跑在虚拟机上的,而Native code则直接跑在bare metal上。为了访问底层资源,Java中的部分接口最终还是要通过JNI调到Native code中来。Mono的框架其实也是类似的,IL代码要实现与平台相关的调用或是调用已有的Native library,最终还是要通过一套类似于JNI的接口实现,同时Mono自己也有一套可以使用非托管代码的方法。

Unity的Mono用得好好的,为什么要加入IL2CPP机制呢?Unity官方解释的原因有以下几个:

1)维护成本过大。Unity的Mono虚拟机有自己的修改方案,需要自己维护独有的虚拟机程序。这导致Unity在各个平台完成移植工作时,工作量巨大,有时甚至不可能完成。在这种情况下,每新增一个平台,Unity的项目组就要把虚拟机移植一遍,同时要解决不同平台虚拟机里的问题。而像WebGL这样基于浏览器的平台的移植工作甚至不太可能完成。

2)Mono版本授权受限。Mono版本无法升级,这也是Unity社区开发者抱怨最多的一条,很多C#的新特性无法使用。如果换成IL2CPP,则可以通过IL2CPP自己开发一套组件来解决这个问题。

3)提高运行效率。根据官方的实验数据,换成IL2CPP以后,程序的运行效率有了1.5~2.0倍的提升。

那么IL2CPP的编译和运行过程是怎么样的呢?

首先还是由Mono将C#语言翻译成IL,IL2CPP在得到中间语言IL后,将它们重新变回C++代码,再由各个平台的C++编译器直接编译成能执行的机器码。

这里要注意的是,虽然C#代码被翻译成了C++代码,但IL2CPP也有自己的虚拟机,IL2CPP的虚拟机并不执行JIT或者翻译任何代码,它主要是用于内存管理,其内存管理仍然采用类似Mono的方式,因此程序员在使用IL2CPP时无须关心Mono与IL2CPP之间的内存差异。

前面已提到,Unity在iOS平台中使用基于AOT的完全静态编译绕过了JIT,使得Mono能在这些不支持JIT的操作系统中使用。对于IL2CPP来说,其实就相当于静态编译了C#代码,只是这次编译成了C++代码,最后翻译成二进制机器码绕过了JIT,所以也可以说IL2CPP实现了另一种AOT完全静态编译。