01
背景
奇异果TV作为在电视设备上用户活跃度最高的应用之一,为广大用户提供了丰富的内容播放服务。随着奇异果TV多年的发展,功能逐步增加,业务更加复杂,每次发版都需要经过功能测试、适配测试、线上灰度测试,但线上问题仍不能完全避免,需要及时对线上问题进行修复。
同时,由于电视端特有的商业模式和合作生态,App更新覆盖速度较慢,且更新操作较为复杂,对于以老人和儿童居多的TV用户来说,需要更快速地使用无感知的方式修复线上问题。
在之前的文章里,我们介绍了奇异果TV特有的插件机制,可通过插件对自身主要业务进行更新,也是奇异果TV最主要的升级途径。同样,当遇到严重线上问题时,也可通过插件更新修复错误,但有一定局限性:
1)奇异果的业务插件中几乎包含了整个应用的功能,包体较大。
2)合作的TV厂商和应用商店对质量要求较高,插件上线需要经过严格测试,且每个厂商的流程不同,导致线上问题修复进展较慢。
3)插件更新的检查时机较少,且下次启动才会生效,面对紧急线上问题无法第一时间修复。
4)插件是一种对系统有侵入性hook的方案,需要大量的适配工作。
02
技术选择
综上所述,我们需要一个更轻量、快速、高兼容性的线上问题修复手段。我们调研了目前主流的线上问题修复方案,大致分为四类:
第一类:hook native层代码,如andfix,通过hook native代码对要修复的方法进行替换,因为是运行时动态修改和替换代码,所以可实时生效。但是需要针对dalvik虚拟机和art虚拟机以及不同版本做适配,同时需要考虑指令集的兼容问题,兼容性上会有一定的影响。
第二类:dex的替换,把含有修复代码的dex插入dexElement数组前面,在加载类时优先使用补丁dex中的代码如Qzone,Tinker方案,下发一个diff dex和旧dex合成一个新的dex,需要下次重启生效。
第三类:插桩,在编译阶段将每个方法上自动插桩,利用插桩代码再把原方法替换为补丁中的方法如robust。
第四类:JVMTI,Java虚拟机对外提供的Native编程接口,Agent 是一个运行在端上的 JVMTI 代理程序,它通过调用 JVMTI 接口,实现补丁的加载、类的动态替换,如爱奇艺的Jvmfix,最低8.x系统可支持。
由于TV设备的独特性,上述部分方案不能较好适配TV设备,我们最终从系统兼容性、修复速度和后续维护成本考虑,选择了Robust作为修复方案。但Robust本身也有一定的缺陷,比如插桩导致应用dex增大、kotlin支持不够友好、不支持Native代码修复等。
因此,我们基于Robust进行了二次开发,尽量降低该方案的缺陷,并补齐了我们亟需的能力,同时,使该方案和我们现有的插件方案共存,作为插件方案的有效补充。
03
实现和改进
3.1 原理
Robust的修复原理主要是在构建apk过程中,为每个class增加一个类型为ChangeQuickRedirect的静态变量,并在每个方法前都插入了使用changeQuickRedirect相关的逻辑。当加载补丁时会把被修复类xxx中的changeQuickRedirect变量赋值为补丁中的xxxPatchControl,这样在执行到被修复的方法时就会执行到这个xxxPatchControl的accessDispatch方法进而略过之前的方法实现跳入补丁方法中执行,达到修复问题的目的。
3.2 改进过程
根据奇异果TV实际业务需求,我们建立了KRobust工程,规避了反射系统类问题、优化了java和Kotlin lamada表达式和插件资源固定问题,增加了Native代码修复和手写补丁功能,同时,针对现有的发版机制,进一步对补丁部署流程进行优化,提高部署效率。
3.2.1 反射问题
Robust在为一个xxx类创建补丁时,会生成一个xxxPatch的补丁类并把要修复的方法从xxx类中搬运到xxxPatch中。由于直接把方法搬到xxxPatch肯定不适用,被修复方法的实现中会调用到的原类中的私有变量或方法,导致无法在xxxPatch类中直接使用,所以需通过javassist解析方法的字节码,把对应方法、变量的直接引用修改为反射的方式调用。如下:
生成补丁后,补丁类中反编译代码如下:
然而在调用一些系统方法时,如上面System.loadLibrary("sodemo")正常调用是没有问题的,但使用反射调用就会因为so找不到而报错。
从报错日志来看,是从/vendor/lib和/system/lib这两个文件夹中去查找libsodemo.so找不到的,这是因为我们奇异果App自己的so肯定不在系统文件里面。那么为什么没有从奇异果的安装目录中查找呢?经过分析System.java的源码发现:
Runtime#loadLibrary方法会从传入的classLoader里查找so,那么问题会不会出在传入的ClassLoader身上呢?从日志上分析 loader==null 时才能输出"Library sodemo not found; tried [/vendor/lib/libsodemo.so, /system/lib/libsodemo.so]"的日志。ClassLoader是通过VMStack.getCallingClassLoader()获取的,它是用来获取调用者的ClassLoader。难道反射调用System.loadLibrary会改变调用堆栈,使得VMStack.getCallingClassLoader()获取到的ClassLoader为空吗?经过demo验证,反射调用时发现获取到的classLoader 为null,这时候就会从系统的路径下中查找so而导致加载失败。Robust可通过robust.xml文件配置在补丁中哪些类不设置反射,在<noNeedReflectClass>标签下配置了java.lang.System不反射调用后果然so加载成功了。
那么可以把补丁代码中用到的一些系统类比如Log、File、InputStream等设置为不使用反射吗?不仅减少补丁中的反射代码,同时也增加了易读性,减少性能消耗。尝试在一次构建补丁时,把补丁方法中使用的系统类全部配置为不反射调用,结果不出意外的出问题了。
现象是在kotlin代码的一个读写操作中引用到了FileInputStream和InputStreamReader,同时把这两个类设置了不反射调用,如下:
结果补丁运行后立即报错,错误信息是FileUtilKotlinPatch.readFile方法需要一个java.io.InputStream的参数但是传入了一个com.meituan.sample.FileUtilKotlin的参数。
查看构建补丁时生成的dump文件可以看到补丁中有如下几行代码:
验证字节码时InputeamReader构造函数的参数被认为传入了FileUtilKotlin类导致。所以在kotlin下的代码有些是不能设置不使用反射的,robust的配置文件中标注中说的配置不需要反射处理的类要慎重选择。
3.2.2 对kotlin的支持
自动化构建补丁对于kotlin代码的支持不是很好,除上面提到的配置非反射类之外,在面对when+enum的补丁(混淆情况下)也是遇到了问题。修复的方法中传入了一个枚举类,使用kotlin中的when关键字去匹配各种情况,结果执行到补丁的代码时报错找不到静态变量$EnumSwitchMapping$0。
反编译补丁和apk的代码发现变量$EnumSwitchMapping$0在补丁中被混淆成了a,然而在补丁中为int[] iArr = d.a.$EnumSwitchMapping$0;并没有用混淆后的值,同样的代码在java中生成的补丁为反射调用int[] iArr = (int[]) EnhancedRobustUtils.getStaticFieldValue("a", c.a.class);且运行正常。
那么就有两个问题:
1、为什么这行代码在kotlin中没有使用反射?
从自动化构建脚本上看读取变量值时的处理如下:
从源码可以看出对于变量的读取操作,如果变量是静态且public修饰的则保持不变,否则用反射方式调用。问题应该就出在变量是否有public修饰的问题上。反解apk把kotlin和java代码对比:
kotlin代码编译后有public static修饰:
java代码编译后没有public修饰:
这也就是为什么kotlin代码没有被反射的原因了。
2、对于直接引用的类或变量为什么没有在Smali汇编语言层做替换?
对于直接引用的变量值,robust的自动构建脚本会对smali文件进行逐行遍历并把原值替换成混淆后的值。那为什么没有把$EnumSwitchMapping$0替换成混淆后的值呢?经过日志发现虽然找到了$EnumSwitchMapping$0混淆后的对应关系,但是在执行替换时没有替换成功,替换操作如下:
这样就清楚了,在正则表达式中 $ 是个特殊字符。因此当regex字符串中包含 $ 字符时,须将其转义,以便它被解释为字面字符,也就是说regex应该改为regex = '->\\$EnumSwitchMapping\\$0'才能被正确替换。故这里的替换对于含有特殊字符的是有些隐患的,需要特别注意一下。
3.2.2 对so修复的支持
Robust本身并不支持对Native代码进行修复,但奇异果App中很多功能使用到了动态链接库,如果不能对Native代码进行热修,那么线上问题需要重新发版的概率依旧很高。
奇异果App使用了一个简单直接的方案来解决该问题。不直接对native代码进行修复,通过so的动态加载替换so库的方式来解决。从源码来看,当调用System.loadLibrary("libName")时,执行流程是这样的:
第三步中从classLoader中查找lib,找到后立即执行doload方法去加载。那么findLibrary最终是从nativeLibraryPathElements的数组中遍历,也就是说只要把修复好的so的路径插入到nativeLibraryPathElements数组的最前面,加载时就一定会优先加载修复后的so。如下:
3.2.2 补丁构建优化
根据我们打补丁的经验,完全依赖自动化构建有时会遇到一些棘手的问题,如前面提到的反射和kotlin补丁问题。线上着急修复,能否绕过,直接生成一个没有问题的补丁呢?
答案是肯定的。
经过分析生成的补丁文件,主要分为以下几部分:
PatchedClassInfo
这个类主要是混淆后的类名和补丁中转发器的映射关系,xxPatchControl
类称为转发器,负责把方法转发到对应的补丁方法。
xxPatch
这个是补丁类,包含了修复问题的全部代码。这部分代码较多,主要的代码就是对改动类的一次翻译:把改动方法中调用的方法/字段,全部改为了反射调用,同时解决Proguard造成的混淆、以及内联的问题。
xxInLinePatch
这个类是为了处理内联问题而产生的,把因为内联消失的代码放到了xxInLinePatch
中。
XXPatchRobustAssist
这个类别是为解决super问题引入的解决办法。
新增类,Add
注解加在哪个类上,就会把这个类放入补丁内部。
其中xxxPatch是真正用来修复问题的补丁类,我们上面遇到的几个错误都是出在这个类上。内部实现其实就是把被修复的方法复制到了这个补丁类,当然直接复制过来肯定是不行的,需要使用javassist对方法的实现进行了遍历,把调用的方法和字段改成了反射调用,同时解决混淆问题。
那么知道原理后,我们可以直接手写一个xxxPatch的补丁类。
第一步:创建xxxPatch类。
按照自动化生成的patch类的结构创建xxxPatch类,并声明一个被修复类的变量xxx originClass,通过构造方法传入被修复类的对象。
第二步:创建被修复的方法。
把原类中被修复的方法拷贝到xxxPatch中,方法中使用到其他类的非公共变量和方法,改为反射的方式进行调用。
第三步:处理super和私有方法。
被修复的方法如果调用了super或者是私有方法,按照自动化时构建的处理方式进行处理即可。
这样一个手写的补丁类就完成了。我们可以修改robust的配置文件同时在脚本构建过程中跳过xxxPatch的生成,只负责生成xxxPatchControl等类就可以了。
另外,release包经过了混淆,Robust在自动构建xxxPatch类的过程中把方法和字段的访问替换成反射调用,同时把要反射的方法和字段名称替换成混淆后的方法和字段名称。手写补丁的时候也需要这些操作,但各个不同渠道apk的混淆规则是不一样的。同一个字段在这个apk中被混淆成了a,另一个apk中可能被混淆成了b。多个apk就需要对应多个补丁包。如果每构建一个补丁包就手动修改映射关系,那对发布补丁来说是灾难性的。
那能否在编译阶段根据传入的混淆文件,自动把被反射变量替换成混淆后的呢?答案是不能,因为被反射的变量和方法名称属于运行时数据,即使我们在运行前已经知道了要反射的内容,但在编译阶段自动替换也不可行。所以我们使用了一个更简易的方案,即把所有需要反射调用的变量名称和方法放入配置文件中,在编译时找出混淆后的值并把这个映射关系生成一个map集合,这样在运行时反射调用改为从map集合中读取混淆后的值。
代码如下:
3.3 自动化部署
由于奇异果App对接合作厂商较多,每次升级需打包部署上百个APK升级包,同时会有 APK+插件混合模式。在面对线上问题需要修复时,同时支持几百个补丁包部署,对打补丁包和部署都是个较大的挑战,人工成本巨大且易出错,亟需一套简易快速的自动化部署平台。结合奇异果App业务特性,我们设计了一套完整的自动化部署打包方案如下:
通过该自动化部署,打通升级部署后台和打包平台,实现一键部署/灰度补丁能力,操作人只需关心原升级任务和补丁分支即可创建热修任务,大大降低了理解难度和操作工作量,即使非研发人员也可以操作部署。
04
成果和后续规划
经过不懈的努力,最终KRobust在奇异果上线了。修复范围、修复效率大幅度提升,同时修复成本大幅度降低。
在修复范围上,我们扩充了对native代码修复的支持,优化对kotlin代码的支持,进一步提升了该方案的能力覆盖度,线上问题可修复达到95%以上。同时借助于原方案的优势,后续适配问题相对较少,较低的android版本也可修复。
在修复效率上,为了进一步提升热修复成功率,奇异果TV新增了push消息和轮询机制保证获取补丁包的实时性,使补丁发布后,App可以第一时间获取补丁并加以修复。线上数据显示,各个步骤补丁下载成功率99.8%、安装成功率99.97、加载的成功率99.97%。在补丁发布后,24小时修复率超过90%,5日修复率超过99%。
在修复成本上,发现线上问题到修复上线,从以前3日左右(包含解决、部署插件+APK和联系合作方审批的时间)到现在24小时内,同时支持300+渠道APK补丁一键部署,大大降低了修复线上问题的成本和时长,避免了大面积客诉和故障。
后续,我们计划进一步提升补丁生成的简易度,如通过自动对比代码差异生成补丁,从而进一步降低补丁生成成本,降低运维人员操作成本。