业内的插件化都在讲app如何插件化、解决了什么问题,游戏SDK的插件化却涉及甚少,本文主要是介绍阅文游戏SDK是如何确定插件化需求场景,以及解决插件化实践后的线上问题如线上测试、灰度发布等,从而保证一个插件化的SDK在多达百款游戏app中稳定运行的。
阅文游戏SDK主要是用于第三方游戏接入阅文平台的账户、支付功能,并提供运营相关功能,如活动、消息、礼包、客服等。
传统的游戏SDK发布流程重复工作非常多,下面看一下传统SDK的接入和发布流程:
N :某一个游戏厂商旗下游戏数量
M:阅文合作厂商数量
K:阅文游戏SDK发布次数
重复次数= (N+M*N)*K
上图是一个简化版的SDK从接入游戏到打包发布的流程,可以发现传统的SDK发布方式需要 (N+M*N)*k 次重复工作。我们发现随着游戏数量的增加,人力、物力所有的成本都在上升,很明显,这和互联网思想背道而驰,我们急需优化这个流程,简化游戏的发布流程。
因为游戏SDK需对外提供,首先我们不能限定打包工具,即需要支持eclipse、androidstudio、apktool等等,而且游戏SDK需要被游戏依赖,入口类不一定是android组件,可能只是一个class,我们需要做到插件化后的SDK和插件化之前的API无明显区别。为此我们对插件化原理进行了深入研究。
插件化实现的核心功能点主要有以下几条:
插件中class的识别
插件中resouce的识别
插件中android组件的识别
我们针对业界各大厂商的插件化框架实现原理分析发现:
插件中class的识别
原理几乎都是着手实现宿主和插件的class合并,通过重写classloader双亲委派模型合并class。主要有以下方式:
自定义Classloader,继承系统的Classloader,合并Classloader中的dex、zip、libpath等属性,重写findclass/findlib等方法
自定义DexClassloader,方式和上面几乎完全一致,但上述第一条在游戏业务上有兼容性问题,后文在叙。
直接修改系统中Classloader中的dex/zip/libpath等属性
插件中resouce的识别
宿主和插件资源分离,宿主调用宿主的,插件调用插件的(如奇虎360的Replugin);
宿主和插件的资源合并(滴滴VirtualAPK),这里我们对资源直接时合并会产生resourceId冲突;
实现id分组的方式几乎都是通过区分宿主和插件、插件和插件的packageId进行分组,实现packageId分组主要有三种方法:
自定义aapt,早期在使用eclipse开发时就已经有通过自定义aapt的方式对packageId进行自定义了,那时每个插件都要编译一个aapt,在google推出androidstudio之后,aapt支持动态传参,愈发方便自定义resouceId。
使用publi.xml手动固定每一个资源ID,通过重写packageId以区分宿主和插件
编写gradle插件hook资源打包流程动态修改resourceId
android组件的回调以欺骗系统使插件的组件具有系统的生命周期,这块也是业内不同插件化的主要差异之处。这里不同插件化实现的功能和方式都不太一样。拿activity这个组件举例主要有以下几种实现方式:
预埋手动把插件的组件注册到宿主的manifest中的方式
占坑,这里又分两种,一种是开发者在代码中进行调包,另一种是在插件被加载时自动调包,可以简单的理解为一个编译期调包,一个是运行时调包。
经过以上预研,现在的插件化开源方案已经提供了几乎所有的方式帮助我们实现我们想要的功能,下面我们分析下阅文游戏SDK的插件化如何用最小的代价实现插件化。
游戏SDK的组件更新频次低,可以采用预埋的方式,以占坑作为扩展形式以备不时之需。
资源独立的方式在代码的编写上难以严格控制,自定义gradle插件的成本也比较高,而且如果google的资源打包流程改变,gradle插件也需要适配;游戏SDK包含较多的resourceId,放弃public.xml固定id的方式;最后我们发现自定义aapt的方案在很早很早就被业内采用,我们使用了自定义aapt的方式,这种方式稳定简单,实现成本较低。
通过自定义DexClassloader对宿主的和插件的class进行合并
通过静态代理替换application、activity的class和resource
以上就是游戏SDK的插件化需要实现的功能点,看上去是不是很简单?
事实上,我们站在巨人的肩膀上很快确定了游戏SDK的插件化方案,并成功实现了插件(阅文游戏SDK)的加载、运行。
初期我们只在少量的游戏产品上上架,以优化稳定性和兼容性。早期插件化SDK上架时曾遇到如下问题:
在android7.0+的设置上崩溃的问题,报错内容:
其中b是项目中的自定义classloader,继承自Classloader,
测试发现android6.0未出现相关问题,有部分游戏在同一台7.0+设备上并未出现异常,经过反编译不同游戏发现,只有游戏activity继承自NatieActivity才会在7.0+设备上崩溃,通过对比api23/24的NativeActivity的源码发现:
在onCreate中方法中API24有细微改动:
为了兼容使用NatvieActivity的游戏在API24的系统设备上兼容,我们将之前的b类改成了继承DexClassloader,解决了这个兼容性的问题。
SDK插件化之后:
重复次数=(N+N*M)+灰度次数
使用插件化之后我们几乎实现了游戏的一次性接入,大大优化了传统SDK发布流程,减少大量的重复性工作。
插件化上架后,我们的工作流程从多方合作的方式转化成阅文自主发布的方式。这对SDK插件的发布带来了严峻的考验,过去我们传统的SDK发布方式如游戏引擎的兼容性问题,在每款游戏接入时就会暴露出来,我们可以针对不同的游戏实时修改SDK以适配。SDK插件化后,我们不可能直接发布线上测试不同游戏的兼容性。
为此我们设计了一套测试方案,按照游戏分类分别测试不同的游戏,如:
游戏引擎分类(android游戏、lua、cocos2d游戏、unity3d游戏)
游戏热度
是的,你没看错,确实是直接在线上环境测试新版SDK插件,只不过这个SDK版本并未发布。
举个例子,我们的SDK准备发布一个新功能,使用了在window的addContentView(Viewview)这个接口,
根据热度及不同的游戏引擎分别从线上下载多个游戏,通过apktool替换内置插件并重签名生成包体(线上环境),测试发现addContentView方法中add的view的事件在unity3D游戏引擎中会被引擎屏蔽掉
需要保证游戏引擎开启
而这个配置的更新是插件无法做到的(需要宿主也就是游戏重新打包,线上unity3D引擎的游戏非常多,我们不可能要求全部重新出包),我们在测试阶段提前发现并避免了较为严重的问题,于是我们尝试了其它方案解决这个需求(可点击阅读原文查看FloatMenuSample)。
基于上述测试方式测试通过后,我们才会进行灰度测试,那万一经过严格测试灰度上线后仍然出现问题怎么办?
因为我们的版本升级只有向上升级,我们有没有办法进行版本回归操作?其实是有的,我们开发了脚本对旧版的插件中配置的版本号进行直接修改并重新出包,然后发布更新,这当然不是严格意义上的回滚,却有异曲同工的效果。
编者按:
一明
阅文集团技术部游戏技术组资深android工程师,在android组件化、插件化方向有深入的研究和实践。平时也是个wower——无核心橙信仰贼:)