01 前言
多渠道打包对于每一个Android开发来说应该都不陌生,从最早的Eclipse上纯手动打包到Ant脚本打包,再到现在Android Studio的自带的渠道配置,以及gradle脚本实现批量打包。多渠道打包的方案在不断的优化,打包速度也从原来的几十个渠道包打一天到现在只需要几小时。
但是上述方案只是替换了配置文件中的渠道信息,如果没有源码,只有一个apk文件,并且根据不同的渠道每个包里的模块和代码都要定制化,有没有解决方案呢?
02 游戏渠道发行的打包
目前国内安卓市场的渠道非常之多,其中有华为、小米、vivo、oppo等自带操作系统或硬件设备的硬核厂商,基于自己移动设备建立了非常大的用户群体,还有应用宝、九游等虽然没有自己的移动设备,但是凭借其app广大的受众,也积累了许多的用户。对于要在这些渠道上发行游戏,就要接入这些渠道不同的sdk来实现渠道的登录、支付等能力,并不像常规app那样,只是改个channelId就好了。游戏对于单个渠道的接入可能就需要花上一周甚至更多时间,如果要同时接入几十个渠道,对于游戏研发来说需要投入非常大的时间成本,另外上文所说的打包方案又该如何解决,一个游戏包打包往往需要几小时,如果每个渠道单独打包,打完几十个渠道包需要花上数十上百个小时,再加上后期对于每个渠道sdk的迭代维护,其中的成本可想而知。为此,我们需要提供给游戏研发一套能低成本的打包方案。
有别于传统的app,自家研发的产品,在编译过程中可以配置各种脚本实现多渠道打包。
在游戏渠道发行中,发行方并不是游戏的开发者(以下简称CP),因此我们只能拿到CP提供的apk(以下简称母包),我们需要基于母包来进行各个渠道的定制整合,其中包括集成每个渠道不同的sdk以及他们的鉴权、登录、支付等能力,最终打出各个渠道不同的渠道子包。
03 目标规划
针对上述的痛点我们不妨先定个小目标:游戏只接一个sdk,游戏只打一次包。
那么要完成这个“小目标”我们就需要解决两个问题:1、整合渠道,2、整合打包
3.1 整合渠道
这里的整合渠道并不是说把所有的渠道都接完放到一个大的sdk里,然后根据channelId来调用不同渠道的方法,这么做既不优化也难以维护。所谓整合其实是通过一个统一的出口来对渠道进行封装,在我们常规的app开发中也有通过不同的flavor或者buildType动态加载dependency的场景,那我们只要把每个渠道当成一个单独的module,不过由于我们是合作方,只能拿到游戏打完的apk包,我们不能把渠道当成module集成在游戏的代码里,只能变成单独的apk去集成,在每个独立的渠道apk里集成sdk的能力,再通过统一封装的代理层来实现这些接口的对外暴露就行了,具体的架构如下所示:
通过上图可以看出整体的业务流程是:游戏调用proxy的代理接口→代理接口调用具体集成的渠道api,这样无论底层的渠道如何变换,只要代理层的接口设计能覆盖渠道所有的能力,那么对于上层游戏来说渠道的变化就是无感知的,这样做到了游戏和渠道的彻底解耦,也做到了渠道的整合。
3.2 整合打包
游戏打一次包往往需要几个小时,如果每个渠道打一次包,耗时巨大,但是如果按照上文架构设计,游戏只要接入一次代理sdk,然后我们只要在打渠道包的时候替换渠道模块的代码以及资源就行了。
04 技术实现
既然目标已经确定,那么我们就需要具体的打包方案来实现。
传统的渠道打包方式无法满足,游戏发行需要有一套独特的多渠道打包方式。
整理一下需求:我们有一个apk,还有一份渠道sdk的代码,我们需要把这些代码合并到apk中生成一个新的apk,这个流程听着是不是很熟悉?这不就是反编译和回编译的过程吗。
谷歌官方的apktool提供了反编译,回编译等能力,基于它我们可以设计出大致流程:
整个流程中大部分的工作调用apktool的api就能够实现,但是如何去替换注入渠道sdk的代码呢?
熟悉逆向的同学一定知道,apktool反编译之后生成的是smali文件,大概长成下面这样:
别说修改了,这种类似汇编的代码的可读性都很差。
换一种思路,如果我们编辑的是java文件,那是不是就方便很多了。
顺着这种思路,如果我们有一个集成了渠道sdk的demo.apk,再提供给CP一个代理sdk,通过apktool反编译demo.apk之后生成的smali文件替换母包反编译后对应的代理类,这样就可以实现渠道代码的注入了。
同样,我们只要针对每个渠道单独开发一个接入的demo.apk就可以复用在所有的游戏上。这样既做到了渠道的独立,又可以横向扩展。
因此,我们设计了一套代理层,对上暴露登录,支付,鉴权等基础能力的api,内部是渠道api的调用,基于这套代码打包出来的就是demo.apk了。
其次,渠道之间还有很多差异化的内容要处理,最简单就是同一个游戏不同的渠道包名是不一样的。
这里就要用到反编译之后的yml文件了,这个文件记录了反编译的配置信息,用于回编译的时候读取,用修改包名举例,只要增加第一行的配置就可以改变回编译之后的包名了。
同时,yml文件还可以自定义很多配置,这里就不展开了,感兴趣的同学可以自行了解一下。
最后,解决了代码合并,渠道差异化的配置之后,整个打包过程大致为以下几步:
1. 准备游戏母包和对应的渠道demo.apk
2. 通过apktool d xxx命令分别反编译这两个apk,得到如下文件结构
3. 合并AndroidManifest.xml,合并assets中的文件,合并lib,合并并替换res中的相应资源和配置文件,替换smali中的相关文件
4. 通过apktool b xxx命令回编译apk
5. 通过签名工具对回编译的apk进行签名
4.1 脚本打包
虽然打包步骤就简单的五步,但是其中步骤3的资源整合和替换是非常繁琐的。
AndroidManifest合并:
1. 使用xml解析器,获取所有的节点
2. 合并相同节点
3. 添加渠道特殊逻辑
4. 添加游戏特殊逻辑
5. 替换包名相关的节点(provider,permission等)
6. 创建新的manifest
assets合并:
1. 合并assets文件夹
2. 添加渠道特殊逻辑
3. Splash资源替换
4. 生成新的assets文件夹
lib合并:
1. 获取母包libs
2. 获取渠道libs,并且和母包libs进行对比
3. 合并相同的libs
4. 根据游戏支持的cpu复制对应的libs
5. 生成新的libs文件夹
res合并:
res比较特殊,它的合并需要拆成两个部分:一个是anim,color,drawable,layout等文件夹的合并,另外是values的合并;
除values外其他文件的合并类似assets合并,替换相同的文件,合并其他文件,生成新的文件夹;
values文件夹的合并就要逐条解析文件内容进行合并;
最后还是要加上渠道的特殊逻辑,生成新的res文件夹。
smali合并:
首先找到对应的代理层文件夹,把demo.apk的文件替换到母包中对应位置;
需要注意的是很多渠道的sdk比较大,方法数可能会超65535的限制,合并的时候我们通过脚本统计每个smali文件夹里类的方法数,当方法数累加超过阈值之后会新建smali_classes2文件夹,把后续类迁移到后面的文件夹中。
这些操作如果单纯靠人工手动处理不仅非常耗时,而且还容易出错。我们整理完合并替换规则之后,实现了一个打包工具来帮我们处理这些繁琐的工作。
以下是资源替换的工具类:
至此,我们就可以把繁琐的人工打包过程转换成简单的脚本命令来实现,节省时间的同时还能保证准确率。
4.2 工具打包
虽然脚本打包已经非常便捷了,但其实由于每位同学的电脑环境不同,同一份脚本在不同的电脑上运行的结果也会有差异,环境差异的报错对打包也会有一定程度的影响,因此,我们需要一个相对统一稳定的环境来执行打包任务,这就可以使用传统的持续集成工具:jenkins
于是我们基于打包脚本和jenkins,部署了一套高可用的游戏渠道发行打包工具,降低了打包的门槛和费力度,让打包效率有了进一步的提升。
4.3 平台打包
脚本也好,jenkins也好,其实都是比较偏向于开发的工具,然而打包不仅仅是开发用,更重要的是打完包之后交付给测试以及业务方,那么如果有一个非开发也能使用的,更直观、更低门槛、更产品化的方案,是否能在工作流提效上有更好的帮助,为此我们还设计、研发UO打包平台,致力于让业务同学也能够轻松的打出渠道包。
05 避坑建议
其实整个研发工程并不像上文所说的一帆风顺,其中也遇到了许多奇怪问题,以下挑几个典型的与各位分享:
5.1 合并游戏母包和渠道demo
是遇到单dex方法数超 出64K问题
部分渠道的sdk自带了很多的方法,此时合并成一个dex文件时,可能出现方法数超出65536的问题。其实方法数超的问题相信很多安卓研发都遇到过,现在只需要配置multiDexEnable true就可以在编译的时候自动分dex打包,但是对于游戏来说,我们拿到的是已经编译后的apk,因此没有编译工具会替我们进行dex1、dex2分包,我们需要通过配置来模拟编译工具的分包逻辑,实行手动分包。
5.2 加固对出包流程的影响
部分游戏接入了加固平台,这会导致合并好母包和渠道demo.apk之后,生成的游戏渠道包启动闪退。遇到这种情况,我们需要改变一下出包流程。
流程由原来的:
加固后母包 -> 生成未签名的渠道包 -> 签名 -> 得到渠道包,但是启动会闪退
将加固动作往后移动,改为:
未加固母包 -> 生成未签名的渠道包 -> 加固 -> 签名 -> 得到可用的渠道包
5.3 资源文件
由于二次打包aapt会重新生成R.smali文件,会产生两个问题:
1. R文件的路径产生变化:
由于每个渠道的游戏包名都不同,最终渠道包的R文件路径也不同,如果游戏中直接通过R.id.xxx的方式调用,这里的R引用的是游戏原有的包名,R文件本质是一个类,如果路径发生了变化,那么我们代码中对R文件的引用就会找不到类,解决这个问题可以有两个思路,修改所有R文件引用,改成新的包名,或者在老的包名路径下复制一份R文件。
大部分游戏的native代码并不多,其中极少部分游戏会通过R.id.xxx的方式获取资源,对于这种游戏我们在合并smali文件的时候,会根据配置来判断是否要在游戏原来的包名路径下保留R文件。
同时,为了防止游戏的R文件id发生变化,我们会在新包名的目录下复制一份游戏的R文件,确保游戏的资源id不发生变化。
对于我们sdk自己的id,为了防止sdk的资源id和游戏冲突,我们sdk的资源id就交给aapt重新生成,因此,我们sdk内部不能通过R.id.xxx的方式来获取资源id,我们调用Context.getResources().getIdentifier()这个方法,通过包名+资源名的方式来获取到资源id,避免了二次打包之后id改变造成的crash。
同样,虽然大部分渠道sdk里面也调用了getIdentifier来获取资源id,但是也有个别渠道直接使用了R.id.xxx的方式来获取,对于这种情况,在二次打包后由于上述id改变的原因,会导致crash。
对于这种问题其实和游戏的R文件一样,只要保证原始包名下有对应的R文件来避免引用错误就可以解决了。
2. 资源id变化:
由于新增了资源,资源id会变化,同时母包和demo包里都会有一些公用的基础组件,同一个资源的id在两个包中可能是不一样的。
大家都知道,打完包之后会生成resource.arsc文件,这个文件是资源索引文件,解包之后apktool会根据resource.arsc文件生成public.xml,这个文件里面保存了资源id的值,那么我们要统一id就需要用public.xml中的值覆盖demo里面原有的值,大致流程如下:
我们通过解析game和sdk的public.xml文件,然后用sdk里的资源和game进行对比:
1. 如果sdk内的资源game里没有,把资源保存到新增资源集合A中
2. 如果sdk中有game里一样的资源,我们会保留game里的id,并且记录sdk的新旧id映射保存到集合B中
3. 合并的时候会先去读集合A中的id,判断是否有冲突,如果和现有id冲突了,会通过一定的规则重新生成id,并且也和步骤2一样,把新旧id的映射保存到集合B中
4. 读取集合B,全量搜索旧id的值,替换成新id
5. 生成新的public.xml
大致方法如上所示,一些细节的实现就不占用篇幅赘述了,至此,资源文件相关的处理就完成了。
5.4 provider、permission
作为android四大组件的ContentProvider大家一定不会陌生,在使用的时候需要在manifest中声明,如下:
其中authorities是唯一标识,渠道sdk中ContentProvider的authorities都会使用包名+类名的方式来声明
这样对于集成了渠道sdk的demo.apk来说,所有ContentProvider的authorities都是相同的,这就会导致如果单渠道多个游戏,当第二个游戏安装的时候就会由于authorities冲突导致失败,因此我们首先需要用特殊的占位符替换包名字段,然后再manifest合并的时候识别占位符,用渠道子包的包名来替换。
当然,如果这么简单就能处理完,就不会出现在”避坑建议“里了,其实很多渠道的ContentProvider声明是放在自己sdk的manifest里的,对于这样的渠道,我们需要在集成渠道sdk工程里的manifest进行authorities替换,需要使用replace,这样在渠道demo打包的时候,manifest合并过程中就会把渠道sdk内部的authorities改成我们自定义的authorities。
最后,同样的问题还会出现在permission中,有一些渠道sdk中声明了自定义的permission来限制对自己服务或者是组件的调用,处理这部分问题的方法和provider类似,这里就不赘述了。
06 优化对比
6.1 流程优化
整个打包平台的方案,解决了之前出包流程上耗时的点:
1. 包体多次传输
打包平台将原来的上传→下载→上传的三次传输过程简化成了单次上传。
2. 人工响应时间
工作时间响应时间是比较稳定的,但是当出现突发情况,很有可能会有非工作时间(午夜、假日)的打包需求,这时候可能由于各种原因技术无法及时出包,通过平台出包可以避免此类的响应问题。
3. 加固流程
现在加固基本是每个app必备的流程,有一部分游戏加固是在签名前对每一个渠道包进行一次加固,然而目前我们加固是采用第三方的解决方案,基于这种情况就会中断我们的打包流程,在签名前还需要给第三方进行一次加固,然后再签名,并且这样也会造成多次的游戏包上传下载的操作,严重影响出包效率,也增加了很多人工操作的工作量。
为了进一步提升打包体验和效率,我们整合了部分加固方案,让加固变成了打包流程的一个部分,流程对比如下:
从上图可以看到通过平台集成了加固流程后,每个包额外又减少了6次传输,以及一系列的人工操作,对于打包的时间和体验有非常大的提升。
6.2 耗时对比
游戏包不同于别的app,单个游戏都在1-2G不等,单次传输的耗时也在3-5分钟,如果是外网环境速度会更慢,优化了两次传输流程后,整体出包流程提升6-10分钟/个包,平均每个游戏接入的渠道有8-10家,相当于每次出包缩短了一个小时的耗时。
对于加固包而言,平时整个流程可能需要1-2个小时,中间由于上传、加固等多方响应时间,可能会更长,但是通过系统只需要十几分钟就能完成所有渠道的出包。
我们用重生细胞做了一次测试,8个渠道整个打包、加固、签名一共只用了20分钟都不到,其中耗时的还是加固流程,可以看到下面无需加固的bangGream 7个渠道只用了3分钟。
07 总结
本文看似简单的过程,其实由于各个渠道逻辑差异、底层依赖库的冲突,对于ProxySDK的高内聚和低耦合的设计要求还是比较高的,开发过程中也经过了几次改版和踩坑,最终才交了一份阶段性的答卷。
其次apktool的本身的问题也给我们造成了不少麻烦,经过不同版本的尝试,以及各种配置修改、试错之后才确定了一个符合我们需求的稳定版本。
未来我们还会往平台化的方向探索,追求推出一款高可用的游戏多渠道打包平台。