结合代码来看 Unity 3D 的跨平台特性实现

图片取自 Zoommy

Unity3D 的 1.0 版本在 2005 年 6 月 6 日发布,是当前使用十分广泛的一款游戏引擎。它的使用广泛离不开 Write once, run in everywhere的特性,那就是写一次代码,可以做多个平台运行。这一特性使开发者的开发、维护成本变得更小,无需实现、维护多套不同平台的代码,直接使用 Unity3D 提供的 C# API 开发即可。

跨平台问题

那 Unity3D 是如何实现跨平台特性的?和我们熟知的 Java 跨平台实现上是一致的吗?

Java 跨平台通过提供一个中间层来解决跨平台问题。JRE(Java 运行时)就是这里的中间层。通过了解 Java 代码编译过程,这一点就比较容易理解。JRE 中的 JVM(Java 虚拟机)并不能直接运行 Java 代码,而是运行编译之后的 .class 文件内容,该文件中其实是 Java 字节码(bytecode)。换句话说,Java 代码是先编译成字节码,然后运行在 JVM 之上的,JVM 负责将字节码编译为运行在 CPU 之上的机器码。这里的 JRE 帮我们屏蔽了硬件层面的区别,我们只需要在 macOS、 Windows 甚至塞班系统(过时的诺基亚手机系统)中嵌入 Java 运行时,我们的 Java 代码就可以跑在这些平台上了。

其实 Unity3D 的解决方案是相同的:增加一个中间层来负责消除硬件的差别。我们知道 Unity3D 使用的语言是 C#,提到 C# 第一个想到的就是 .NET。.NET 相当于 C# 的运行时,C# 编译生成 .dll 或 .exe 中间文件,而这些中间文件交由 .NET 来进而编译成机器码。其编译流程如下图。但这里的和 JRE 不同的地方是,.NET 只能运行在 Windows 平台。想要实现跨平台特性,Unity3D 是不能使用 .NET 作为 C# 运行时的。这里就要提到 Mono。

C# 编译流程

Mono 由 Xamarin(本质上也是 Microsoft) 公司赞助开发,遵循 C# 的 ECMA 标准和 CLI(Common Language Infrastructure),相当于 .NET 的跨平台版本。Unity3D 也是采用 Mono + C# 来获得跨平台的能力的。

至于为什么 Unity3D 要使用 Mono + C#,那就不得不提到 Unit3D 起初使用的 C++ 语言。C++ 使引擎拥有了十分强悍的性能。但是带来的问题是无法适应快速开发迭代的环境,C++ 是比较接近硬件的语言,开发效率不如一些高级语言。但是,纯粹的使用高级语言带来的问题是运行效率低下。这就导致了两者结合的出现:引擎底层使用 C++ 开发,提供性能保障;上层提供高级语言 的 API,方便开发者;中间嵌入运行时,提供高级语言和 C++ 之间调用的桥梁。下列图中介绍了三种方式的区别。

C++ 程序结构示意-高效

高级语言程序结构示意-低效

两者结合的方式-优势互补

Unity3D 也是采用这种方式,只是这里的高级语言和运行时选用的是 C# 和 Mono 运行时。

接下来我们来实践一下,来看看如何实现 C# 和 C++ 的互相调用,尝试了解一下 Unity3D 游戏内部运行的机理。

实践

上文我们提到 C# 和 C++ 互相调用的关键是 Mono。我们先来看下 Mono 提供了哪些内容:

  • C# 编译器
    将 C# 编译为中间文件,即 bytecode
  • Mono 运行时
    将 bytecode 编译为 native code(它是基于 CPU 架构,非跨平台的)。此处的编译方式有三类:JIT(Just In Time)、AOT(Ahead of time)、Full-AOT。
    JIT 是指应用运行时,进行编译;AOT 代表运行之前,将大部分代码编译好,但其中小部分代码仍会放在运行时编译;Full-AOT 就是完全没有 JIT 的 AOT。
  • 基础类库、Mono 类库
    为开发者提供的方便使用的类库

然后我们来看一下,C# 代码是如何嵌入到已有的 C++ 程序中执行的。
我们已有的 C/C++ 程序如下图:

C/C++ 程序

将 Mono 运行时链接到已有的 C++ 程序中,其实是和 libmono 库进行链接。链接后的地址空间如下图:

链接 Mono 运行时之后

Mono 运行时给 C++ 部分提供了 API 调用,让 C++ 具备获取运行时环境和在其中运行的 C# 代码的能力。当 Mono 运行时加载了 C# 编译后的中间代码之后,其地址空间如下图:

加载 C# 中间代码后

总的来说,我们先将提供了 Mono 运行时的库和现有的 C++ 程序链接,同时因为 Mono 运行时提供 C++ 获取其内部 C# 中间代码执行环境的能力,所以在 Mono 运行时加载中间代码之后,就可以使用 C++ 调用 C# 了。我们代码所实现的流程也基本如此。

接下来,我们以 macOS 平台为例,看一下如何根据上述流程,编写一个 C++ 调用 C# 的程序。

流程概览

这里先整体说一下整个流程中需要操作的步骤,可能碰到的问题和解决方法可以下文找到。

  1. 确认 g++ 是否安装,macOS 会自带该程序,有下文输出代表已经安装
$ g++
clang: error: no input files
  1. 安装 pkgconfig,用于链接 C++ 程序和 mono 库
$ brew install pkg-config
  1. 安装 Mono 环境,点击下载后,双击安装即可;安装完成,终端输入以下命令正常输出版本号即可
$ mono --version
Mono JIT compiler version 6.4.0.208 (2019-06/07c23f2ca43 Wed Oct  2 04:52:23 EDT 2019)
  1. 编写 C++、C# 代码:CppInvokeCS.cpp、CppInvokeCS.cs
  2. 编译、运行代码
// 编译 C++
$ g++ CppInvokeCS.cpp `pkg-config --cflags --libs mono-2`
// 编译 C#
$ mcs CppInvokeCS.cs -t:library
// 运行
$ ./a.out
// 运行结果
Hello World

接下来我们看下具体的代码实现。

代码部分

这里我们以 C++ 调用 C# 为例,C# 调用 C++ 的代码大同小异,在此将我编写的 C++、C# 互相调用的源码放出来给大家参考。

点击查看 GitHub 源码

代码主要包括两部分:CppInvokeCS.cpp、CppInvokeCS.cs。其内容如下,大家可以根据注释来了解对应代码的功能。

CppInvokeCS.cpp

#include "mono/jit/jit.h"
#include <mono/metadata/assembly.h>
#include <mono/metadata/class.h>
#include <mono/metadata/debug-helpers.h>
#include <mono/metadata/mono-config.h>

int main()
{
    // 初始化 Mono 运行时
    MonoDomain *domain = mono_jit_init("CppInvokeCS");
    // 配置 Mono 的位置和配置文件
    mono_set_dirs("/Library/Frameworks/Mono.framework/Home/lib", "/Library/Frameworks/Mono.framework/Home/etc");
    // 使用默认配置文件
    mono_config_parse(NULL);
    // 加载 CppInvokeCS.dll
    MonoAssembly *assembly = mono_domain_assembly_open(domain, "./CppInvokeCS.dll");
    MonoImage *image = mono_assembly_get_image(assembly);

    //获取 MonoClass
    MonoClass *main_class = mono_class_from_name(image, "CppInvokeCS", "Main");
    // 获取 MonoMethodDesc
    MonoMethodDesc *entry_point_method_desc = mono_method_desc_new("CppInvokeCS.Main:Log()", true);
    MonoMethod *entry_point_method = mono_method_desc_search_in_class(entry_point_method_desc, main_class);
    mono_method_desc_free(entry_point_method_desc);
    // 调用方法
    mono_runtime_invoke(entry_point_method, NULL, NULL, NULL);
    // 释放运行时
    mono_jit_cleanup(domain);
    return 0;
}

CppInvokeCS.cs

namespace CppInvokeCS
{
    public static class Main
    {
        public static void Log()
        {
            System.Console.WriteLine("Hello World");
        }
    }
}

延伸思考

这里不得不说的是,根据 C++ 代码中调用 C# 方法的部分,我们会发现它的流程和 Java 反射的代码流程是十分相似的:

  1. 获取对应类
  2. 获取要调用的方法
  3. 执行方法

我们上文提到,C# 和 Java 类似,通过提供中间层解决跨平台问题。我们稍加探索可以发现,这是语言跨平台技术形成的共识,就是将高级语言编译为 il(intermediate language)代码,il 代码是具有跨平台特性的,然后提供运行时环境(runtime)在将 il 编译为基于硬件有差别的 native code,以此实现高级语言的跨平台。我们将『提供中间层』这一思路想的更广泛一点会发现,很多问题都是如此解决的。我们网络需要分层来将网络中各部分职责分离以简化网络硬件、软件实现的复杂度,软件开发时需要分层来分离代码、降低复杂度、提高可维护性,公司的管理需要分层次来将职责划分。

大家或许可以由此对 C++ 和 C# 提出更加深入的问题:C++ 调用 C# 内部的机制是怎样的?跟 Java 反射机制从原理上讲有什么联系?其原理从底层上看是一样的吗?如果你对这个更加深入的话题感兴趣,可以自行探索。

可能碰到的问题及其解决

找不到 mono-2

问题如下:

$ g++ CppInvokeC#.cpp `pkg-config --cflags --libs mono-2`
 
Package mono-2 was not found in the pkg-config search path.
Perhaps you should add the directory containing `mono-2.pc' to the PKG_CONFIG_PATH environment variable
No package 'mono-2' found
CppInvokeCS.cpp:1:10: fatal error: 'mono/jit/jit.h' file not found

解决方法:命令行输入以下命令即可解决,其中 6.4.0 根据你安装的版本号来动态修改

export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/lib/pkgconfig:/Library/Frameworks/Mono.framework/Versions/6.4.0/lib/pkgconfig:$PKG_CONFIG_PATH

Reference

版权声明
本文首发于微信公众号:AndroidRain
同步发于简书,搜索作者 QinGeneral
同步发于CSDN博客,搜索作者 QinGeneral
无需授权即可转载,请保留以上版权声明;
转载时请务必注明作者。

扫码关注微信公众号

扫码关注微信公众号

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章