Unity 补完计划(三):Unity的热更新与xLua
热更新
热更新是指在不需要重新编译打包游戏的情况下,在线更新游戏中的一些非核心代码和资源,比如活动运营和打补丁。热更新分为资源热更新和代码热更新两种,代码热更新实际上也是把代码当成资源的一种热更新,但通常所说的热更新一般是指代码热更新。
资源热更新主要通过AssetBundle来实现,在Unity编辑器内为游戏中所用到的资源指定AB包的名称和后缀,然后进行打包并上传服务器,待游戏运行时动态加载服务器上的AB资源包。
代码热更新主要包括Lua热更新、ILRuntime热更新和C#直接反射热更新等。由于ILRuntime热更新还不成熟可能存在一些坑,而C#直接反射热更新又不支持IOS平台,因此目前大多采用更成熟的、没有平台限制的Lua热更新方案。
三种热更新方案
- Lua热更新
Lua热更新解决方案是通过一个Lua热更新插件(如ulua、slua、tolua、xlua等)来提供一个Lua的运行环境以及和C#进行交互。目前用的人最多,性能最好的当属 xlua 热更新插件对应的热更新解决方案。xLua是腾讯开源的热更新插件,有大厂背书和专职人员维护,插件的稳定性和可持续性较强。
由于 Lua 不需要编译,因此 Lua 代码可以直接在 Lua 虚拟机里运行,Python 和 JavaScript 等脚本语言也是同理。而 xLua 热更新插件就是为 Unity、.Net、Mono 等 C# 环境提供一个 Lua 虚拟机,使这些环境里也可以运行 Lua 代码,从而为它们增加 Lua 脚本编程的能力。借助 xLua,这些 Lua 代码就可以方便的和 C# 相互调用。这样平时开发时使用 C#,等需要热更新时再使用 Lua,等下次版本更新时再把之前的 Lua 代码转换成 C# 代码,从而保证游戏正常运营。
- ILRuntime 热更新
ILRuntime 项目是掌趣科技开源的热更新项目,它为基于 C# 的平台(例如Unity)提供了一个纯 C#、快速、方便和可靠的 IL 运行时,使得能够在不支持 JIT 的硬件环境(如iOS)能够实现代码热更新。
ILRuntime 项目的原理实际上就是先用 VS 把需要热更新的 C# 代码封装成 DLL(动态链接库)文件,然后通过 Mono.Cecil
库读取DLL信息并得到对应的IL中间代码(IL是 .NET平台上的C#、F#等高级语言编译后产生的中间代码,IL的具体形式为 .NET 平台编译后得到的 .dll 动态链接库文件或 .exe 可执行文件),最后再用内置的IL解译执行虚拟机来执行DLL文件中的IL代码。
由于ILRuntime项目是使用C#来完成热更新,因此很多时候会用到反射来实现某些功能。反射是 .NET 平台在运行时获取类型(包括类、接口、结构体、委托和枚举等类型)信息的重要机制,即从对象外部获取内部的信息,包括字段、属性、方法、构造函数和特性等。我们可以使用反射动态获取类型的信息,并利用这些信息动态创建对应类型的对象。
只不过ILRuntime中的反射有两种:一种是在热更新 DLL 中直接使用C#反射获取到 System.Type
类对象;另一种是在 Unity 主工程中通过 appdomain.LoadedTypes
来获取继承自 System.Type
类的 IType
类对象,因为在Unity主工程中无法直接通过System.Type类来获取热更新DLL中的类。
- C#直接反射热更新
由于Android支持JIT(Just In Time)即时编译(动态编译)的模式,即可以边运行边编译,支持在运行时动态生成代码和类型。从Android N开始引入了一种同时使用JIT和AOT的混合编译模式。JIT的优点是支持在运行时动态生成代码和类型,APP安装快,不占用太多内存。缺点是编译时占用运行时资源,执行速度比AOT慢。
比如,C#中的虚函数和反射都是在程序运行时才确定对应的重载方法和类。因此,Android平台可以不借助任何第三方热更新方案,直接使用C#反射执行DLL文件。实际开发时通过 System.Reflection.Assembly
类加载程序集DLL文件,然后再利用 System.Type
类获取程序集中某个类的信息,还可以通过 Activator
类来动态创建实例对象。
而IOS平台采用AOT(Ahead Of Time)预先编译(静态编译)的模式,不支持JIT编译模式,即程序运行前就将代码编译成机器码存储在本地,然后运行时直接执行即可,因此AOT不能在运行时动态生成代码和类型。AOT的优点是执行速度快,安全性更高。缺点是由于AOT需要提前编译,所以APP的安装时间长且占内存。
Mono 在 IOS 平台上采用Full AOT模式运行,如果直接使用C#反射执行DLL文件,就会触发Mono的JIT编译器,而Full AOT模式下又不允许JIT,于是Mono就会报错。因此,IOS平台上不允许直接使用C#反射执行DLL文件来实现热更新。
1 | ExecutionEngineException: Attempting to JIT compile method '...' while running with --aot-only. |
XLua 热更新步骤
-
下载xLua插件,解压后将该目录中Assets文件夹下的所有资源复制到Unity工程的Assets文件夹下。
-
在Unity编辑器(File->Build Settings->Player Settings->Other Settings->Scripting Define Symbols)下中添加
HOTFIX_ENABLE
宏以支持xLua热更新,Unity编辑器和各个手机平台都要添加。建议平时用Lua写业务逻辑时可以关闭HOTFIX_ENABLE
宏,当打包手机版本或者在编辑器下开发补丁时才添加HOTFIX_ENABLE
宏。 -
对所有较大可能变动的类型加上
[Hotfix]
标签。如果可能变动的类比较多,手动添加比较麻烦,一般游戏初次上线时,由于不确定添加哪些类,因此我们可以用反射将当前程序集下的所有类自动加上[Hotfix]
标签,还可以按某个namespace或目录等条件进行设置。 -
新建一个
MonoBehavior
脚本并挂载到需要热更新的场景中,然后在Awake
函数中新建一个Lua虚拟机用于加载和执行Lua热更新脚本文件。代码如下:
1 | // 需要using XLua; |
- 由于xLua内置了从Resources目录下加载Lua文本文件,因此我们新建一个
hotfix.lua.txt
文本文件,然后在里面用Lua实现热更新逻辑。代码如下:
1 | -- CS.XXX表示在C#代码中打[HotFix]标签的XXX类,"Start"表示XXX类中要进行更改的Start函数, |
- 点击Unity编辑器 的XLua/Generate Code 工具,该操作会收集所有打上
[HotFix]
标签的类并生成适配代码。
1 | [ ] |
-
点击Unity编辑器的 XLua/Hotfix inject in Editor 工具,该操作会对所有打上
[HotFix]
标签的类进行IL注入。 -
运行游戏,若发现XXX类的Start函数输出了 hello world,则表示热更新成功,即整个热更新流程就走完了。
xlua 热更原理
从上面看出,xLua实际上是C#和Lua进行交互的桥梁,因此xLua不仅可以用于热更新,还可以借助它用Lua实现游戏中一些性能要求不高的业务逻辑。经过上面的步骤,我们对xLua热更新的流程应该有了一定的了解,现在我们就来深入分析下xLua热更新的原理。
xlua 热更的基本思想是,对于一个类:
1 | public class TestXLua |
通过在IL层面为其注入代码,使其变成类似这样
1 | public class TestXLua |
然后通过Lua编写补丁,使hotfix_Add指向一个lua的适配函数,从而达到替换原C#函数,实现更新的目的。
详细分析
以上一节中的 Test
类为例:
1 | [ ] |
Test类打上[HotFix]
标签后,执行XLua/Generate Code后,xLua会根据内置的模板代码生成器在XLua目录下的Gen目录中生成一个DelegatesGensBridge.cs
文件,该文件在XLua命名空间下生成一个DelegateBridge
类,这个类中的__Gen_Delegate_Impl
函数会映射到xlua.hotfix
中的function
。代码如下:
1 | namespace XLua |
生成适配器代码后,执行XLua/Hotfix inject in Editor后,xLua会使用Mono.Cecil
库对当前工程下的Assembly-CSharp.dll
程序集进行IL注入。IL 是.NET平台上的C#、F#等高级语言编译后产生的中间代码,该中间代码IL再经.NET平台中的 CLR(类似于JVM)编译成机器码让CPU执行相关指令。由于移动平台无法把C#代码编译成IL中间代码,所以绝大多数热更新方案都会涉及到IL注入,只有这样Unity内置的VM才能对热更新的代码进行处理。
由于IL代码是C#代码编译而来的,因此我们可以借用ILSpy工具对C#编译出来的程序集DLL文件进行反编译得到C#源代码,看看IL注入后打上[HotFix]
标签的类的变化。注入后的C#代码如下:
1 | [ ] |
从反编译的 C# 代码看出,xLua 进行 IL 注入时会为打上 [Hotfix]
标签的类的所有函数创建一个DelegateBridge
变量,同时添加对应的判断条件。如果Lua脚本中添加了对应的热更新函数,DelegateBridge
变量就不为空,并将 DelegateBridge
变量中的__Gen_Delegate_Imp0方法指向xlua.hotfix(CS.XXX, “Start”, function(self))
中的具体function。这时由于DelegateBridge
变量不为空,所以C#中的函数就会执行Lua脚本中对应的热更新函数逻辑。但如果没有定义对应的热更新函数,或对应的热更新函数为nil,DelegateBridge
变量就为空,则C#中的函数依然执行原有的函数逻辑。因此,xLua热更新实际上就是在运行时用Lua函数替换对应的C#函数。
其他标签
与xLua热更新相关的标签还包括:[LuaCallCSharp]
、[ReflectionUse]
和 [CSharpCallLua]
,这三个标签都需要生成适配代码,但不需要IL注入。
[LuaCallCSharp]
[LuaCallCSharp]
标签表示如果一个C#类型添加了该标签,xLua会生成这个类型的适配代码(包括构造该类型实例,访问其成员属性、方法,静态属性、方法),否则将会尝试用性能较低的反射方式来访问。
比如,Lua脚本中想调用某个C#函数,就可以在该C#函数上添加[LuaCallCSharp]
标签,这时Lua就会去寻找该函数的适配代码,然后进行调用。如果没有添加该标签,xLua就会尝试用反射的方式进行调用,但性能低于适配代码,而且在IL2CPP下还有可能因为代码剪裁而导致无法访问。
唯一的解决方法就是在Assets目录下新建一个名为link.xml
的XML文件,告诉Unity哪些类型不能被裁剪。[ReflectionUse]
标签就是表示如果一个类打上该标签,xLua就把该类型添加进link.xml
以阻止il2cpp的代码剪裁。因此,要想在各个平台上都能通过Lua访问到C#的类型,就必须在C#类型上添加[LuaCallCSharp]
或[ReflectionUse]
标签。
[CSharpCallLua]
[CSharpCallLua]
标签,表示如果C#想要访问Lua中函数或Table,就要在C#中对应的 Delegate 或 Interface 添加该标签。尽管还有其他映射方式,但最好通过 Delegate 来映射 Lua 中的函数,通过 Interface 来映射 Lua中的Table。
HybridCLR 热更
HybridCLR是一个特性完整、零成本、高性能、低内存的近乎完美的Unity全平台原生C#热更方案。
HybridCLR扩充了IL2CPP的代码,使它由纯AOT Runtime变成“AOT+Interpreter“混合Runtime,进而原生支持动态加载Assembly,使得基于IL2CPP Backend打包的游戏不仅能在Android平台,也能在iOS、Consoles等限制了JIT的平台上高效地以AOT+interpreter混合模式执行。
HybridCLR开创性地实现了“Differential Hybrid Dll“技术。即可以对AOT Dll任意增删改,会智能地让变化或者新增的类和函数以Interpreter模式运行,但未改动的类和函数以AOT方式运行,让热更新的游戏逻辑的运行性能基本达到原生AOT的水平。
基于 IL2CPP 的热更方案
很不幸,不像Mono有Hybrid mode execution,可支持动态加载DLL(见 Unity 补完计划(一):Unity 与 Mono、IL2CPP)。IL2CPP是一个纯静态的AOT运行时,不支持运行时加载DLL,因此不支持热更新。
目前Unity平台的主流热更新方案xLua、ILRuntime之类都是引入一个第三方VM(Virtual Machine),在VM中解释执行代码,来实现热更新。这里我们只分析使用C#为开发语言的热更新方案。这些热更新方案的VM与IL2CPP是独立的,意味着它们的元数据系统是不相通的,在热更新里新增一个类型是无法被IL2CPP所识别的(例如,通过System.Activator.CreateInstance
是不可能创建出这个热更新类型的实例),这种看起来像,但实际上又不是的伪CLR虚拟机,在与IL2CPP这种复杂的CLR运行时交互时,会产生极大量的兼容性问题,另外还有严重的性能问题。
一个大胆的想法是,是否有可能对IL2CPP运行时进行扩充,添加Interpreter模块,进而实现Mono hybrid mode execution这样机制?这样一来就能彻底支持热更新,并且兼容性极佳。对开发者来说,除了解释模式运行的部分执行得比较慢,其他方面跟标准的运行时没有区别。
对IL2CPP加以了解并且深思熟虑后的答案是——确实是可行的!这个想法诞生了HybridCLR,Unity平台第一个支持iOS的跨平台原生C#热更新方案!
HybridCLR 热更原理
HybridCLR扩充了IL2CPP运行时,将它由AOT运行时改造为“AOT + interpreter”双引擎的混合运行时,进而完美支持在iOS这种禁止JIT的平台上以解释模式无缝地运行动态加载的DLL。如下图所示:
更具体一些,至少需要实现以下功能:
- 加载和解析DLL元数据
- 动态注册元数据,其中关键为Hook动态函数的执行流到解释器函数
- 实现一个高效正确的解释器
- 正确处理GC及多线程等运行时机制
与其他流行的 C# 热更新方案的区别
HybridCLR是原生的C#热更新方案。通俗地说,IL2CPP相当于Mono的AOT模块,HybridCLR相当于Mono的Interpreter模块,两者合一成为完整Mono。HybridCLR使得IL2CPP变成一个全功能的Runtime,原生(即通过System.Reflection.Assembly.Load)支持动态加载DLL,从而支持iOS平台的热更新。
正因为HybridCLR是原生Runtime级别实现,热更新部分的类型与主工程AOT部分类型是完全等价并且无缝统一的。可以随意调用、继承、反射或多线程,不需要生成代码或者写适配器。
其他热更新方案则是独立VM,与IL2CPP的关系本质上相当于Mono中嵌入Lua的关系。因此类型系统不统一,为了让热更新类型能够继承AOT部分类型,需要写适配器,并且解释器中的类型不能为主工程的类型系统所识别。特性不完整、开发麻烦、运行效率低下。