浅谈游戏热更新
游戏开发一般都需要大量迭代,例如节假日的特殊活动,赛季更新等等情况,最简单的方法就是让玩家重新下载最新包体,但有些游戏动辄几十G,,很显然这方式会让玩家的体验很差,所以就诞生了热更新技术,下面将会介绍热更新涉及的一些知识。
Unity脚本编译原理
如果要了解热更新的知识则需要深入了解Unity是如何实现跨平台的。
Unity是通过两种脚本后端:Mono或者IL2CPP来实现C#脚本的编译。
Mono
Mono编译运行脚本分为两步:
- 将C#代码编译成CIL中间汇编语言
- 在游戏运行时CIL和其他第三方兼容DLL放入Mono虚拟机中运行。
Mono跨平台经典图例:
Mono在运行CIL时有三种模式运行:
- JIT(Just In Time),动态编译,在编译时把C#编译成CIL,在运行时逐条读入、逐条解析成原生码交给CPU执行。
- AOT(Ahead Of Time),部分静态编译,在编译成CIL后再将CIL编译成原生码,运行时把原生码交给CPU执行,但在Mono中这种模式金辉处理部分CIL,还有部分CIL采用JIT的模式。
- Full AOT,完全静态编译,将全部CIL都编译成原生码,在运行时执行。
IL2CPP
IL2CPP前面与Mono一样都是将C#编译为CIL中间代码,之后则不同:
- 将得到的IL中间代码通过IL2CPP重新变回C++代码
- 通过各个平台的C++编译器直接编译成能执行的native code(原生汇编代码,即本机可执行的汇编代码)
IL2CPP跨平台经典图例:
所以IL2CPP没有动态翻译的过程,仅支持AOT模式编译。
AOT编译的问题
上面两种脚本编译方式都支持AOT模式,但是AOT模式仍然有问题,例如代码中包含泛型代码则会跳过代码并报错,这也是AOT难以避免的弊端。
热更新技术
热更新的主要原理就是将需要替换或者新增的资源加载到内存,运行时加载,游戏中需要的热更新可以分为两中情况:
- 如果是项目文件资源(如美术资源,文本文件等),则需要检查是否本地与服务器是否相同,对欠缺或者差异的文件资源进行下载或更新,Unity中一般通过AssetBundle实现。
如果是代码更新则情况会复杂一点,分为PC平台和移动平台:
- 在PC平台同项目文件资源类似,使用补丁技术,检查代码是否欠缺或者更新,从服务器上下载更新的代码(.dll文件),通过Assembly. Load动态加载DLL,反射创建热更DLL中的类的实例或者静态方法。
- 移动平台与PC平台不同主要是因为IOS禁止赋予动态分配内存执行的权限,所以IOS不支持JIT(即时代码编译),从服务器上下载下来的代码所在内存位置被标为禁止执行,也就无法编译,导致编译型的语言都无法在运行IOS实现热更。
由于IOS平台的特殊性,大多热更新方案在IOS平台游戏需要通过嵌入脚本语言的方式来实现(除HybridCLR)。
PS:实际上虽然Mono支持Full AOT,但是只能打包出32位APP,然而在2016年IOS要求新上架的游戏必须要64位,从而导致打包IOS只能通过IL2CPP的方式
目前的方式主流的方案包含Lua热更方案:ToLua等,C#热更方案:HybridCLR,ILRuntime。
- Lua的热更方案是静态绑定实现C#与Lua之间交互,通过Unity内嵌的Lua虚拟机映射C#脚本,再通过该虚拟机运行Lua脚本,运行时Lua脚本可以调用C#注册过的对象,其他的Lua解决方法思路都大差不差。
- C#方案中ILRuntime听说各种坑,但是没用过不敢说什么,不过HybridCLR看起来很厉害的样子,特性完整、零成本、高性能、低内存、C#热更方案等等优点,还是国人写的,支持,有兴趣的可以去HybridCLR文档看一下。
目前大多公司使用的热更方案应该都是Lua,假设(主要是没有深入了解过)HybridCLR非常NB,大公司里面要往这个热更方案转换还是需要挺长时间的。
为什么选择Lua
实际上脚本语言(不需要编译)基本都可以实现热更新,但Lua更为适合作为游戏的热更新方案,主要是因为:
- Lua是一门用标准C编写的脚本语言,Lua语法不会解释为机器码,也不会申请特殊权限的存在,只把Lua解释为一种计算需求,游戏未运行时Lua脚本就如同图片,文本文件等属于文件资源,当游戏运行时Unity内嵌的Lua虚拟机就会实时地解释执行Lua脚本,从而实现了热更新。
- Lua十分小巧,仅几万行代码。
- Lua的虚拟机更为稳定。
- 开发效率很高(试着用Lua写一写就能明白,这语言有多么随意了,虽然容易出bug就是:( )
- Lua编译(解释)很快,很适合存储大量数据,在《Lua程序设计》中提到4秒内占用240MB内存完成100万行条赋值语句的读取、编译、运行,其他脚本语言要么崩溃要么时间过长。
- 大多开源、成熟的热更方案都是基于Lua。
Lua热更代码层实现
Lua通常加载一个文件的方式是通过require函数实现,函数逻辑如下:
require在表package.loaded中检查模块是否已被加载
- 如果被加载,则返回对应的值
如果没有被加载,则在搜索指定模块名的Lua文件
- 如果找到对应的Lua文件,则通过loadfile加载,获得加载器函数
- 如果没有找到对应的Lua文件,则搜索相应名称的C标准库,并通过底层函数package.loadlib进行加载,获得被表示为Lua函数的C语言函数luaopen_modname
require带着两个参数(模块名和加载函数所在文件的名称)调用加载上述加载的函数
- 如果函数有返回值则将返回值保存到表package.loaded中
- 如果没有返回值则假设模块返回值为true,并保存到表package.loaded中
理清了require的逻辑后,热更新机制也就有了思路,可以将之前缓存过的旧模块置空,然后再重新require新模块就行了。
但是这里会有问题:仅通过设置nil再require的Lua脚本hotfixes仅仅函数得到了更新,原来函数的upvalue(当一个局部变量被内层的函数使用时,它被成为该函数的upvalue,或叫上值、外部局部变量)由于Lua闭包的特性则得到了保留,这时则需要通过debug库获取并对新的方法的upvalue进行更新。
ToLua
在我的这篇文章中介绍了ToLua以及它的使用。
参考
- 《Unity热更那些事》,回答中里的视频还是挺有内容的。
- 《Unity将来时:IL2CPP是什么》,介绍Mono和IL2CPP
- 《Lua热更新解析》