奇异果TV热修复实践

  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方法进而略过之前的方法实现跳入补丁方法中执行,达到修复问题的目的。

a23de31f29d336096537da86e57eaeca.png

3.2 改进过程

根据奇异果TV实际业务需求,我们建立了KRobust工程,规避了反射系统类问题、优化了java和Kotlin lamada表达式和插件资源固定问题,增加了Native代码修复和手写补丁功能,同时,针对现有的发版机制,进一步对补丁部署流程进行优化,提高部署效率。

3.2.1 反射问题

Robust在为一个xxx类创建补丁时,会生成一个xxxPatch的补丁类并把要修复的方法从xxx类中搬运到xxxPatch中。由于直接把方法搬到xxxPatch肯定不适用,被修复方法的实现中会调用到的原类中的私有变量或方法,导致无法在xxxPatch类中直接使用,所以需通过javassist解析方法的字节码,把对应方法、变量的直接引用修改为反射的方式调用。如下:

9592e08900a6b94e1f85ef1ed827cff8.png

生成补丁后,补丁类中反编译代码如下:

9797169b4651f0235590ca936810b1e5.png

然而在调用一些系统方法时,如上面System.loadLibrary("sodemo")正常调用是没有问题的,但使用反射调用就会因为so找不到而报错。

98806c2700d1fc33956f71d43fc1e917.png

从报错日志来看,是从/vendor/lib和/system/lib这两个文件夹中去查找libsodemo.so找不到的,这是因为我们奇异果App自己的so肯定不在系统文件里面。那么为什么没有从奇异果的安装目录中查找呢?经过分析System.java的源码发现:

c3a9c660ac6a250bf1c588e202789489.png

96711914f3a2987625c18dcc1168dc19.png

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,同时把这两个类设置了不反射调用,如下:

a3f26cc4f514c308720b2aa67aae3e43.png

结果补丁运行后立即报错,错误信息是FileUtilKotlinPatch.readFile方法需要一个java.io.InputStream的参数但是传入了一个com.meituan.sample.FileUtilKotlin的参数。

f45a33ac3c103a1fc538511aeddcb987.png

查看构建补丁时生成的dump文件可以看到补丁中有如下几行代码:

8bc8bc8fba918964aff2f26456f6ca22.png

验证字节码时InputeamReader构造函数的参数被认为传入了FileUtilKotlin类导致。所以在kotlin下的代码有些是不能设置不使用反射的,robust的配置文件中标注中说的配置不需要反射处理的类要慎重选择。

3.2.2 对kotlin的支持

自动化构建补丁对于kotlin代码的支持不是很好,除上面提到的配置非反射类之外,在面对when+enum的补丁(混淆情况下)也是遇到了问题。修复的方法中传入了一个枚举类,使用kotlin中的when关键字去匹配各种情况,结果执行到补丁的代码时报错找不到静态变量$EnumSwitchMapping$0。

ded4af954708f4013ebc272049892819.png

反编译补丁和apk的代码发现变量$EnumSwitchMapping$0在补丁中被混淆成了a,然而在补丁中为int[] iArr = d.a.$EnumSwitchMapping$0;并没有用混淆后的值,同样的代码在java中生成的补丁为反射调用int[] iArr = (int[]) EnhancedRobustUtils.getStaticFieldValue("a", c.a.class);且运行正常。

那么就有两个问题:

1、为什么这行代码在kotlin中没有使用反射?

从自动化构建脚本上看读取变量值时的处理如下:

bc8370fc60882704a4ffaf973fe7e52f.png

从源码可以看出对于变量的读取操作,如果变量是静态且public修饰的则保持不变,否则用反射方式调用。问题应该就出在变量是否有public修饰的问题上。反解apk把kotlin和java代码对比:

kotlin代码编译后有public static修饰:

a47b433b57949b73e200e14e1c8b336e.png

java代码编译后没有public修饰:

805b6a1671f97bb2cfb0812afdb54a52.png

这也就是为什么kotlin代码没有被反射的原因了。

2、对于直接引用的类或变量为什么没有在Smali汇编语言层做替换

对于直接引用的变量值,robust的自动构建脚本会对smali文件进行逐行遍历并把原值替换成混淆后的值。那为什么没有把$EnumSwitchMapping$0替换成混淆后的值呢?经过日志发现虽然找到了$EnumSwitchMapping$0混淆后的对应关系,但是在执行替换时没有替换成功,替换操作如下:

32937afbbd0e16bc6fca7454dfcd641a.png

这样就清楚了,在正则表达式中 $ 是个特殊字符。因此当regex字符串中包含 $ 字符时,须将其转义,以便它被解释为字面字符,也就是说regex应该改为regex = '->\\$EnumSwitchMapping\\$0'才能被正确替换。故这里的替换对于含有特殊字符的是有些隐患的,需要特别注意一下。

3.2.2 对so修复的支持

Robust本身并不支持对Native代码进行修复,但奇异果App中很多功能使用到了动态链接库,如果不能对Native代码进行热修,那么线上问题需要重新发版的概率依旧很高。

奇异果App使用了一个简单直接的方案来解决该问题。不直接对native代码进行修复,通过so的动态加载替换so库的方式来解决。从源码来看,当调用System.loadLibrary("libName")时,执行流程是这样的:

1fb448d11f2ff8848973ed069e5cefef.png

第三步中从classLoader中查找lib,找到后立即执行doload方法去加载。那么findLibrary最终是从nativeLibraryPathElements的数组中遍历,也就是说只要把修复好的so的路径插入到nativeLibraryPathElements数组的最前面,加载时就一定会优先加载修复后的so。如下:

687b4589c6c23658d3acf6ef196be26e.png

3.2.2 补丁构建优化

根据我们打补丁的经验,完全依赖自动化构建有时会遇到一些棘手的问题,如前面提到的反射和kotlin补丁问题。线上着急修复,能否绕过,直接生成一个没有问题的补丁呢?

答案是肯定的。

经过分析生成的补丁文件,主要分为以下几部分:

4259cf9eda8c57a9b17b3a2f5d39fb2d.png

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集合中读取混淆后的值。

代码如下:

fb7018d2e8a82c2b919e1a4bbe7dddf6.png

3.3 自动化部署

由于奇异果App对接合作厂商较多,每次升级需打包部署上百个APK升级包,同时会有 APK+插件混合模式。在面对线上问题需要修复时,同时支持几百个补丁包部署,对打补丁包和部署都是个较大的挑战,人工成本巨大且易出错,亟需一套简易快速的自动化部署平台。结合奇异果App业务特性,我们设计了一套完整的自动化部署打包方案如下:

bb910f502b69312aa0614f62b33e5839.png

通过该自动化部署,打通升级部署后台和打包平台,实现一键部署/灰度补丁能力,操作人只需关心原升级任务和补丁分支即可创建热修任务,大大降低了理解难度和操作工作量,即使非研发人员也可以操作部署。

         04

成果和后续规划

经过不懈的努力,最终KRobust在奇异果上线了。修复范围、修复效率大幅度提升,同时修复成本大幅度降低。

在修复范围上,我们扩充了对native代码修复的支持,优化对kotlin代码的支持,进一步提升了该方案的能力覆盖度,线上问题可修复达到95%以上。同时借助于原方案的优势,后续适配问题相对较少,较低的android版本也可修复。

在修复效率上,为了进一步提升热修复成功率,奇异果TV新增了push消息和轮询机制保证获取补丁包的实时性,使补丁发布后,App可以第一时间获取补丁并加以修复。线上数据显示,各个步骤补丁下载成功率99.8%、安装成功率99.97、加载的成功率99.97%。在补丁发布后,24小时修复率超过90%,5日修复率超过99%。

在修复成本上,发现线上问题到修复上线,从以前3日左右(包含解决、部署插件+APK和联系合作方审批的时间)到现在24小时内,同时支持300+渠道APK补丁一键部署,大大降低了修复线上问题的成本和时长,避免了大面积客诉和故障。

后续,我们计划进一步提升补丁生成的简易度,如通过自动对比代码差异生成补丁,从而进一步降低补丁生成成本,降低运维人员操作成本。

  • 8
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值