《大掌门》欧阳刘彬:基于Cocos2d-x引擎开发经验分享
大掌门》欧阳刘彬分享的内容同样是与Cocos2D-X和跨平台开发有关,在演讲中他详细分享了为什么会选择Lua。
欧阳刘彬:首先感谢CocoaChina的邀请,跟大家分享一下我们《大掌门》(排名)在游戏开发过程当中使用Cocos2D所开发的一些经验。刚才凌聪讲的内容 感觉已经是一个比较完整的、系统的东西了,我们本身在刚开始做的时候,我觉得他们那边应该是有一个比较强大开发团队在下面做了一些支撑的事情。我们做的一 些事情,其实大部分他刚才已经提到了,我们做的可能不像他们那么系统,但是也有一些东西是跟他很像的一个过程,我就我们这边的一些经验来跟大家再做一些分享。
我们为什么选Cocos2D-X,其实最大的原因就是跨平台。当时为什么选择Lua呢?大概我们从去年春天的时候开始做这个事情,我去年春天也来了 CocoaChina的一个会,当时我们选一个脚本,觉得比较合适的可能还是用Lua,那时候已经出来了一段时间了。它的一些特点其实刚才也已经提到过 了,我们觉得它,支持一些动态更新,它的容错性比别的好,我们找一个靠谱的C++程序员说实话还是比较难的,我们在开发速度上会比其他的语言要快一些。
我们的一些基本情况,其实我们用Cocos2D是比较老的一个版本了,我们大概在去年3、4月份就开始做,基于上面做了一些改进,后面因为在开发的过程当 中,我们觉得一个底层的框架已经选好了之后就尽量不要去动它,因为这个说实话是一个伤筋动骨的事情,但是我们会继续用Cocos2D的框架,我们会选择 2.0的版本,老的那一套东西一直在上面做。我们做的一些事情没有那么系统,但是也做了一些。首先我们在上面做了我们的一些事件管理,做了一些底层的UI 控件,做了我们的网络通信模块和加解密的模块。我们的一个特点就是说,我们所有的游戏逻辑都是用Lua去实现的,我们在Lua里面也去实现了一些MVC的 框架等等,但是对于一些性能要求比较高的东西,比如说加解密这种东西可能还会在C上实现,在Lua里面去用新的接口。
首先我要分享的就是我们版本更新的机制,其实刚才凌聪也提到了,我们为什么要用Lua,就说它能够走游戏内更新的方式更新版本,比如说我们拿App Store来举例,你去提交一个版本,我们以往的经验,首次提交的话可能需要一个月的时间,后续的更新审核可能5个工作日,也就一周过去了。这个提交从审 核里面如果一个月过了,当然大家相当高兴了。中间的过程当中其实经常会遇到一些非技术的原因把这个打回来,比如说你的截图,或者是一些信息写的不对,中间 来回打会可能也会耽误时间,这个审核周期太长了,我们觉得是一件让人不太好接受的事情,尤其是一些非技术原因。
再有一个问题就是,像我们的游戏版本,我们游戏的安装包现在说实话,从最开始的50兆到现在变成100兆已经比较大了。假设我们去实现的话,如果我发现游戏里面一个比较重要的Bug,我去修复这个 Bug,提交之后玩家再去更新,按照现有的机制,玩家就是下一个完整的安装包下来。国内其实像凌聪刚刚提到了,在Android市场有一定的新的机制,但 是我了解在Android的4.0上面才支持这种方式,在比较低的版本上还是不支持增量的更新。
在腾讯这边确实是做得比较好,它的QQ游戏大厅是支持增量 的更新的。我刚才提到的,就是说更新一个版本,我们加一个什么玩法有一个全新的包,这种带来的问题就是每次更新都要下一个完整的包,用户的体验是非常差 的,对于用户整个留存都会有影响。我们的解决方案就是,所有的游戏逻辑都去用Lua实现,我们实现一套游戏内更新的机制,每次游戏升级的时候,基本上只要 更新自己的Lua文件就行了,这样就可以及时、随时的更新,我们不需要等任何人的审核。还有就是快速,我们走增量更新的方式,只需要去下载我们改动过的一 些资源和代码。在这里也可以插一句,刚才也人问到苹果基于这个审核的条款,在我的理解,实际上苹果只是给自己设一个底线。
如果游戏只是对于一些正常的功能 逻辑的更新,走脚本的方式的话应该还是可以的,只不过你如果把游戏整个更新一遍,变成一个完全不同的游戏,那肯定是不行的,我认为苹果这种条款,可能是作 为它自己的一个底线放在那儿。
我们整个游戏更新的过程,其实也是类似的,我们的后台服务器分为了版本管理的服务器、资源服务器、游戏逻辑服务器。首先我们的游戏启动之后,第一次会带着 自己的版本向我们的服务器版本去问,就是我现在的版本号是一,最新的版本号是多少。如果没有更新的话,后续它直接往游戏逻辑服务器发起后续的通信需求,但 是如果有更新的话,我们的版本服务器会告诉他最新的版本已经到了2.0了,会返回一些需要更新的文件列表回来,然后我们手机端就会从资源服务器把这些所有 需要的文件全部下载下来,然后在自己这里实现一些资源的重载,整个脚本的一些Reload,接下来再往我们的服务器做通信的时候,就已经变成一个最新的版 本了。
我们如果实现了这种游戏自己更新的机制的话,我们就要考虑到一个问题,就是我们客户端上面的那些代码文件和一些资源文件怎么去放。我们一个简单的想法就是 说,我们的游戏逻辑都是拿Lua实现的,我们的代码可能就是直接放一个Lua文件在手机上面。它从V1更新到V2的话,我们就从资源服务器直接下载一份最 新的Lua文件把它覆盖掉。但是这样有一个问题,我这个PPT里面其实有一些忽略,在我们的后台有一些版本的管理系统,我们对于每一个版本都有资源、后台 代码和数值的配置都是有保存的,我们随时可以从后台界面上去做切换,把我们的游戏升级到某一个版本,我们也支持游戏多版本的并存。比如说我们开发人员在测 一个版本,但是玩家玩的是另外一个版本,我们自己测试好之后再切换上线。所以我们一套东西之后,你就会考虑到一个问题,就是说假设我们测试不够充分,一个 版本放出去之后,你可能突然发现一个重大的Bug,你可能要么给它进行修复,如果很快的找不到解决办法,你只能马上把版本回复回去。第一种方式就是说,如 果客户端只放一个版本的话,我的回滚希望更快,就是说代码在之前,唯一的代码在你手上实际上是有的,希望服务器返回某一个状态,你立刻自己就能够切回到 V1的版本上去,而不需要重新把那些代码再重新Download一遍。
还有一个办法就是每个版本都通过一个版本号,比如说这是我V1的版本,我增量后续更新的时候,可能会把V1先拷贝成V2,然后再去做增量更新。比如说V1 到V2有哪些文件变了,比如有两个文件,A.Lua或者是B.Lua,我可能会实现增量的更新,但是我的V1的B点Lua和V2的B点Lua实际上是没有 变的,它们会占用额外的存储空间,会有浪费。所以我们最后的方案,也结合我们后台的那套版本管理的系统,就是实现的方案是把我们所有的资源和我们的脚本, 包括我们的数值配置,我们的脚本都算是一种资源,我们做md5重命名,通过在我们的手机端、游戏后台、资源管理服务器都会有一个数据库去管理这种每一个版 本下面所有的资源的映射关系,就是原始的文件名和最后打包之后会存下来,每一个版本都会存下来。接下来真正游戏逻辑运行起来之后,需要通过一个自定义的 Lua Loader去查找这些文件。
这是我们打包的流程,首先会打包的时候把这些文件算一个码然后进行重命名,同时会生成一个资源的数据库,接下来把这些资源的数据库和脚本文件放到这个包里 面去。然后游戏逻辑里面,比如说我们的Lua代码去装载一个文件的话,语法就是Require一个A,就会去查找A.Lua这个文件。我们现在面临的问题 就是,这个A.Lua实际上已经不存在了,我们已经重命名了,我们需要有一个机制,让Lua再去Require这个A的时候要找到一个文件,所以我们需要 自己实现一个Lua Loader,实际上Lua那个引擎里面是有相应的方法的,我们可以在那个引擎里面注册一个自己的Loader,在它被触发之后,首先根据我们要装载的文 件,比如说A,在我们的List里面去查这个文件真实的文件名是什么。比如说我们的客户端现在是V1的版本,它去V1的里面去查到的文件是这个,那么他可 能就会最终度曲的文件就是这个文件,再最终通过Lua的Loadbuffer这个函数转进来,再走后续的流程。如果是V2的版本,就找到另外一个文件,最 终回进入到这个Loadbuffer当中来。这样的话,我们在整个游戏逻辑里面有一个变量去标识当前客户端的版本是1还是2,但是实际上他们版本1和版本 2的代码可能都会在我们的手机上有,可以很快速的去切换。像我们一个没有变化的B点Lua,就以我们刚才提到的这种方式去实现的话,他们查的话是不同的, 但是找到的文件是同一个。
接下来大家就会想到,游戏逻辑如果去用脚本实现的话,那你的代码怎么加密?像JS一般有混淆器。但是这种方式总之还是能有人去反编译的,如果我们有一些比 较核心的业务逻辑放在前端的代码里面,我们不希望被别人很轻易的看到。其实我们如果在打包流程当中已经做了这些事情,我们已经有了自己自定义的Lua Loader的话,加解密这个事情就很容易做了,我们在打包的过程当中,在重命名之前做一次加密的操作,然后在我们的游戏逻辑运行起来之后,我们再把一个 文件的内容读出来之后,再去调用Loadbuffer再去做加密就OK了。
我们用这种方式去管理脚本和资源之后,这个文件就会有一个问题,就是说我们整个代码可能有70多个Lua文件,有200多个配置文件,这些文件如果以文本 的方式去加密之后,APK的安装包和IPK的安装包都是最基础的安装包,这些文件在加密之后你再去压缩没有压抑的,因为加密之后二进制流是没有规则的,你 用Zip去压是是没有任何意义的,带来的问题就是安装包体积会变大。还有一个需要考虑的就是说,我们这种增加一个加解密的环节,对于我们游戏运行起来的时 候会有什么性能的影响?所以我们做了一些改进。就是说其实这个想法也挺简单的,就是说你加密之前先把文件压缩了再加密,这个事情直接就让这个加密对于压缩 这个影响已经完全没有了,你先压缩再加密的话,本身这个文件就已经比较小了。在我们游戏运行的时候,把它先在我们的Lua Loader里面先解密,再解压,然后再调用Loadbuffer这个函数。
我们做一些对比,刚才提到了我们的安装包如果直接使用的话,这个图(PPT)有三列,第一列是我们的原始文件,我们的文件是一个Zip格式,在安装包里面 的大小可能就是1.5M,如果我们把配置文件和脚本去做加密处理的话,然后再放到我们的安装包里面去,它就变大到8M,就是整个安装包就大了不少。但是如 果我们后面再通过第三种方式,就是先压缩再加密这种方式的话,安装包的体积可能比以前的原始文件直接放进去稍微大一点点,基本上大不了多少。所以这样的 话,压缩、加密这种对于游戏安装包的影响就已经可以完全没有了。
我们对比了一下这些脚本通过处理之后,它在游戏运行时对于性能的一些影响。因为我们的游戏逻辑运行起来的时候最开始的启动阶段可能是Require一些少 量的脚本,真正进入游戏的时候,因为我们Lua内存一些框架的实现原因,我们是需要几乎把所有的Lua代码都要装载进来,所以你一次性的去装载700多个 Lua文件的话,实际上是有一些性能的问题,实际上会使你的装载时间变长,就是在这种加密的方式下。但是对于我这一列后面列的第三种方式,就是说压缩和加 密的方式下,实际上加载的时间又会降下来。所以可能大家就会困惑,为什么我只加密装载时间变长了,但是我加了一个压缩再加密,我运行的时候先解密再解压, 装载时间反而会降下来了呢?这张图就是说明了这个原因,大家可以对比一下。我的Lua文件装载实际上是分了好几个阶段,可能有先读取文件的内容,然后有解 密,有解压缩的过程,就是三个颜色代表了。对于原始文件,如果我们直接放到安装包的话,直接读原始文件就可以了,只要是能读出来,不需要解密和解压缩,总 共1千个文件,我们脚本和配置文件夹都放进来的话。如果只加密的话,读文件装载时间可能少一点点,这个可能是测试的一些偏差了,正常来说应该是差不多的, 可能还需要另外一半的时间去做解密的操作。但是如果文件压缩之后再加密的话,在我们运行的时候先把它读出来,读出来的时间因为本身压缩之后那个文件会很 小,就是它读出来之后,第一次装载内存的Buffer是比较小的,装载的时间其实比原始的文件还要小一点,接下来解密时间也会变得小很多,因为文件的大小 也小了很多,所以解密耗的时间也会比较少。最终解压缩的时间,我们测试的时间上是很少的。
前面提到的都是我们在iOS上做的一些性能的对比。到Android上我们就发现了另外一个问题,这个图里面蓝色的线就是iOS上所有的脚本和配置文件的 加载时间,基本上就是都很小,慢的也就是2秒,快的可能也就是不到1秒的时间。跑到Android上就变得很夸张,游戏可能进去之后开始有一点转圈,然后 过了快10秒钟才会把一些界面装载进来。不管是放原始文件还是放加密的文件还是压缩加密的这种文件,我们放到APP里面之后再去直接装载的话就发现性能有 很大的问题。我们看了一下为什么会有这种问题?我们就发现在Android上面你直接从SD卡读一个文件和直接从APK里面读一个文件实际上是有相当大的 差异的。其实对于单个文件来说,如果运行时只是装载一个文件,从SD卡上读耗的时间可能是一毫秒,从APK里面去读耗的时间是十毫秒。对于一个文件来说, 其实这个时间几乎没有什么关系。但是如果你一次装载1千个文件出来的话,这个10倍的差距就会放大到比较明显的一个程度。所以针对这种情况,我们在 Android上面就增加了一个环节,就是我们游戏安装完第一次启动的时候,需要有一个解开我们资源和配置文件的过程。像iOS上就不需要这样的过程,因 为iOS上本身安装包装进去之后就是一个展开的状态,不像APK,安装完了之后就是放在手机的RAM里面。
我们对于这种展开之后,你再去放到SD卡上后面去运行我们游戏的话可以看到,一个浅蓝色的线就是从Android上,如果从SD卡去加载我们的配置文件和 脚本的话,时间就跟iOS上的加载时间差不多了,比直接从APP里面加载要好很多,刚才提到的是我们的版本管理的一些经验。接下来就是我们用Lua在开发 商利用它的一些灵活性,利用一些丰富的接口,我们去做一些辅助我们定位问题和帮助我们加快开发速度的一些方法。在调用我们Lua的时候,在Lua引擎的文 件里面,会调一个Lua的函数,或者是执行一个Lua的文件,最终的调用方式都是通过Lua Pcall这个方法,它的文档里面其实第四个字段是一个Message Handle,这个Lua的Pcall接下来如果在执行Lua代码的时候如果出错了,如果我有设置这个Messager handler的话,我会通过这个去处置我的异常。我不知道现在引擎有没有这个问题,实际上以前在Pcall的时候是没有做这种错误处理的,第四个参数都 是直接填的,如果我们自己定位一个Message Handle,我们可以运行一个详细的错误的堆栈,我们开发的时候,可以选择把这个堆栈弹放出来,开发人员可能在模拟器上调试的时候,如果出现什么错误就 可以直接把这个错误弹出来了。在我们发布之后,我们游戏毕竟不能保证百分之百没有Bug,如果运行的时候出现一些错误,我们能够把这些逻辑信息捕获,上传 到我们的日志服务器里面去。Lua还有一个好处,一般来说一个挂掉了之后,可能只是影响当时的那一次调用,一般不会让你的游戏直接崩溃掉。
比如说我们实现了一个自己的错误的Handle,这个例子就是我们去改造CCLua文件里面一个执行Global的一个例子,红色的三项是我们自己加上去 的,如果你有1G的Handle,你首先要Psuh到State上去,在最终调用的时候,-1就是你马上要执行的那个Global的指针,-2就是我们自 己的Message Handle。在这个Message Handle里面的实现,实际上主要是用到了Lua的一个方法,他们可以取得运行时的信息,就是堆栈和一些调用的脚本名称和行号什么的。这是我们通过自己 定义的Hanlle可以捕获的一些错误异常,第一行是我们Lua错误的Message,下面是Lua错误的堆栈信息。我们通过这些东西很容易看到究竟是哪 个模块,哪个文件的哪一行出了什么问题,有堆栈的话也很容易看出,为什么它会在这些行出现这样的错误。
刚才凌聪也提到了Lua跟Java跟iOS去做交互的时候有一些比较恶心的东西,他们有的通过自己的方式实现,我们也有自己的一些小的技巧。一些常用的方 式,如果我们有一些Java和Object的接口的话,我们会通过C++导出给Lua用。这样的问题,尤其是一些像我们的iOS和Java需要有一次 C++的封装其实就有开发量。再有就是这种接口比较固定的还好,比如说加解密这种可能实现一次就OK了。后来你再去做SDK介入的时候你会发现每一个渠道 都不太一样,你很难做出一个比较通用的接口暴露给Lua,如果按照之前的这种Tolua++的方式去做的话就很麻烦。比如说我在Java这一层实现打开一 个网址的功能,可能先在Java里面要实现一个OPenUrl的函数,在C++这边也要实现一个OperUrl的函数,去调用Java的代码,再用 Tolua++这个导出,再去调用这个函数。JNI调用的代码说实话,我的感觉很恶心,很容易写出Bug,如果这个字段改成份了,需要修改的参数就很大, 需要改很多地方。Java这一层要改,C++和Lua都要改。我们的技巧就是对于一些性能要求不高的封口上,我们就安装一个doAction这个函数,在 Lua层,就去调用不管是C还是Object C还是Java的接口的时候,可能就是调这个接口。我们在C里面去实现一个调度,就是在C里面,我们这个Tring里面一般会有一个Action的字段, 我们在C这里面出现相应的处理,可能也都会在这个Tring里面去做,在Java这边,首先通过JNI的调用,调到一个Java的 PlatformTool里面,我们通过这种封装之后,在Lua这一层去大概一个网址,我可能在Lua的代码里面就会这么写,首先会把我的参数,比如说 Action是UIO,需要变成一个串,然后再传给这个doAction的方法。这样的好处就是说,我的交互,基本上第一次写好这个doAction之后 就不用变了,我依然要在OC或者是Java这里去实现我的业务代码,不需要考虑在C这一层怎么做中间的角度。调用方式也变得比较一致了,接口后面如果发生 一些变化的话,修改量也会比之前的方式小很多。
上面就是我们在游戏开发过程当中对于版本更新、代码加密、错误检查和接口封装方面的一些经验。很高兴今天能在这里跟大家分享这些内容,欢迎大家能有一些技 术上的交流。我这边广告准备得不充分,我们也是最近招各种各样的人,包括技术、策划和美术,我们都欢迎大家来加入我们,去年这个时候我们可能就是一个很成 长中的团队了,我们也面临着很多的机遇和挑战,大家有兴趣的话欢迎来联系我。
来源:Cocoachina