深⼊理解xLua热更新原理
热更新简介
热更新是指在不需要重新编译打包游戏的情况下,在线更新游戏中的⼀些⾮核⼼代码和资源,⽐如活动运营和打补丁。热更新分为资源热更新和代码热更新两种,代码热更新实际上也是把代码当成资源的⼀种热更新,但通常所说的热更新⼀般是指代码热更新。资源热更新主要通过AssetBundle来实现,在Unity编辑器内为游戏中所⽤到的资源指定AB包的名称和后缀,然后进⾏打包并上传服务器,待游戏运⾏时动态加载服务器上的AB资源包。代码热更新主要包括Lua热更新、ILRuntime热更新和C#直接反射热更新等。由于ILRuntime热更新还不成熟可能存在⼀些坑,⽽C#直接反射热更新⼜不⽀持IOS平台,因此⽬前⼤多采⽤更成熟的、没有平台限制的Lua热更新⽅案。
为什么需要热更新
⼀般情况下,游戏开发并测试完后就要提交应⽤商店审核,其中苹果商店审核周期最长,审核通过后才能上线发布,这时玩家才能下载安装游戏。在如今快节奏的⼿游时代,游戏的⽣命周期⼤幅缩短⽽且更新还很频繁,如果每次游戏更新都要重新编译游戏打包,然后等待审核发布,最后⽤户再下载安装游戏,那玩家的耐性早没了。
⽽且游戏安装包还不能太⼤,不然玩家还没等到游戏下载安装好就失去兴趣了。正确的⽅式是将游戏中⼀些⾮核⼼的资源打包并上传服务器,等游戏下载安装好实际运⾏时才在线动态加载资源,从⽽减少游戏安装包的⼤⼩。因此,我们急需⼀种不需要重新编译打包就能在线更新游戏中的⼀些⾮核⼼代码和资源,⽽这种⽅式就是热更新。
热更新分为资源热更新和代码热更新,资源热更新主要是指将游戏中⼀些资源打包成AB包,并上传服务器,等游戏运⾏时才从服务器上加载资源。通过这种⽅式可以减少游戏安装包的⼤⼩,减少⽤户下载游戏的时间。其次,可以通过这种⽅式动态加载游戏中的资源,⽐如节假⽇有活动运营时,可以直接在线更新游戏中的场景,不需要重新发布游戏和重新下载安装游戏,进⽽提⾼玩家的游戏体验。
代码热更新,实际上也是⼀种资源热更新,它可以在不需要重新编译打包的情况下在线更新游戏的⾮核⼼代码,⽐如游戏中的活动运营、补丁修复和添加⼩功能等。如果没有代码热更新技术,每次游戏⼀有改动就需要重新编译打包发布。试想如果新版本游戏变化不⼤,只能更新⼏个⼩功能,却需要重新下载安装游戏,玩家会种有浪费时间和被欺骗的感觉,这会极⼤地降低玩家的游戏体验。更何况App Store的严格审核机制,长期更新打包发布游戏会丢失⼤量⽤户。因此,热更新是⼿游开发的必备技术之⼀。
由于Unity开发⼤多采⽤C#作为脚本语⾔,⽽C#是⼀门编译型语⾔,只有编译后才能运⾏,⽽移动平
台不⽀持C#编译,即使把C#代码像资源⼀样下载到移动平台也⽆法运⾏。因此,不能直接⽤C#进⾏热更新,除⾮采⽤ILRuntime热更新和C#直接反射热更新,但这两种⽅式都有各⾃的局限性,最好的⽅式是⽤⼀种不需要编译就可以直接在移动平台上运⾏的脚本语⾔进⾏热更新,⽽⼩⽽精的Lua就是最好的选择。
三种热更新⽅案
Lua热更新
Lua热更新解决⽅案是通过⼀个Lua热更新插件(如ulua、slua、tolua、xlua等)来提供⼀个Lua的运⾏环境以及和C#进⾏交互。Lua是⼀门⾮常⼩巧的语⾔,⽤C语⾔编写⽽成,⼏乎可以在任何操作系统和平台上运⾏,具体语法参考。⽬前⽤的⼈最多,性能最好的当属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平台上采⽤模式运⾏,如果直接使⽤C#反射执⾏DLL⽂件,就会触发Mono的JIT 编译器,⽽Full AOT模式下⼜不允许JIT,于是Mono就会报错。因此,IOS平台上不允许直接使⽤C#反射执⾏DLL⽂件来实现热更新。
1ExecutionEngineException: Attempting to JIT compile method '...' while running with --aot-only.
xLua热更新步骤
学编程,先跑起来,再去研究原理。下⾯是xLua热更新的步骤:
1)、下载,解压后将该⽬录中Assets⽂件夹下的所有资源复制到Unity⼯程的Assets⽂件夹下。
2)、在Unity编辑器(File->Build Settings->Player Settings->Other Settings->Scripting Define Symbols)下中添加HOTFIX_ENABLE宏以⽀持xLua热更新,Unity编辑器和各个⼿机平台都要添加。建议平时⽤Lua写业务逻辑时可以关闭HOTFIX_ENABLE宏,当打包⼿机版本或者在编辑器下开发补丁时才添加HOTFIX_ENABLE宏。
3)、对所有较⼤可能变动的类型加上[Hotfix]标签。如果可能变动的类⽐较多,⼿动添加⽐较⿇烦,⼀般游戏初次上线时,由于不确定添加哪些类,因此我们可以⽤反射将当前程序集下的所有类⾃动加上[Hotfix]标签,还可以按某个namespace或⽬录等条件进⾏设置。代码如下:
1 2 3 4 5 6 7 8 9 10 11[Hotfix]
public static List<Type> by_property
{
get
{
// 需要using System.Linq;
return (from type in Assembly.Load("Assembly-CSharp").GetTypes()                where type.Namespace == "XXXX"
select type).ToList();
}
}
4)、新建⼀个MonoBehavior脚本并挂载到需要热更新的场景中,然后在Awake函数中新建⼀个Lua虚拟机⽤于加载和执⾏Lua热更新脚本⽂件。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15// 需要using XLua;
void Awake()
{
// 新建⼀个Lua虚拟机,为减少开销,建议全局唯⼀。
LuaEnv luaEnv = new LuaEnv();
// DoString表⽰执⾏Lua代码,由于Unity不能识别.lua⽂件,只能把Lua⽂件变成⽂本⽂件进⾏读取。    // require⽤于加载Lua⽂件,内置多个Loader加载器,我们也可以⾃⼰写Loader。
luaEnv.DoString("require 'hotfix'");
}
// 在游戏对象被销毁时,释放Lua虚拟机内存。
void OnDestroy()
{
luaEnv.Dispose();
}
5)、由于xLua内置了从Resources⽬录下加载Lua⽂本⽂件,因此我们新建⼀个⽂本⽂件,然后在⾥⾯⽤Lua实现热更新逻辑。代码如下:
1 2 3 4 5// CS.XXX表⽰在C#代码中打[HotFix]标签的XXX类,"Start"表⽰XXX类中要进⾏更改的Start函数,
// function(self)表⽰Start函数更改后的函数逻辑,待热更新完后XXX类的Start函数就会执⾏function(self)中的代码逻辑。xlua.hotfix(CS.XXX, "Start", function(self)
print("hello world")
end)
6)、点击Unity编辑器的XLua/Generate Code⼯具,该操作会收集所有打上[HotFix]标签的类并⽣成适配代码。
7)、点击Unity编辑器的XLua/Hotfix inject in Editor⼯具,该操作会对所有打上[HotFix]标签的类进⾏IL注⼊。
8)、运⾏游戏,若发现XXX类的Start函数输出了hello world,则表⽰热更新成功,即整个热更新流程就⾛完了。
xLua热更新原理
从上⾯看出,xLua实际上是C#和Lua进⾏交互的桥梁,因此xLua不仅可以⽤于热更新,还可以借助它⽤Lua实现游戏中⼀些性能要求不⾼的业务逻辑。经过上⾯的步骤,我们对xLua热更新的流程应该有了⼀定的了解,现在我们就来深⼊分析下xLua热更新的原理。以该类为例:
1 2 3 4 5 6[Hotfix]
public class Test : MonoBehaviour
{
void Start ()
{
// 接下来对Start函数进⾏热更新,改为输出Hello World。
7 8 9 10 11 12 13 14        Debug.Log("test");    }
void Update ()
{
}
}
Test类打上[HotFix]标签后,执⾏XLua/Generate Code后,xLua会根据内置的模板代码⽣成器在XLua⽬录下的Gen⽬录中⽣成⼀个DelegatesGensBridge.cs⽂件,该⽂件在XLua命名空间下⽣成⼀个DelegateBridge类,这个类中的__Gen_Delegate_Imp*函数会映射到xlua.hotfix中的function。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17namespace XLua
{
public partial class DelegateBridge : DelegateBridgeBase
{
// DelegateBridge类的关键函数__Gen_Delegate_Imp*
public void __Gen_Delegate_Imp0(object p0)
{
RealStatePtr L = luaEnv.rawL;
// luaReference就是指向xlua.hotfix(CS.XXX, "Start", function(self))的function            int errFunc = LuaAPI.pcall_prepare(L, errorFuncRef, luaReference);
ObjectTranslator translator = anslator;
translator.PushAny(L, p0);
PCall(L, 1, 0, errFunc);
LuaAPI.lua_settop(L, errFunc - 1);
}
}
}
⽣成适配器代码后,执⾏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才能对热更新的代码进⾏处理。下⾯是Unity使⽤Mono VM的脚本编译执⾏过程:
Mono是社区对.NET Framework的跨平台实现⽅案,实现了.NET Framework的绝⼤部分类库,因此基于Mono研发的Unity引擎才具有跨平台能⼒。⽽Mono VM就是基于Mono框架实现的,不同的平台实现不同的Mono VM,从⽽可以不同平台上执⾏C#脚本。由于IL代码是C#代码编译⽽来的,因此我们可以借⽤ILSpy⼯具对C#编译出来的程序集DLL⽂件进⾏反编译得到C#源代码,看看IL注⼊后打上[HotFix]标签的类的变化。注⼊后的C#代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39[Hotfix(HotfixFlag.Stateless)]
public class Test : MonoBehaviour
{
// 构造函数对应的DelegateBridge变量
private static DelegateBridge _c__Hotfix0_ctor;
private static DelegateBridge __Hotfix0_Start;
private static DelegateBridge __Hotfix0_Update;
private static DelegateBridge __Hotfix0_TestFunc;
public Test()
: this()
{
_c__Hotfix0_ctor?.__Gen_Delegate_Imp0(this);
}
private void Start()
{
DelegateBridge _Hotfix0_Start = __Hotfix0_Start;
// 如果lua脚本⾥定义了热更新函数,就执⾏对应的热更新函数逻辑。        if (_Hotfix0_Start != null)
{
_Hotfix0_Start.__Gen_Delegate_Imp0(this);
}
else
{
Debug.Log((object)"test");
}
}
private void Update()
{
__Hotfix0_Update?.__Gen_Delegate_Imp0(this);
}
private void TestFunc()
{
__Hotfix0_TestFunc?.__Gen_Delegate_Imp0(this);
}
}
从反编译的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]标签表⽰如果⼀个C#类型添加了该标签,xLua会⽣成这个类型的适配代码(包括构造该类型实例,访问其成员属性、⽅法,静态属性、⽅法),否则将会尝试⽤性能较低的反射⽅式来访问。⽐如,Lua脚本中想调⽤某个C#函数,就可以在该C#函数上添加[LuaCallCSharp]标签,这时Lua就会去寻该函数的适配代码,然后进⾏调⽤。如果没有添加该标签,xLua就会尝试⽤反射的⽅式进⾏调⽤,但性能低于适配代码,⽽且在IL2CPP下还有可能因为代码剪裁⽽导致⽆法访问。IL2CPP是Unity推出的⽤来替代Mono VM的编译
器,IL2CPP的脚本编译过程如下:
从上图看到,IL2CPP实际上是将C#编译得到的IL代码转换成C++代码,然后再由各个平台的原⽣C++编译器将C++代码编译成原⽣汇编代码(ASM汇编指令)。虽然代码转换成了C++代码,但我们知道C#中的内存是由GC⾃动管理,⽽C++需要⼿动管理内存,因此还需要⼀个
IL2CPP VM⽤于GC管理等操作。IL2CPP的优点性能得到提升,运⾏速度更快,其次是编译成C++后
反编译更难,进⽽安全性更⾼。缺点就是IL2CPP打包速度慢,⽽且转换后的C++代码量猛增,进⽽可能超过iOS平台可执⾏⽂件⼤⼩的限制。从2019年8⽉开始,Google Play上架的APP必须⽀持64位,因此只能发布时只能采⽤IL2CPP了,但平时开发调试时还是可以采⽤Mono,因为Mono出包快。
要想解决这个问题就要对UnityEngine下的代码进⾏Strip裁剪,但这容易导致反射时不到对应的类型。因为Unity在程序运⾏前会对代码中没⽤引⽤到的地⽅进⾏裁剪,⽽反射必须在程序运⾏时才能确定要引⽤的类,如果进⾏裁剪可能会导致程序运⾏时通过反射不到对应的类或函数,从⽽报错。唯⼀的解决⽅法就是在Assets⽬录下新建⼀个名为l的XML⽂件,告诉Unity哪些类型不能被裁剪。[ReflectionUse]标签就是表⽰如果⼀个类打上该标签,xLua就把该类型添加进l以阻⽌il2cpp的代码剪裁。因此,要想在各个平台上都能通过Lua访问到C#的类型,就必须在C#类型上添加[LuaCallCSharp]或[ReflectionUse]标签。
[CSharpCallLua]标签,表⽰如果C#想要访问Lua中函数或Table,就要在C#中对应的Delegate或Interface添加该标签。尽管还有其他映射⽅式,但最好通过Delegate来映射Lua中的函数,通过Interface来映射Lua中的Table。
在实际开发时,这些标签可以通过⾃定义配置来⾃动添加,配置⽂件放在XLua⽬录下的Editor⽂件夹中,下⾯是具体的配置建议:
提交更改是内存条吗
1)、游戏刚上线不确定哪些类需要添加[Hotfix]标签时,可以使⽤反射把当前程序集下的所有类型都加上[Hotfix]标签,还可以设置条件进⾏过滤。
2)、⽤反射出所有函数参数、字段、属性、事件涉及的delegate类型,标上[CSharpCallLua]⽤于C#映射Lua中的函数。
3)、业务代码、引擎API、系统API等需要在Lua⾥⾼性能访问的类型,标上[LuaCallCSharp],这样就Lua就会从⽣成的适配代码⾥从⽽性能更⾼,不然Lua会尝试⽤反射的获取对应的类型,这会产⽣⼤量的性能消耗。
4)、引擎API、系统API在IL2CPP下可能被代码剪裁(C#⽆引⽤的地⽅都会被剪裁),这样Lua采⽤反射的⽅式获取对应的类型时就会出错。因此,如果觉得可能会新增C#代码之外的API调⽤,那么这些API所在的类型就必须添加[LuaCallCSharp]或[ReflectionUse]标签。

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。