“国民”三消游戏《开心消消乐》上线十余年,常年稳坐手游榜单TOP20,今年年初,该游戏正式登录小游戏平台后,依然轻松囊括了大批新老玩家青睐。但在实际的平台迁移过程中,开发团队也遇到了很多棘手的挑战和难题,整个移植的过程大概花了100天。
在刚刚落幕的微信小游戏开发者大会上,乐元素开心消消乐技术负责人孔祥一围绕着《开心消消乐》迁移微信小游戏平台的流程,就迁移过程中的挑战、详细步骤和优化手段进行了详尽的分享。
以下为分享内容全文:
大家好,我叫孔祥一,来自乐元素,今天为大家分享的内容就是《开心消消乐》团队把我们APP手游迁移到小游戏的整个过程实录。
首先,《开心消消乐》这款游戏基本是一款国民游戏,大家都很熟悉,相信在座很多人或者是自己的亲友也在玩这款游戏。我们在2014年ios就上线了,到目前为止已经11年以上,我们陆陆续续又发布了安卓版本,以及在2024年上线了鸿蒙版本,目前主线关已经超过1万关,每周更新30个关卡以上。在这么多的关卡内容上,以及活动玩法的基础上,这个APP游戏迁移到小游戏工作量是非常大的,因为历史积累的功能活动、历史代码会非常多,并且很多已有的平台需要兼容,所以工作复杂度还是比较高。
我们迁移主要挑战就是把APP端的整个技术架构迁移到小游戏端,最核心的地方就是APP以前是用Cocos开发的,Cocos加Lua,现在我们要迁移到小游戏端,小游戏又只能运行在APP中的一个GS环境下,所以在小游戏中如果还用Lua去运行的话,基本是在一个虚拟机中套一个Lua虚拟机的模式。但是我们又不能避免这个模式,否则的话可能APP开发业务和小游戏开发业务需要走两套代码,那个开发成本太高,我们是不可能接受的。所以在小游戏端我们选择的架构也是WebGL上,用Unity导出代码,并且在业务逻辑都跑在Lua中,小游戏中的Lua运行环境的运行效率也会相对低一些。
主要挑战的内容,刚才已经叙述了绝大部分挑战的主要内容,我们把最前期最核心的内容需要提出来,选一个最小级,我们第一版上线,主线关需要上线1005关,后期调整到了2000多关。另外一点,《开心消消乐》是一款已经在运营的游戏,所以我们希望给用户一个一致的体验,不管是在APP玩也好,还是在小游戏玩也好,我们希望你的账号是互通的,数据资产是一致的,参加的一些活动、领取的道具、素材资源,在小游戏也好、在APP都可以通用,所以我们需要一个通用的体系。一些核心的功能、道具、支付等等都需要支持,另外就是在小游戏上我们需要一个比较好的体验,帧率需要达标、启动时间需要达标。对我们来说挑战最大的方面就是时间很短,我们接到任务的时候大概只有3个月的时间,需要尽快上线,因为游戏开发团队基本上接到的所有任务都是时间很紧张,都需要赶工期、赶市场节奏,所以当时的时间是非常紧的。小游戏的运行性能,因为是在GS环境,运行效率本身就打了一个很大的折扣,平均测算下来,不管是官方宣布的还是我们自己测的,可用性能大概是net5的1/3左右,并且还不能用多线层相关的技术,所以对性能优化的挑战也非常大。
迁移工作我们主要是这样设计的,第一个步骤就是先验证我们的最小功能级,《开心消消乐》核心玩法就是打关,打关要是打不了,后续工作也基本不用做了,UI的展示,主要使用的Spine动画,如果运行效率非常低,后面的工作基本上所有方案需要推倒重来。在最小验证集过了以后我们有业务逻辑移植、小游戏平台能力接入、测试和优化,最后就是上线、功能迭代玩法。
前期最小验证集对我们来说是挑战最大的部分,我们的游戏是在Cocos2dx基础上开发的APP版本,当时是满足产品的需求以及快速上线验证,功能开发也很顺利。但是随着这些年运营效果下来,我们发现产品一些新的需求可能不管是对表现力也好、对玩法内容也好,对一些3D建模的需求也好,都有很多需求,所以我们在之前就开始做Cocos向Unity迁移的准备,正好借这个机会,我们也把发行小游戏的时候就把Unity版本导出小游戏作为一个主攻目标。但是客户上我们也需要验证运行时能在WebGL上正常跑起来,还好我们Cocos导出的版本只能打关,排除掉一切联网的功能的这个版本,在WebGL版本上,在高端机上能运行打50帧,低端机上能运行个十几帧的状态,至少这是看到一个希望,运行起来没有太大的问题。在Unity上我也需要验证,测试了一个典型的Spine动画,我们放了很多动画,运行效率上看看是不是能达标,基本上还算是可以,但是还有许多需要优化的动作。
工作流,我们的目标已经确定了,整体框架已经确定了,工作流上确定了核心的两点,一个是代码、一个是资源,需要把相关的迁移到WebGL上。在小游戏上,所有运行实时的加载动作都是异步加载,而APP上面的性能会非常好,所以很多都是同步加载的,但是小游戏里面用不了,所以把APP端底层架构,最基础的文件加载、资源加载都需要迁移。我们也通过分析配置文件、分析Lua代码,把所有这些引用到的资源可以自动化分类,按不同的障碍名称、按不同的关卡段分到Unity不同的BundleGroup上,自动化把这个Bundel打出来。
经过以上几个步骤,基本上可以出一个完整的版本,能在客户端运行、能在Unity运行,也能导出到Web端,在小游戏上都能运行起来,这都是没问题的。我们下一步就需要处理平台差异和适配的问题,就是我们在小游戏平台有很多第三方接口,首次需要接入进来,接入进来以后也需要接入小程序的API和开发能力,支持登录、支付、广告等等相关内容。
第一个版本跑起来了之后,很自然的就会遇到很多问题,主要是卡顿发热、帧率不高、内存不足经常卡死、卡死报错,经常卡,然后效果不对,最小验证集,所以把美术的资源压缩率非常高,基本上技术是不考虑效果的,只要能跑起来能看到就行。但是这个效果像美术肯定是不认可的,所以后期美术也需要把压缩纹理往上调,把效果做起来。纹理品质这些方面需要和美术一起共同在效果和资源中间取一个平衡点,争取能跑起来。
下面也会介绍很多优化的手段,但是优化的前提就是能形成量化,把我们的这些指标能量化出来,后面才能谈具体优化的动作、过程和效果。量化的性能分析工具,这也是大家耳熟能详的,Unity的这一套,Unity Profiler、Memory Profiler、Frame Debugger
,这套工具还是很完备的,这也是我们选Unity的一个原因所在。微信开发者工具也有很多比较成熟的工具,比如Performance,也有提供CPU slowdown的功能,可以放大CPU的运行效果,可以让我们更容易发现CPU上的问题。在开发机上的跑的效果不管多好多流畅,也不能代表用户实际体验的效果,所以最终真机上跑的效果才是我们真正关心的东西,真机Profile+Performance工具导入过来,在Chrome这个工具上能看到这些还原图,和我们在开发机上看到的效果其实是一致的,这个工具也是非常好用。
对于小游戏的实际真正优化手段,有一些文档或者开发者最佳实践上也一条一条列了非常多,我们也是一条一条都做了,但对我们来说最核心的小游戏还是两点,一个是内存优化、一个是计算优化,其他的基本都是这两个相优化的扩展或者延伸。小游戏上,尤其是微信小游戏上有一个ios高性能+模式,这是很重要的,因为它决定了我们的可用内存是多少,效率提升是什么样的,毕竟在ios高性能+的模型下,微信小游戏把小游戏运行在一个单独的进程中,内存空间完全不一样,对我们内存使用有很大的帮助。另外就是WASM分包,对内存分化很显著,降低渲染分别率,这是立竿见影的效果,虽然很简单,但是对我们的游戏最初在APP端涉及到分辨率就是设计720宽的效果来看,渲染到目标分辨率然后再放大,对帧率的提升以及对内存占用降低是非常明显的。另外就是预加载资源和用户数据,在小游戏上非常敏感,不管是使用量也好,还是加载速度也好,尤其是启动时间,所以能并行的事情我们基本上都并行处理,这样能极大提高手势启动起来的加载速度。
对内存优化的通用手段就是解决内存泄漏问题,因为刚才也提到了虚拟机套虚拟机,这几层的内存都需要精确控制,Lua也有内存泄漏的问题,GS也有。在初期的移植,我们以快为准,后期逐步迭代的过程中要解决大量内存泄漏的问题。以及资源按需加载,内存还是非常敏感的,压缩纹理格式、WASM分包,不管是提升加载速度也好、降低内存使用量都非常有帮助。对象池的使用,在游戏上GC的问题还是比较严重的。另外就是团结引擎对小游戏的导出也做了很多对标优化工作,所以团结引擎导出还是有比较大的提升效果。
提高GC频率这块,在ios和安卓上GC频率是不一样的,微信小游戏在JS上有一个10秒钟自动的GC的流程,在Lua上我们一开始是没有做的,如果不做的话,在一个大掉落、在一个关卡玩的过程中可能会造成内存的问题,所以后来我们把GC也定时调一下。但是在安卓上这样调就不可以,安卓有一些低性能的机器,如果要是频繁GC的话会引起显著的卡顿,并且安卓对内存的敏感度不如ios这么高,ios大概有1.4G的限制,所以在安卓上我们基本上每局GC一次,在ios上是定时GC。
WASM分包,这也是一个立竿见影效果非常显著的一个内存优化点,我们的总函数量大概是11万个函数,首包的函数大概1.8万,未压缩的情况下有符号表大概55MB首包,分包之后一个是15.8首包,一个40兆分包,它俩加起来其实都是没有符号表的,跟有符号表的数量差不多,如果不带符号表的GS包大概是48兆左右。为什么分包以后会比以前大呢?从右侧白色的代码图可以看到,真正掉了一行代码,但是上下有很多相关的准备工作,要检测函数存不存在、做一些参数准备、做一些异常处理等相关工作,会把这个函数做到冗余的代码非常多,所以代码量就上来了。另外一个就是做br压缩,br压缩之后可以显著降低首包下载的大型,可以看到从15.8兆降到3.4兆。分包还有一个最大的优点就是内存降低得非常明显,因为从官方文档看看,GS代码1兆大概对应内存中运行时的占用是10兆,是1:10的关系,所以分包40兆,大概能降低400兆GS代码的占用,所以对其他功能就会非常友好,把这些内存放在美术素材上,效果提升也非常明显。
内存优化说完,另一方面就是计算优化,计算优化这块对我们来说也是一个核心的问题,小游戏性能大概只有net5的1/3,所以计算优化如果不仔细做会有比较大的问题。几方面,一方面是我们去掉了很多try-cotch函数,变异成WASM后代码会膨胀,并且很多检查执勤也非常耗时。另外一个,我们的代码本身是嵌套虚拟机,所以在参数传递时有很多层装箱、拆箱的过程,所以参数的传递量比较大或者参数个数比较多的话,对我们来说影响都不小。另外一个就是调整小游戏的补帧逻辑,《开心消消乐》分两部分,一个是运行时的逻辑运算、一个是运行时的渲染运算。逻辑运算我们定在固定的数量级上,比如我们就定在30帧,流畅的运行是30帧,但如果某个大掉落非常卡顿的话,这一帧33毫秒不够算,可能会用的时间更多一些,那就会造成一个卡顿。如果持续卡顿的话,在用户的角度看来可能就变成一个只占时间的效果,但这不是我们的核心目的,我们希望用户能见到一个连续的游戏逻辑体验。所以我们会在计算帧数比较大或者是延迟计算的时候,把下1帧一次算2帧,把这个帧追回来。大家也知道这是一个矛盾的事情,本身是卡顿造成的算不完,算不完还要多算一些新的内容去追这个帧,就更卡顿,这就造成雪崩的问题。在APP端其实这个问题不是很严重,因为真正的大降落可能只有1帧、2帧就刷完了,后面可用富余的计算量非常大,所以很快能追回来。在小游戏上就追不回来,真正造成血崩,造成一个连续的像子弹时间的效果,所以我们把这块也调整了,真正计算帧的时候只追部分的帧,也有可能在1帧中算2帧,稍微能合并的地方合并一下。另外一个部分是优化Luo-C#参数传递、JS接口调用,核心问题还是虚拟机嵌套的问题,Lua代码的执行效率是非常有限的,不是Lua5.3,很多代码也用不了,所以这边只能在业务逻辑上优化了很多Lua代码。
优化Spine动画的实践方式,Spine是我们的核心关卡内的表现形式,所有关卡障碍的这些小动物、障碍,绝大多数都是Spine动画,表现效果也很好,在APP端的优化效果非常明显,但是在小游戏上就会造成非常多的问题,还是刚才说的计算类问题,还有内存的问题。内存的问题,我们就是优化顶点数、减少网格,同时也能优化计算相关的消耗。另外一方面是替换帧动画为静态图,我们有一些播放一致的Spine动画,到静态状态的时候可能就把它从Spine状态替换为静态图,这样内存占用也少,有各种各样的好处。如果能换的话我们肯定都换,有很多能动的东西也换不了,怎么办呢?就是减帧、抽帧。另外一个特性就是Clip,能遮挡住部分图源的效果,我们在APP端其实美术用了很多这样的效果,表现力非常强,但是在小游戏端用不了,所以我们跟美术一起努力,把很多不必要的Clip效果去掉了,必须使用的部分也做了几部分优化,一个是美术相关的优化、一个是技术相关的,把Clip相关代码的内存输出使用上的优化,一会儿也会说到。另外一方面就是使用Mesh动画,把Spine计算过程中的这些Mesh动画我们提前算好存起来,然后用静态的Mesh动画去播放Spine的整个过程。
关于Spines动画刚才提到了,它是一个动态的过程,所以需要重新算很多社交点,这些点是动态申请的速度,每一帧都是严格匹配的,这就有很多浪费,额外的操作,所以我们把它变成一个串联共享的静态,减少这些清理的动作,对函数的性能提升非常明显,能由8毫秒降到3毫秒以内。Mesh动画就是提前预渲,把Spine动画所有三角点提前预算出来,在运行时就不需要再计算了,直接引用静态的素材资源就可以了,这也是一个用内存换CPU的一个常规技术。然后有一些不能使用的场景,比如说骨骼位置跟业务逻辑相关的,有一些内容,有一些连续显示进度的星星瓶等效果是不能提前算的,但也有其他优化手段,比如进度是100%,以前是连续流畅的,到现在就分10个进度就好了,也一样能表现出意图来,虽然效果上稍微差一些,但基本上是可以接受的。
还有一些方面,就比如说API相关的优化,我们在ios和安卓上对文件操作的性能其实是非常高的,因为现在都是固态的存储,效率非常高,但是在小游戏上不是这样的,小游戏上一方面我们是嵌套虚拟机,另一方面小游戏本身对文件操作就有一定限制。我们在对文件操作的时候把一个二进制的数据要写到文件里,至少得转成字符串,再通过层层装箱、封箱、拆箱,从Lua传到GS,再从GS传到net5,性能消耗非常明显,现在这个还原图是我们一个关卡内达关时的表现,一关在APP端为了做崩溃的时候玩家状态的恢复,我们是玩家每次操作都需要把当前的断面状态存在磁盘上,当APP端及时存储一点问题都没有,但是小游戏端就不行了,每一次文件操作从几十K的数据量都会造成显著的卡顿,所以这个也不行,我们把小游戏端这些频繁的文件操作都需要去掉。
同理音效播放也是遇到类似的问题,小游戏对API的封装调用的时候性能是有限的,所以这块也需要注意。另外就是游戏对音乐播放本身的功能需求是非常低的,只要能播放,播放完告诉就可以了,我不需要中间插播、回放等功能,所以我们把相关代码也都裁掉了,对运行效率有所提升,对代码量也降低了很多。
震动效果是同理的,我们在华为P20机器上的震动效果也能测出来,当时频繁调用这些震动效果耗时非常多,一次耗时可能20多毫秒,而且小游戏对震动可控制的层次也是比较少的,只有高、中、低三种震动效果,没有震动曲线这些,这些都不用,所以我分装成三种函数,这样对震动相关优化也非常显著,20多毫秒能降到几豪迈以内。
Lua代码这是我们游戏本身的一个特点或者是传统APP转到小游戏都是避不开的话题,我们也对比了Lua文本模式,对加载的运行效果其实影响不大,但是大小确实降低了很多。这样有优点,也有弊端,优点就是代码相对来说做了一次类似于混淆的动作,占用内存也会减少很多。弊端就是我们在查问题的时候这个对战是不完整的,是看不到具体的位置,但是如果对这个系统功能比较熟,对自己的代码比较理解的话能拆出来,但效果也不是很好。但是有另外一个方面,字节码和文本可以混合使用,把有问题的代码用文本的方式去执行,这样对战信息都是完整的,这是一个比较好的点。
经过以上所有努力,我们8月份正式上线测试,这是当时的现场,整个过程大概有100天,过程中做了非常多的工作,在一些小游戏开发者听来,100天他们可能开发了好几款新游戏了,但是对传统游戏来说我们一点业务逻辑都没做,只是把原生的迁过来就花了这么多工作。这个工作也是在我们团队很多部门共同通力配合的情况下,并行执行的情况下才能做到,所以传统游戏在微信小游戏上还是有一定难度的。
总结起来,在我们迁移过程中最核心的参考资料就是这几项,一个是微信小游戏的官方API指南,这个帮助非常大。另外一方面,我们用Unity开发小游戏,我们用引擎导出的时候有很多优化项,这个文档在另外一个网站上,大家也需要关注一下。
总体的一致心得就是这几点:
1.先做减法,再做加法。把所有能剔除掉的就剔除掉,把最核心能验证的框架先跑起来,这是我们最核心的一点,如果这一点验证成功,后面的框架都是没问题的,如果这一点跑不通,虽然所有问题技术上都能解决,但是技术选型如果要走一次弯路的话,花的时间还是非常多的,所以先做减法,最小集验证过了,然后后面再加新想、加新的迭代方式去补充相关内容。
2.尽量让所有任务并行,做好相关支持工作来加速开发进程。小游戏开发可以并行的工作对我们来说是非常多的,引擎可以并行、API接入可以并行、Spine渲染优化可以并行、业务移植可以并行、美术相关的迭代可以并行、产品的设计也可以并行,所以我们把所有东西都并行起来,才能把整个时间往前移。
3.产品做好短期和长期规划,为此制定可行的开发计划。《开心消消乐》一款十年的游戏,内容非常多,如果一股脑把所有东西都搬到小游戏上,对我们来说开发压力也很大,即便技术上实现了,可能也不是产品需求,小游戏的运行策略和APP的运行策略也是不一样的,所以前期把真正要的东西规划好,避免弯路,这是非常重要的。
4.与公司内部和外部专家保持交流,以快速获取有效方案。我们也获得了微信小游戏团队的支持、Unity团队的支持,对我们的帮助也非常大。
我的分享就是这些,谢谢大家!