腾讯开源手游热更新方案:Unity3D下的XLua技术内幕(一)
发表于2017-01-04
目前Unity下的热更新方案有不少,这些方案都要求要热更新的地方一开始就得用脚本来实现,这带来一些问题:
1、接入成本高,有的项目已经用C#写完了,这时要接入需要把需要热更的地方用脚本重新实现;
2、即使一开始就接入了,也存在同时用两种语言开发难度较大的问题,有些项目只是想fix下bug而已,却被迫使用两种语言开发,明显代价有点大;
3、脚本性能不如C#;
对于双语编程的问题,有人尝试通过把C#翻译成脚本来解决(js,lua都见过)。这种翻译往往在C#语法支持上都不完备,而且,性能问题仍然未解决,甚至更严重(除了脚本本身比C#差之外,自动翻译性能会高效?我持保留态度)。
反观Unity之外有不少方案,比如ios下的jspatch,android下的tinker等,并没这约束。以jspatch为例,它支持把一个oc函数替换为js实现,平时开发可以只写oc,只有出现bug,才需要写个js去fix。
把这两个方案应用到Unity技术上可能可以行得通,但问题是jspatch是用来修复oc,android是修复java的,难道要把逻辑在ios下用oc写一份,android下用java写一份,然后编辑器又另外一份?这在需求急剧变化的游戏领域显然是不现实的。
Unity下的热补丁实现也有,但目前看到的方案,都不支持iOS,由于审核制度的存在,iOS热更才是硬需求,一切不支持iOS的热更方案都是耍流氓!
好吧,重头戏来了,xLua最新的热补丁技术比较完美的解决了上面的问题!等等,不是说好了忘掉lua,怎么说起xLua了?好吧,至少项目组大多数人不需要懂Lua,有一个就可以了,这人大多数时间也不用写Lua,出了线上问题才需要。
xLua热补丁技术支持在线把一个C#(方法,属性,事件等等)实现替换成Lua实现。这意味着你可以:
1、开发只用C#;
2、运行也是C#,性能可以秒杀lua;
3、出问题了才用Lua来改掉C#出问题的部位,替换甚至做到不用重启游戏;
用完即走,是小龙哥对一个好工具的定义,xLua这个特性也如此:没问题的时候你感觉不到它的存在,出了问题才需要它来救场。
极其简单的统一接入方式
在启动的地方加入几行代码即可:
1 2 3 4 5 | if (File.Exists( "/path/to/hotfix.lua" )) { LuaEnv luaenv = new LuaEnv(); luaenv.DoString(File.ReadAllText( "/path/to/hotfix.lua" )); } |
好,你的应用就有了热补丁的能力了,没有hotfix.lua(名字你可以安装你的需要改)时,什么事情都不干,自然也没开销。
你需要在可能需要热更新的类型打上[Hotfix]标签即可(打了标签的类有极其轻微的性能损失,后面会分析)。
一个API打补丁
通过某种方式下载了hotfix.lua,hotfix.lua就是修复代码。
先看简单示例:
1 2 3 4 5 | xlua.hotfix(CS.HotfixTest, 'Update' , function() print( 'Update in lua' ) end) |
上述代码把C#的HotfixTest类(继承于MonoBehaviour)的Update函数替换成lua的实现。相关API就一个:
1 | xlua.hotfix( class ,[method_name], fix) |
class是C#的类名,method_name可选,如果写了就替换某函数,没有就是替换类,fix如果给了method_name就提供个函数,没给就通过table提供一组函数。
lua函数的实参,以及属性,事件这些的修复,可以看详细指南。
Stateless和Stateful
打Hotfix标签时,默认是Stateless方式,你也可以选Stateful方式,我们先说区别,再说使用场景。
Stateless方式是指用Lua对成员函数修复时,C#对象直接透传给作为Lua函数的第一个参数。
Stateful方式下你可以在Lua的构造函数返回一个table,然后后续成员函数调用会把这个table给传递过去。
Stateless比较适合无状态的类,有状态的话,你得通过反射去操作私有成员,也没法新增状态(field)。Stateless有个好处,可以运行的任意时刻执行替换。
Stateful的代价是会在类增加一个LuaTable类型的字段(中间层面增加,不会改源代码)。但这种方式是适用性更广,比如你不想要lua状态,可以在构造函数拦截那返回空。而且操作状态性能比反射操作C#私有变量要好,也可以随意新增任意的状态信息。缺点是,执行成员函数之前就new好的对象,接收到的状态会是空,所以需要重启,在一开始就执行替换。
两条指令的性能开销
热补丁基本原理非常简单,所以其性能、内存、安装包影响一目了然。
xLua会在C#编译成il后插入一个处理,该处理会从il层面为每个打了Hotfix标签的类型的函数开头加入几个il指令,如果用C#描述是这样的:
加il指令前:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public int Add( int a, int b) { return a + b; } // 加了il指令: static Func< object , int ,= "" int = "" > hotfix_Add = null ; public int Add( int a, int b) { if (hotfix_Add != null ) return hotfix_Add( this , a, b); return a + b; } object ,> |
判断一个delegate是否为空(没用过C#的童鞋可以理解为函数指针),如果不为空就调用这个delegate,而这个delegate指向的是一个把调用转发给lua的函数。
由于是在il层面做这事,所以不会影响到源码。il2cpp是在我们处理完成后执行,所以这方案il2cpp也能行得通,mono就更不在话下了。换句话说,理论上支持所有Unity支持的平台。
上面的delegate是静态的,所以内存开销不大,一个类(和对象实例个数没关)的每个函数对应一个delegate引用。
对函数有轻微影响,没hotfix时就一个指针为空判断而已(两条指令)。Windows测试开销大约是空函数调用的十分之一到五分之一,window下C#空函数能够到每秒十个亿,加了那两指令也将近十个亿,这数字对老王来说也不算小目标了。
对安装包大小有少许影响,影响程度和你本身函数的复杂度有关,很难有个普适的数据。用每个函数都有几百行的类型测出结果是影响极少,如果全是空函数结论就是影响很大了。
热补丁支持的C#特性
各种函数,包括私有/公有,静态/成员,操作符重载,泛化函数。
构造函数,析构函数,和普通函数不一样的是,构造函数和析构函数并不是用lua函数替换了,而是执行了lua函数后还会执行原来的逻辑。
属性,包括私有/公有,静态/成员,泛化的。
事件的add/remove。
泛化类型。
总而言之,目前已知唯一尚不支持的是类静态构造函数。
总结
热补丁实现并不困难,思维定势的跳出才是关键。记得有次总监发了jspatch和tinker的介绍给我,因为之前已经了解过他们的思路,我不假思索的回了“热补丁在Unity下做不到”。其实换一种思路就海阔天空。
下回预告及拉票
热补丁是xLua的突破之一,也不是实现最难的突破,而且这篇也仅是其最基本原理的介绍,深入点还有属性,事件,操作符支持的原理,重载是怎么实现的,而构造函数、析构函数的处理也略有不同,而泛化的处理思路更是大不一样。除了功能关注点,还有性能上怎么避免GC,适配代码如何收敛的考虑。
下面会有一系列的剖析文章,介绍xLua的一些突破的实现原理,敬请关注。
号外号外:2017年xLua第一炮,对外开源了!传送门:https://github.com/Tencent/xLua,老板,给个Star呗。