2.2 Mono如何扮演脚本的角色
Mono究竟为何被Unity 3D游戏引擎的开发人员选择作为其脚本模块的基础呢?而Mono又是如何提供这种脚本的功能的呢?
如果需要利用Mono为应用开发提供脚本功能,那么其中一个前提就是需要将Mono的运行时嵌入到应用中,因为只有这样才有可能使托管代码和脚本能够在原生应用中使用。所以,可以发现将Mono运行时嵌入应用中是多么重要。但在讨论如何将Mono运行时嵌入原生应用中之前,首先要清楚Mono是如何提供脚本功能的,以及Mono提供的到底是怎样的脚本机制。
2.2.1 Mono和脚本
本节将会讨论如何利用Mono来提高开发效率以及拓展性,而无须将已经写好的C/C++代码重新用C#写一遍,也就是Mono是如何提供脚本功能的。在第1章中曾经说过,在过去开发游戏时,常常只使用一种编程语言。游戏开发者往往需要在高效率的低级语言和低效率的高级语言之间抉择。例如,一个用C/C++开发的应用的结构,如图2-1所示。一个脚本语言开发的应用的结构,如图2-2所示。
图2-1C/C++开发的应用的结构
可以看到低级语言和硬件打交道的方式更加直接,所以其效率更高。
图2-2 脚本语言开发的应用的结构
可以看到高级语言并没有直接和硬件打交道,所以其效率较低。
如果以速度作为衡量语言级别的标准,那么语言从低级到高级的大概排名如下所示。
• 汇编语言。
• C/C++,编译型静态不安全语言。
• C#、Java,编译型静态安全语言。
• Python、Perl、JavaScript,解释型动态安全语言。
开发者在选择适合自己的开发语言时,的确面临着很多现实的问题。高级语言对开发者而言效率更高,也更加容易掌握。但高级语言也并不具备低级语言的那种运行速度,甚至对硬件的要求更高,这在某种程度上的确也决定了一个项目到底是成功还是失败。
因此,如何平衡两者,或者说如何融合两者的优点,变得十分重要和迫切。脚本机制便在此时应运而生。应用的引擎由富有经验的开发人员使用C/C++开发,而一些具体项目中功能的实现,例如UI、交互等,则使用高级语言开发。
通过使用高级脚本语言,开发者便融合了低级语言和高级语言的优点。同时提高了开发效率,如第1章中所讲,引入脚本机制后开发效率提升了,可以快速地开发原型,而不必把大量的时间浪费在重编译上。
脚本语言同时提供了安全的开发环境,也就是说开发者无须担心C/C++开发的引擎中的具体实现细节,也无须关注例如资源管理和内存管理这些细节,这在很大程度上简化了应用的开发流程。
而Mono则提供了这种脚本机制实现的可能性,即允许开发者使用JIT编译的代码作为脚本语言为他们的应用提供拓展。
目前很多脚本语言的选择趋向于解释型语言,例如Cocos2d-JS使用的JavaScript。因此效率无法与原生代码相比。而Mono则提供了一种将脚本语言通过JIT编译为原生代码的方式,提高了脚本语言的效率。例如Mono提供了一个原生代码生成器,使你的应用的运行效率尽可能快,同时提供了很多方便调用原生代码的接口。
因为当为一个应用提供脚本机制时,往往需要和低级语言交互。而最常见的做法是提供句柄供脚本语言操作。这便不得不提到Mono运行时嵌入到应用中的必要性。
2.2.2 Mono运行时的嵌入
既然明确了Mono运行时嵌入应用的重要性,那么如何将它嵌入应用中呢?
本节会为大家分析一下Mono运行时究竟如何被嵌入到应用中、如何在原生代码中调用托管方法,以及如何在托管代码中调用原生方法。而众所周知的是,Unity 3D游戏引擎本身是用C++写成的,所以本节就以Unity 3D游戏引擎为例,假设此时已经有了一个用C++写好的应用(Unity 3D),如图2-3所示。
图2-3 用C++写好的应用
将你的Mono运行时嵌入到这个应用后,应用就获取了一个完整的虚拟机运行环境。而这一步需要将“libmono”和应用链接,一旦链接完成,C++应用的地址空间如图2-4所示。
图2-4C++应用的地址空间
而Mono的嵌入接口会将Mono运行时暴露给C++代码。这样通过这些接口,开发者就可以控制Mono运行时,以及依托于Mono运行时的托管代码。
一旦Mono运行时初始化成功,那么下一步最重要的就是将CIL/.NET代码加载进来。加载后的地址空间如图2-5所示。
图2-5 加载后的地址空间
那些C/C++代码,通常称为非托管代码。而通过CIL编译器生成CIL代码,通常称为托管代码。
所以,将Mono运行时嵌入应用,可以分为3个步骤。
(1)编译C++程序和链接Mono运行时。
(2)初始化Mono运行时。
(3)C/C++和C#/CIL的交互。
首先需要将C++程序进行编译并链接Mono运行时。此时会用到pkg-config工具。
在OS X系统上使用homebrew来进行安装,在终端中输入命令“brew install pkgconfig”,可以看到终端会输出如下内容。
终端输出结束之后,证明pkg-config安装完成。
接下来新建一个C++文件,将其命名为unity.cpp,作为原生代码部分。需要将这个C++文件进行编译,并和Mono运行时链接。
在终端输入如下内容。
此时,经过编译和链接后,unity.cpp和Mono运行时被编译成了可执行文件。
此时就需要将Mono的运行时初始化。所以再重新回到刚刚新建的unity.cpp文件中,要在C++文件中进行Mono运行时的初始化工作,即调用mono_jit_init方法,代码如下。
mono_jit_init这个方法会返回一个MonoDomain(Mono程序域),用来作为盛放托管代码的容器。其中参数managed_binary_path,即应用运行域的名字。除了会返回MonoDomain之外,这个方法还会初始化默认框架版本,即2.0或4.0,这个主要由使用的Mono版本来决定。当然,也可以手动指定版本。只需要调用下面的方法即可,代码如下。
此时就获取了一个应用域——domain。但是当Mono运行时被嵌入一个原生应用时,它显然需要一种方法来确定自己所需要的运行时程序集以及配置文件。在默认情况下,它会使用在系统中定义的位置。例如/usr/lib/mono目录下的程序集,以及/etc/mono目录下的配置文件。但是,如果应用需要特定的运行时,显然也需要指定其程序集和配置文件的位置。如图2-6所示,在一台电脑上可以存在很多不同版本的Mono,所以选择指定版本的Mono就变得十分必要。
图2-6 一台电脑上存在的不同版本的Mono
为了选择我们所需要的Mono版本,可以使用mono_set_dirs方法,代码如下。
这样就设置了Mono运行时的程序集和配置文件路径。
当然,Mono运行时在执行一些具体功能时,可能还需要依靠额外的配置文件来进行。所以有时也需要为Mono运行时加载这些配置文件,通常使用mono_config_parse方法来进行加载这些配置文件的工作。
当mono_config_parse的参数为NULL时,Mono运行时将加载Mono的配置文件(通常是/etc/mono/config)。当然作为开发者,也可以加载自己的配置文件,只需要将自己的配置文件的文件名作为mono_config_parse方法的参数即可。
Mono运行时的初始化工作到此完成。接下来就需要加载程序集并运行它。这时需要用到MonoAssembly和mono_domain_assembly_open这个方法,代码如下。
代码会将当前目录下的ManagedLibrary.dll文件中的内容加载到已经创建好的domain中。此时需要注意的是,Mono运行时仅仅是加载代码,而没有立刻执行这些代码。
如果要执行这些代码,则需要调用被加载的程序集中的方法。或者当有一个静态的主方法时(也就是一个程序入口),可以很方便地通过mono_jit_exec方法来调用这个静态入口,代码如下。
当然,最好总是能够保证提供一个这样的静态入口,并且在启动Mono运行时的时候通过调用mono_jit_exec方法来执行这个静态入口。因为这样做可以为应用域提供一些额外的信息。
举一个将Mono运行时嵌入C/C++程序的例子,主要流程是加载一个由C#文件编译成的DLL文件,然后调用一个C#的方法并输出Hello World。
首先完成C#部分的代码,代码如下。
在这个文件中,实现了输出Hello World的功能,然后将它编译为DLL文件。这里也直接使用了Mono的编译器——mcs。在终端命令行使用mcs编译该cs文件。同时为了生成DLL文件,还需要加上“-t:library”选项,代码如下。
这样便得到了cs文件编译后的DLL文件,叫ManagedLibrary.dll。
接下来完成C++部分的代码。嵌入Mono的运行时,同时加载刚刚生成的ManagedLibrary.dll文件,并且执行其中的main方法用来输出Hello World,代码如下。
从代码可以看到,在C/C++代码中调用C#的方法需要两个步骤。第一步是要获取目标方法的MonoMethod句柄,第二步是调用该方法。
在本例中,首先获取了一个MonoClass用来代表C#中定义的目标类型。获取MonoClass可以使用如下代码。
接下来使用mono_method_desc_new方法来获取一个MonoMethodDesc,代码如下。
由于第二个参数为true,即需要包括命名空间。所以,要寻找的目标函数是ManagedLibrary.MainTest:Main()方法。获取了C#中的方法的MonoMethodDesc后,就可以根据这个MonoMethodDesc来获得MonoMethod,即目标方法的代表。获取MonoMethod可以通过接口实现,代码如下。
此时就获取了C#文件中的目标方法的句柄。下一步便是如何调用该方法了。
可以直接使用mono_runtime_invoke()方法,通过托管代码中的目标方法的句柄来调用该目标方法,代码如下。
mono_runtime_invoke()方法中第一个参数便是刚刚获取的MonoMethod,而第二个参数则相当于“this”。所以若调用的是静态方法,则此参数为NULL。然后编译运行,可以看到屏幕上输出了“Hello World”。
但是既然要提供脚本功能,将Mono运行时嵌入C/C++程序后,只是在C/C++程序中调用C#中定义的方法显然还是不够的。脚本机制的最终目的还是希望能够在脚本语言中使用原生的代码,所以下面将站在Unity 3D游戏引擎开发者的角度,继续探索如何在C#文件(脚本文件)中调用C/C++程序中的代码(游戏引擎)。
首先,假设要实现的是Unity 3D的组件系统。为了方便游戏开发者能够在脚本中使用组件,首先要在C#文件中定义一个Component类,代码如下。
与此同时,在Unity 3D游戏引擎(C/C++)中,则必然有和脚本中的Component相对应的结构,代码如下。
可以看到此时组件类Component只有一个属性,即ID。再为组件类增加Tag属性。
为了使托管代码能够和非托管代码交互,需要在C#文件中引入命名空间System.Runtime.CompilerServices,同时需要提供一个IntPtr类型的句柄,以便托管代码和非托管代码之间引用数据(IntPtr类型被设计成整数,其大小适用于特定平台,即此类型的实例在32位硬件和操作系统中将是32位,在64位硬件和操作系统中将是64位。IntPtr对象常可用于保持句柄。例如,IntPtr的实例广泛地用在System.IO.FileStream类中来保持文件句柄)。最后,将Component对象的构建工作由托管代码C#移交给非托管代码C/C++,这样游戏开发者只需要专注于游戏脚本即可,无须关注C/C++层面,即游戏引擎层面的具体实现逻辑。所以在此提供两个方法,即用来创建Component实例的方法GetComponents和获取ID的get_id_Internal方法。
这样在C#端,定义了一个Component类,主要目的是为游戏脚本提供相应的接口,而非具体逻辑的实现。在C#代码中定义的Component类,代码如下。
还需要创建这个类的实例,并且访问它的两个属性,所以再定义另一个类Main,来完成这项工作。Main的实现,代码如下。
完成了C#部分的代码后,需要将具体的逻辑在非托管代码端实现。而上文之所以要在Component类中定义ID属性和Tag属性,是为了使用两种不同的方式访问这两个属性,其中之一就是直接将句柄作为参数传入到C/C++中。例如上文所讲的get_id_Internal这个方法,它的参数便是句柄。第二种方法则是在C/C++代码中通过Mono提供的mono_field_get_value方法直接获取对应的组件类型的实例。
所以组件Component类中的属性获取有两种不同的方法,代码如下。
由于在C#代码中基本只提供接口,而不提供具体逻辑实现,所以还需要在C/C++代码中实现获取Component组件的具体逻辑,然后再以在C/C++代码中创建的实例为样本,调用Mono提供的方法在托管环境中创建相同的类型实例,并且初始化。
由于C#中的GetComponents方法返回的是一个数组,所以需要使用MonoArray从C/C++中返回一个数组。所以C#代码中GetComponents方法在C/C++中对应的具体逻辑的代码如下。
其中num_Components是uint32_t类型的字段,用来表示数组中组件的数量,为它赋值为5。然后通过Mono提供的mono_object_new方法来创建MonoObject的实例。而需要注意的是代码中的Components[i],Components便是在C/C++代码中创建的Component实例,这里用来给MonoObject的实例初始化赋值。
创建Component实例的过程代码如下。
C/C++代码中创建的Component的实例的ID为i,tag为i*4。
最后还需要将C#中的接口和C/C++中的具体实现关联起来,即通过Mono的mono_add_internal_call方法来实现,也即在Mono的运行时中注册刚刚用C/C++实现的具体逻辑,以便将托管代码(C#)和非托管代码(C/C++)绑定,代码如下。
这样便使用非托管代码(C/C++)实现了获取组件、创建和初始化组件的具体功能,完整的代码如下。
为了验证是否成功地模拟了将Mono运行时嵌入“Unity 3D游戏引擎”中,需要将代码编译,并且查看输出是否正确。
首先将C#代码编译为DLL文件,在终端直接使用Mono的mcs编译器来完成这项工作,代码如下。
运行后生成了ManagedLibrary.dll文件。然后将unity.cpp和Mono运行时链接,代码如下。
运行后会生成一个a.out文件(OS X系统)。执行a.out,可以在终端上看到创建出的组件的ID和Tag的信息,如图2-7所示。
图2-7 Mono运行时嵌入C/C++的运行结果
通过本节内容可以看到游戏脚本语言出现的必然性。同时也应该更加明确Unity 3D的底层是C/C++实现的,但是它通过Mono提供了一套脚本机制,以方便游戏开发者快速地开发游戏,同时也降低了游戏开发的门槛。
但是Unity 3D游戏引擎作为一款开发跨平台作品的工具,它还采用了Mono来提供自己的脚本模块基础,那么Unity 3D的跨平台能力就和Mono息息相关。