腾讯开源⼿游热更新⽅案,Unity3D下的Lua编程
作者|车雄⽣
编辑|⽊环
腾讯最近在开源⽅⾯的动作不断:先是跨平台基础组件Mars宣布开源,腾讯⼿游⼜于近期开源了Unity3D下Lua编程解决⽅案——xLua。xLua,何⽅神圣?有哪些技术细节可以说道说道?
写在前⾯
xLua是Unity3D下Lua编程解决⽅案,⾃2016年初推⼴以来,已经应⽤于⼗多款腾讯⾃研游戏,因其良好性能、易⽤性、扩展性⽽⼴受好评。现在腾讯已经将xLua开源到GitHub。
2016年12⽉末,xLua刚刚实现新的突破:全平台⽀持⽤Lua修复C#代码bug。⽬前Unity下的Lua热更新⽅案⼤多都是要求要热更新的部分⼀开始就要⽤Lua语⾔实现,不⾜之处在于:
1. 接⼊成本⾼,有的项⽬已经⽤C#写完了,这时要接⼊需要把需要热更的地⽅⽤Lua重新实现;
2. 即使⼀开始就接⼊了,也存在同时⽤两种语⾔开发难度较⼤的问题;
3. Lua性能不如C#;
xLua热补丁技术⽀持在运⾏时把⼀个C#实现(函数,操作符,属性,事件,或者整个类)替换成Lua实现,意味着你可以:
1. 平时⽤C#开发;
2. 运⾏也是C#,性能秒杀Lua;
3. 有bug的地⽅下发个Lua脚本fix了,下次整体更新时可以把Lua的实现换回正确的C#实现,更新时甚⾄可以做到不重启游戏;
这个新特性iOS,Android,Windows,Mac都测试通过了,⽬前在做⼀些易⽤性优化。那么,腾讯开源的xLua究竟是怎样的技术?它是为何如此设计的?更令⼈关⼼的是,xLua的性能如何?带着这些问题,InfoQ对其作者进⾏了采访并将内容整理成⽂。
技术背景
腾讯⾃研⼿游,就我了解的项⽬来说,⼤多数游戏引擎都是Unity3D,少数⽤coco2d。
xLua这个插件具体⽤到了哪些游戏中?虽说xLua是2015年3⽉就完成了第⼀个版本,但由于当时项⽬组热更的意识并没有很普遍,需求不是很强烈,xLua的开发资源都调到更紧急的项⽬了。直到15年年底正式集成到我们的apollo⼿游开发框架,才迎来xLua的第⼀个项⽬。到⽬前为⽌,我们已知的应⽤了xLua的项⽬有⼗多个,其中不乏⼀些重量级IP,或者按星级标准打造的产品。
在xLua之前,⾯对iOS⽆法热更新的问题,有⽤ulua的,有⽤slua的,也有项⽬⽤⾃研的脚本语⾔,不过当时⽤⼈更新的项⽬也不多。
热更新流程
⼿游的热更新流程很简单,只是启动时检测下是否有新版本⽂件,有的话就下载覆盖⽼⽂件,然后启动。
下载的⽂件如果是图⽚,模型这些是没问题的,但如果是Unity原⽣的代码逻辑,⽆论是以前的Mono AOT或者后来的il2cpp,都是编译成native code,iOS下是跑不了的。解决办法就⼀个,别⽤native code,别⽤jit,解析执⾏就可以了。包括xLua在内的所有热更新⽀持⽅案都是通过“解析执⾏”来实现代码逻辑热更新。
来⾃xLua的 Hello world
三⾏代码跑lua脚本
⼀个完整的例⼦仅需3⾏代码:
下载xLua后解压到Unity⼯程Assets⽬录下,建⼀个MonoBehaviour拖到场景,在Start⾥头加上这么三⾏:
XLua.LuaEnv luaenv = new XLua.LuaEnv();luaenv.DoString("CS.UnityEngine.Debug.Log('hello world')");luaenv.Dispose();
运⾏就可以看到Console打印的hello world。
1. 第⼀和第三⾏分别LuaEnv的创建以及销毁,所谓LuaEnv可以理解为lua虚拟机,往往整个⼯程⼀个虚拟机即可:
2. DoString⾥头可以是任意合法的lua代码,例⼦中调⽤了UnityEngine.Debug.Log接⼝打印了⼀个log(C#的静态函数在CS下直接可
⽤);
C#调⽤lua系统函数math.max
xLua⽀持把⼀个Lua函数绑定到C# delegate。
我们先声明⼀个delegate,并为它加上CSharpCallLua标签:
[XLua.CSharpCallLua]public delegate double LuaMax(double a, double b);
然后在上⾯那例⼦加上这么两⾏(luaenv销毁前):
var max = luaenv.Global.GetInPath<LuaMax>("math.max");Debug.Log("max:" + max(32, 12));
就那么简单,把lua的math.max绑定到C#的max变量后,调⽤就和⼀个C#函数调⽤差不多了,⽽且,最最重要的是,执⾏了“XLua/Generate Code”后,max(32, 12)调⽤是不产⽣(C#)gc alloc的,既优雅,⼜⾼效!(更详细的可以看XLuaDoc下的⽂档。)
xLua全局观
易⽤性:编辑器下⽆需⽣成代码⽀持所有特性
xLua的易⽤不仅仅体现在编程,还体现在⽅⽅⾯⾯的细节考虑,甚⾄考虑到团队配合⼯作流。xLua仅有两个菜单选择,分别是⽣成代码和清除⽣成代码。在菜单之外,甚⾄只需要在build⼿机版本前执⾏⼀下“Generate Code”即可(这也有API可集成到项⽬的⾃动化打包流程)。
这就是xLua的特⾊功能之⼀:编辑器下⽆需⽣成代码⽀持所有特性。之所以做这个功能,是因为有的项⽬反馈,“⽣成代码”对于策划美术太过遥远,教了很久还是⽼忘;还有个⼤项⽬反馈说由于代码很多,每次⽣成代码后,Unity3D都要转很久。
扩展性:授之以鱼,不如授之以渔
开发中我们往往要⽤到很多东西,⽐如⽤PB和后台交互,解析json格式的配置⽂件等等。虽说我们都可以在C#那到相应的库,然后通过xLua去使⽤这些库,但这效率不⾼,最好能有相应Lua的库。
游戏xml文件修改不少⽅案是直接集成⼀些常⽤的Lua库,但这带来些新问题:这些库不⼀定⽤到,却增⼤安装包;集成的库也不⼀定符合项⽬习惯:json解析有⼈喜欢rapidjson,有⼈爱⽤cjson,所谓众⼝难调;对于某些项⽬,这些库还是不够,还是得⾃⼰去想办法加;
腾讯团队的设计原则是授之以鱼,不如授之以渔,因此xLua:
提供了接⼝、教程,在不修改xLua代码的情况下,开发者可以根据个⼈喜好加⼊库;
通过cmake实现跨平台编译,可以选择伴随xLua⼀起编译,修改⼀个makefile⽂件,搞定各平台编译。
除了很⽅便加⼊第三⽅Lua插件,xLua的⽣成引擎⽀持⼆次开发,可以编写⽣成插件,⽣成⾃⼰所需的⼀些代码以及配置。
性能的保证
游戏的性能备受关注,因此任何模块的变化都需要尽可能不降低甚⾄调优游戏整体的性能。xLua设计原则是在保证运⾏效率的前提下,尽量的保证开发效率。
对于性能这块,有⼏个⾄关重要的版本:
第⼀个版本1.0.0在05年3⽉份发布,当时delegate,interface作为最主要的C#访问Lua的设定,从接⼝层⾯避免了boxing、unboxing、gc alloc,这是⼀个良好的起点。做⼀个通⽤组件的都知道,接⼝⼀开始设计不合理导致的问题很难解决,别⼈已经⽤了,甚⾄已经养成习惯了,很难纠正。
ps:说起这习惯,有的从别的lua插件转为使⽤xLua的童鞋,⼀开始习惯⽤LuaFunction.Call去调⽤lua(xLua也保留了这接⼝,可⽤于性能要求不⾼的场合),他们后期就痛苦了,还得⼀个个地⽅的改回来。
第⼆个很重要的版本是2.0.0(06年3⽉发布),这版本主要⽬标就性能优化,因为当时有个对性能要求极其严苛的项⽬想⽤lua,严苛到什么程度呢?他们觉得C#性能都不放⼼,战⽃系统打算⽤C++写。那版本我们把虚拟机切换到luajit,加⼊了lazyload技术,逐⾏语句的优化,甚⾄关键地⽅不⽤C#提供的容器,⾃⼰写专⽤的(⽐Dictionary实测性能⾼4倍)。。。可以认为我们重做了⼀个xLua。最终他们的选型测试结论是选xLua。
后来和⼀些项⽬的交流发现,项⽬组很关注gc alloc这指标,甚⾄⽐lua和C#间的互调性能指标还要看重。于是有了2.1.0版本(06年7⽉发布),这版本主要⽬标是gc优化,我们重写了反射,反射调⽤的gc减少到原来的⼏分之⼀,性能提⾼了3倍左右。我们设计了⼀个全新的复杂值类型⽀持⽅案,该⽅案⽀持的类型更多(只要struct的字段都是值类型即可),包括⽤户⾃定义的struct(别的⽅案都不⽀持),也更省内存(Vector3为例,内存占⽤只有别的⽅案的30%)。
但也有劣势的地⽅,⽐如你调⽤Vector3上的⼀些⽅法,会⽐ulua、slua要差,因为后⾯两个把Vector3⽤lua重新实现了,这类耗时不⼤的运算相⽐lua和C#直接的适配成本⼩太多了,直接在lua做更划算,不过这差距仅限于那⼏个ulua、slua完全重新实现的类。
上⾯只是三个重⼤节点,我们觉得性能是⼀个需要持续关注的点:平时想到⼀个好点⼦,就会改改,测试下,有提升就加⼊;建⽴性能基线,防⽌某个新功能的加⼊,某个bug的修改把性能给改坏了。
xLua内置Lua代码profiler;⽀持真机调试。⽬前lua profiler只是⼀个⼩⼯具,所以没有做图形化界⾯,典型的⼀个报告如下:
⽹上也有类似的⼯具,我们这个的优势是对C#函数的⽀持以及luajit下更为准确。
真机调试⽀持各lua插件都⼀样,就是把ZeroBraneStudio调试需要⽤到的luasocket库预先编译进去⽽已,没什么值得介绍的地⽅。
技术实现的细节
泛型
泛型类型除了运⾏时动态实例化之外都⽀持,⽽运⾏时动态实例化需要jit的⽀持,iOS下⾏不通。举个例⼦,如果你配了对Dictionary ⽣成代码,那这个类型是可以⽤的,但如果你新更新的lua代码,想⽤⼀个Dictionary ,这个类型之前没⽣成代码,⽽且C#⾥头也没任何地⽅使⽤过,这就不⽀持。静态实例化的泛型,其实和⾮泛型类型处理上没区别。
委托事件的封装
委托封装是根据委托的接⼝⽣成⼀段操作lua栈的代码作为委托的实现。举个例⼦就很好懂了。⽐如对于委托:delegate double Add(double a, double b),我们⽣成如下代码:
public double SystemDouble(double a, double b){ RealStatePtr L = luaEnv.L; int err_func =LuaAPI.load_error_func(L, errorFuncRef); LuaAPI.lua_getref(L, luaReference); LuaAPI.lua_pushnumber(L, a); LuaAPI.lua_pushnumber(L, b); int __gen_error = LuaAPI.lua_pcall(L, 2, 1, err_func);if (__gen_error != 0) luaEnv.ThrowExceptionFromError(err_func - 1); double __gen_ret = LuaAPI.lua_tonumber(L, err_func + 1); LuaAPI.lua_settop(L, err_func - 1); return __gen_ret;}
这代码把调⽤转给lua函数,调⽤委托就是调⽤这函数。
其它⽅案都有delegate的⽀持,⼀般仅⽤于在lua侧主动传递/设置⼀个lua函数到C#,⽽xLua⽀持更为完整,⽐如:
⽀持C#主动⽤delegate来引⽤⼀个lua函数。⽤delegate代替类似object[] Call(params object[] args)的接⼝调⽤lua最⼤的好处是可以避免值类型传递时的boxing/unboxing,还有参数数组,返回值数组的gc alloc;
⽀持返回delegate的delegate,可对应到lua的⾼阶函数;
作为这技术的⼀个延伸,xLua⽀持⽤⼀个c# interface引⽤⼀个lua table,这个特性和⼀些IOC框架配合可以实现C#和Lua间⽆感知(模块间都通过interface耦合,然后由框架去组装)。
⽆缝⽀持⽣成代码及反射
⽣成代码固然重要,已然是各⼤主流⽅案的标配。
反射有的⽅案明确不⽀持,但从项⽬的反馈来说,也是⾄关重要的:有的项⽬代码很多,已经接近苹果的80M Text段的限制,对他们来说,代码量⼤⼩关乎到能否发布,反射⽅式性能不如⽣成代码,但对安装包影响⼩。
这的⽆缝有两个含义:
1. 两者在⽀持的特性以及特性的使⽤⽅式都是⼀致的,两者⽅式间切换,业务逻辑代码不⽤修改,改改配置就可以了;
2. 两者⽆缝配合,⽐如⼀个继承链上,任意⼀个类都可以选择⽣成代码或者反射,⽐如⼦类选择⽣成代码,⽗类由于不常⽤选择了反
射,还是可以在⼦类对象上调⽤⽗类的⽅法;
对于il2cpp的stripping,xLua也考虑到了,只要你对⼀个类配置了ReflectionUse,会⾃动⽣成Unity的l配置⽂件,将该类型列为不剪
裁。
其他Lua插件⼀览
在xLua之外,还有其他的Lua插件,如 uLua、SLua、C#light等。
(1) ulua应⽤项⽬是最多的,由于开源得早,名⽓也最⼤,这是它很⼤的优势。腾讯也有项⽬⽤ulua,反馈⽐较多的问题是它版本的前后兼容问题:
ulua最早是⼀个叫LuaInterface开源库的Unity移植,在2015年初换成cs2lua,⼜在2016年初换成tolua c#,只所以说“换”,是因为这从API⾓度看可认为三个不同的产品,它们间很难升级,⽽且是每换⼀次,之前的版本就彻底不维护了,这给项⽬带来很⼤的困扰。
ulua的第⼀个版本纯反射,并不实⽤,已经淡出市场,现存应⽤⽤后两个版本居多。cstolua版本接⼝⽐较混乱:它保留了第⼀版ulua 接⼝之余,搞了⼀套新接⼝,这两套接⼝之间并不正交,也不是后者完全替代前者,让⼈有点⽆所适从。到了tolua c#版本,这问题解决了,但同时也把反射特性(⽼接⼝)给废了。不过总体来说,ulua在向好的⽅向⾛。
(2) slua代码质量⽐cstolua好很多(很多⼈当时选slua的理由),部分⽀持反射。性能按我们的测试⽤例整体⽐tolua c#略低,另外代码质量对⽐tolua c#已经形成不了明显优势。
(3) C#light,个⼈觉得主要有两个不⾜:
按其实现原理来说,性能不会靠谱,到不了⼿机上实⽤的地步;
由于不完整⽀持C#,本质上只是另⼀种叫C#light的语⾔(C# like?名字倒很贴切),这两者代码配合起来也复杂,甚⾄它能做到⽐C#和lua配合更复杂些
事实也证明了,C# light基本淡出市场,可以忽略不计了。
(4) LSharp是C# light作者的后续作品,倒是可以期盼些,从il层⾯执⾏,这两个问题有望改善,可惜后⾯没了下⽂(不维护了)。
相⽐之下,腾讯在设计xLua时,实现的功能更全,这“全”体现在C#的特性⽀持得更全些,lua虚拟机版本⽀持更全;更易⽤些,⽐如编辑器下不⽤⽣成代码;另外,性能也不⽐它们差。
说到功能更全,可能有⼈抱怨并没有pb,json,sqlite等等功能。其实稍熟悉lua的⼈都知道,那只是把⼀些现成lua扩展编译进去⽽已,算不上是它做了这些功能。预集成好处是⽅便,坏处是没选择的余地,⽤不上的东西会占空间,⽤得上的东西也不⼀定是你喜欢的库。
xLua的lua库基于cmake编译,要加这些库门槛很低,有教程,改⼀个Makefile搞定各平台编译。在C#测也提供了api来初始化这些库。总⽽⾔之,xLua的原则是授之以渔。
xLua的灵感来源
xLua⽴项当初,考察了当时能到的所有⽅案,并分析各⽅案优劣,定出第⼀个版本的特性,⼤体是基于NLua基础上加上代码⽣成。介绍下NLua,NLua的作者就是LuaInterface的作者,NLua可以认为是LuaInterface的升级版,⽽前⾯也说了,第⼀版uLua是LuaInterface的Unity移植版本,也不能算原创。
因为是“站在”⽣成代码当时有看过cstolua的实现(那时还没挂ulua的牌),觉得它通过硬编码字符串拼接的⽅式维护性不太好,就⽤模版来做。感觉这步是⾛对了,后续⽣成代码调整起来⽐较简单,这对性能调优很有好处。
经过⼗多个版本的迭代,优化,现在NLua的影⼦⽐较淡了(NLua仅⽀持反射,⽽xLua的反射在2.1.0版本已经完全重写),就剩下C#引⽤类型对象在lua的表达的思路没变。
此外,遇到需要调整较⼤的bug,我们也会先看同类插件是不是已经解决了,对⽐他们的修改⽅案和我们的,选更适合的。
xLua背后的研发与团队
xLua⽬前迭代了⼗多个版本,从第⼀个项⽬开始,平均⼀个⽉⼀个版本。研发团队⼈员⽬前有⼀个全职开发。测试使⽤的是腾讯互娱的公有资源,很规范:有⼀套不断补充的功能⾃动化⽤例,性能测试也建⽴了基线,确保不会因为功能迭代⽽影响性能。腾讯互娱有专门的客户端兼容性测试实验室,⾄少中版本号以上的变动我们会提交给他们针对top 100的机型进⾏兼容性测试。
⾄于lua,luajit的更新跟进,先说luajit吧,luajit变动不⼤,我第⼀次⽤luajit是11年,那时⽀持到lua5.1,现在也还是lua5.1,中间只是⼀些bug的修复,性能优化,或者新平台⽀持等,我们要做事情不多。⽽lua中版本间差别还是蛮⼤的,但中版本变动并不频繁,从5.1到5.2⽤了6年,从5.2到5.3⽤了3年,5.3是2015年初发布的,我个⼈觉得到下⼀次中版本变动会很久,不亚于甚⾄⼤于5.1到5.2的时间跨度(5.2个⼈认为只是⼀个过渡版本)。
⼩版本⼀般改改bug,等稳定后直接升级就可以了,不需要做很多事情,⽬前xLua的lua版本⽤的是lua的最新版本5.3.3。
聊聊C#,谈谈Lua
C#在开发效率和运⾏效率平衡得很好,语⾔特性也⽐较全,个⼈觉得是很优秀的⼀门语⾔。在Unity3D上的缺憾主要是其mono版本太低,⼀些很古⽼的bug,⽐如著名的foreach性能问题很多个版本都没解决,新的特性,⽐如await⼜不⽀持。
另外在⼿机平台iOS不允许应⽤下载native code运⾏,jit,刚好把mono应⽤的热更新给堵死了,要是mono虚拟机能够做到像luajit那样,jit ⾛不通就⽤interpret模式,其实就没lua或者其它热更新⽅案什么事了。