游戏开发一般都需要大量迭代,例如节假日的特殊活动,赛季更新等等情况,最简单的方法就是让玩家重新下载最新包体,但有些游戏动辄几十G,,很显然这方式会让玩家的体验很差,所以就诞生了热更新技术,下面将会介绍热更新涉及的一些知识。

Unity脚本编译原理

如果要了解热更新的知识则需要深入了解Unity是如何实现跨平台的。

Unity是通过两种脚本后端:Mono或者IL2CPP来实现C#脚本的编译。

Mono

Mono编译运行脚本分为两步:

  1. 将C#代码编译成CIL中间汇编语言
  2. 在游戏运行时CIL和其他第三方兼容DLL放入Mono虚拟机中运行。

Mono跨平台经典图例:

Mono

Mono在运行CIL时有三种模式运行:

  1. JIT(Just In Time),动态编译,在编译时把C#编译成CIL,在运行时逐条读入、逐条解析成原生码交给CPU执行。
  2. AOT(Ahead Of Time),部分静态编译,在编译成CIL后再将CIL编译成原生码,运行时把原生码交给CPU执行,但在Mono中这种模式金辉处理部分CIL,还有部分CIL采用JIT的模式。
  3. Full AOT,完全静态编译,将全部CIL都编译成原生码,在运行时执行。

IL2CPP

IL2CPP前面与Mono一样都是将C#编译为CIL中间代码,之后则不同:

  1. 将得到的IL中间代码通过IL2CPP重新变回C++代码
  2. 通过各个平台的C++编译器直接编译成能执行的native code(原生汇编代码,即本机可执行的汇编代码)

IL2CPP跨平台经典图例:

IL2CPP

所以IL2CPP没有动态翻译的过程,仅支持AOT模式编译。

AOT编译的问题

上面两种脚本编译方式都支持AOT模式,但是AOT模式仍然有问题,例如代码中包含泛型代码则会跳过代码并报错,这也是AOT难以避免的弊端。

热更新技术

热更新的主要原理就是将需要替换或者新增的资源加载到内存,运行时加载,游戏中需要的热更新可以分为两中情况:

  1. 如果是项目文件资源(如美术资源,文本文件等),则需要检查是否本地与服务器是否相同,对欠缺或者差异的文件资源进行下载或更新,Unity中一般通过AssetBundle实现。
  2. 如果是代码更新则情况会复杂一点,分为PC平台移动平台

    1. 在PC平台同项目文件资源类似,使用补丁技术,检查代码是否欠缺或者更新,从服务器上下载更新的代码(.dll文件),通过Assembly. Load动态加载DLL,反射创建热更DLL中的类的实例或者静态方法。
    2. 移动平台与PC平台不同主要是因为IOS禁止赋予动态分配内存执行的权限,所以IOS不支持JIT(即时代码编译),从服务器上下载下来的代码所在内存位置被标为禁止执行,也就无法编译,导致编译型的语言都无法在运行IOS实现热更。

由于IOS平台的特殊性,大多热更新方案在IOS平台游戏需要通过嵌入脚本语言的方式来实现(除HybridCLR)。

PS:实际上虽然Mono支持Full AOT,但是只能打包出32位APP,然而在2016年IOS要求新上架的游戏必须要64位,从而导致打包IOS只能通过IL2CPP的方式

目前的方式主流的方案包含Lua热更方案:ToLua等,C#热更方案:HybridCLRILRuntime

  • Lua的热更方案是静态绑定实现C#与Lua之间交互,通过Unity内嵌的Lua虚拟机映射C#脚本,再通过该虚拟机运行Lua脚本,运行时Lua脚本可以调用C#注册过的对象,其他的Lua解决方法思路都大差不差。
  • C#方案中ILRuntime听说各种坑,但是没用过不敢说什么,不过HybridCLR看起来很厉害的样子,特性完整、零成本、高性能、低内存、C#热更方案等等优点,还是国人写的,支持,有兴趣的可以去HybridCLR文档看一下。

目前大多公司使用的热更方案应该都是Lua,假设(主要是没有深入了解过)HybridCLR非常NB,大公司里面要往这个热更方案转换还是需要挺长时间的。

为什么选择Lua

实际上脚本语言(不需要编译)基本都可以实现热更新,但Lua更为适合作为游戏的热更新方案,主要是因为:

  1. Lua是一门用标准C编写的脚本语言,Lua语法不会解释为机器码,也不会申请特殊权限的存在,只把Lua解释为一种计算需求,游戏未运行时Lua脚本就如同图片,文本文件等属于文件资源,当游戏运行时Unity内嵌的Lua虚拟机就会实时地解释执行Lua脚本,从而实现了热更新。
  2. Lua十分小巧,仅几万行代码。
  3. Lua的虚拟机更为稳定。
  4. 开发效率很高(试着用Lua写一写就能明白,这语言有多么随意了,虽然容易出bug就是:( )
  5. Lua编译(解释)很快,很适合存储大量数据,在《Lua程序设计》中提到4秒内占用240MB内存完成100万行条赋值语句的读取、编译、运行,其他脚本语言要么崩溃要么时间过长。
  6. 大多开源、成熟的热更方案都是基于Lua。

Lua热更代码层实现

Lua通常加载一个文件的方式是通过require函数实现,函数逻辑如下:

  1. require在表package.loaded中检查模块是否已被加载

    • 如果被加载,则返回对应的值
    • 如果没有被加载,则在搜索指定模块名的Lua文件

      • 如果找到对应的Lua文件,则通过loadfile加载,获得加载器函数
      • 如果没有找到对应的Lua文件,则搜索相应名称的C标准库,并通过底层函数package.loadlib进行加载,获得被表示为Lua函数的C语言函数luaopen_modname
  2. require带着两个参数(模块名和加载函数所在文件的名称)调用加载上述加载的函数

    • 如果函数有返回值则将返回值保存到表package.loaded
    • 如果没有返回值则假设模块返回值为true,并保存到表package.loaded

理清了require的逻辑后,热更新机制也就有了思路,可以将之前缓存过的旧模块置空,然后再重新require新模块就行了。

但是这里会有问题:仅通过设置nil再require的Lua脚本hotfixes仅仅函数得到了更新,原来函数的upvalue(当一个局部变量被内层的函数使用时,它被成为该函数的upvalue,或叫上值、外部局部变量)由于Lua闭包的特性则得到了保留,这时则需要通过debug库获取并对新的方法的upvalue进行更新。

ToLua

在我的这篇文章中介绍了ToLua以及它的使用。

参考

  1. 《Unity热更那些事》,回答中里的视频还是挺有内容的。
  2. 《Unity将来时:IL2CPP是什么》,介绍Mono和IL2CPP
  3. 《Lua热更新解析》