导语
QFix 是手Q团队近期推出的一种新的 Android 热补丁方案,在不影响 App 运行时性能(无需插桩去 preverify)的前提下有效地规避了 dalvik 下”unexpected DEX”的异常,而且还是很轻量级的实现:只需调用一个很简单的方法就能办到。
热补丁方案及手Q上的使用
自2015年 Android 热补丁技术开始出现,之后各种方案和框架层出不穷,原创性的技术方案主要有以下几种:

性能无法提升,需要改变
插桩的解决方案会影响到运行时性能的原因在于:app 内的所有类都预埋引用一个独立 dex 的空类,导致安装 dexopt 阶段的 preverify 失败,运行时将再次 verify+optimize。近期我们通过 ReDex 尝试优化手Q的启动性能时发现:
- 保留手Q现有的插桩,启动性能没有任何优化效果;
- 去掉插桩,优化手Q启动相关类的 dex 分布,启动性能提升 30%。
另外即使后期手Q的发布版本实际上无需发布补丁,我们也需要预埋插桩的逻辑,这本身也是不合理的一点,所以确实有必要去探索新的方向,既保留补丁的能力,同时去掉插桩带来的负面影响。
重新分析”unexpected DEX”异常
寻找新的解决方案,还是需要回过头来分析下这个异常出现的条件:


主要思路是:每当系统调用到这个方法,通过 native hook 拦截这个系统方法,更改这个方法的入口参数,将 fromUnverifiedConstant 统一改为 true,但和 Andfix 类似,native hook 方式存在各种兼容性和稳定性问题,而且拦截的是一个涉及 dalvik 基础功能同时调用很频繁的方法,无疑风险会大很多。
找到新的“大陆”
这段逻辑所在的方法是 dvmResolveClass,通过类之间的引用会调用这个方法,入口参数分别是引用类的 ClassObject,被引用类的 classIdx,以及引用关联的 dalvik 指令是否为 const-class/instance-of,返回的是被引用类的 ClassObject,经反复阅读分析,终于发现了一个可以利用的细节:





另外考虑多 dex 的情况,补丁类很可能被多个不同 dex 里的类引用,那么需要在每个 dex 里找到一个引用类来预先引用补丁类吗?如果 app 里引用类和补丁类原本是在同一个 dex 里,引用类有可能是 preverify 的,这种情况是需要预先引用的;如果原本就不是一个 dex 里的,引用类由于有对其它 dex 类的依赖,就肯定不是 preverify 的,这种情况条件2本来就是不满足的,就没有必要预先引用了,所以可以推断出只需要针对补丁类在原先 App 所对应的 dex 进行预先引用即可。
梳理了思路后,马上在一个简单的 demo 上验证:


没那么简单,初步方案行不通
上面的 demo 预埋了补丁里包含的类,但在实际运用中我们是无法预先设定哪些类要打补丁的,dex 里对补丁类 const-class/instance-of 方式的引用指令是编译时确定的,但具体是哪些类又需要在运行时动态确定,所以这种动态方式行不通,最初想到的是类似插桩的做法,预先把 app 里所有类都以 const-class 方式引用一遍,但很明显有以下问题:
1)由于 App 里类的数量很多,所有类的预先引用统一放在一个地方肯定不现实,需要分散在多个区,只对补丁类所在的少数几个区执行预先引用的操作,但这里如何划分的粒度不好把握,而且 App 里的类及数量一直变化,我们做过一些尝试,但没有比较理想的可考量的方案。
2)预先引用解析所有类,会增加引用类的加载耗时和引用语句本身的执行耗时,对于执行耗时,可以通过添加条件判断来优化,如果要解析的类在补丁类名列表里就执行该语句,否则就不执行,对于加载耗时,初步的测试结果如下(这里一个划分的区包含500个左右的类,并进一步区分了是否 preverify,而测试的补丁包里包含2个类):

3)该方案实现起来特别繁琐,不实用。
确定最终方案
新的方案在 Java 层找不到可行的实现方式,就尝试从 native 层切入,只需首次引用解析补丁类时,直接通过 jni 调用 dalvik 的 dvmResolveClass 这个方法,当然传入的参数 fromUnverifiedConstant 需要设为 true,这个思路与前面说的 native hook 方式不同,不会去 hook 这个系统方法,而是从 native 层直接调用:
- dvmResolveClass 方法是在 dalvik 的系统库 /system/lib/libdvm.so 里,通过 dlopen 即可获取该系统库的句柄
- 通过 dlsym 获取 dvmResolveClass 这个方法的地址
- 设定 dvmResolveClass 这个方法的三个入口参数,再调用 dvmResolveClass:
1)引用类 referrer 的 ClassObject:这里需要设定一个引用类,并且能够获取到该类的 ClassObject;
2)补丁类的 classIdx:需要获取补丁类在 app 原先所在 dex 的 classIdx,通过这个 classIdx 可以在 dex 里找到已解析的类或者获取类的名字;
3)布尔值 fromUnverifiedConstant:在C/C++层,这个值可以固定设置为1或者 true。
这里的关键是能获取到前两个参数的值,第一个参数引用类的 ClassObject,最初借鉴的是 dvmResolveClass 里调用的 dvmFindClassNoInit 这个方法,但这个方法获取一个类的 ClassObject 需要两个参数,其中类名很容易构造,但需要额外的操作获取引用类的 ClassLoader 对象的地址,之后又找到一个更便利的方法 dvmFindLoadedClass:


输入: 原有 apk 的所有 dex、补丁包所有的类名
输出: 补丁包每个类所在 dex 的编号以及 classIdx 的值
注1: 如果在补丁新增原 app 不存在的类,运行时新增类只会被补丁 dex 即同一个 dex 里的类所引用,所以新增的补丁类无需预先解析引用。
注2: 由于”unexpected DEX”异常出现在 dalvik 的实现里,art 模式下不会存在,以上预先引用补丁类的逻辑只需用在5.0以下的系统。
最终新方案的整体实现流程如下图所示:

兼容性问题及解决
这个方案由于是 native 层的,我们也通过众测方式对兼容性做了充分的验证:
1. 不同系统版本导出符号:
在2.x版本dalvik是用C写的,2.3以上的4.x版本是用C++写的,基于C++ name mangling原理, dvmFindLoadedClass在编译后会变为_Z18dvmFindLoadedClassPKc,但经IDA反汇编libdvm.so分析,dvmResolveClass没有变化
2. yunos ROM的兼容性问题:
在第一次众测任务中,有446位用户参与,其中有6位反馈补丁不生效的问题,从反馈的结果码看都是libdvm.so加载成功,但是符号导出为NULL导致的,后来发现这6位用户安装的都是yunos的rom,经分析定位到原因如下:
可以看到dlopen libdvm.so时将库的名字改为了libvmkid_lemur.so,yunos的dalvik实现实际上在后面这个库里,而且通过反汇编发现导出的符号名也变化了,但内部的实现逻辑没有变化:
dvmResolveClass -> vResolveClass
_Z18dvmFindLoadedClassPKc -> _Z18kvmFindLoadedClassPKc
在dlsym调用时考虑以上两种可能的符号名即可,经本地和以上问题用户的再次验证,已成功解决。
3. x86平台的兼容性问题:
解决了yunos的兼容问题后,在第二次众测任务中,有1884位用户参与,有3位反馈异常,发现问题用户都是x86平台的,由于最开始未对x86平台作兼容,arm平台的动态库在x86手机上运行的异常有两种:
a) 部分手机一直卡在黑屏界面,经日志定位,这些手机都安装了houndini的第三方库,会自动将arm的so转换为x86平台兼容的,so加载及符号导出都没问题,在成功获取dvmResolveClass符号地址后,就一直卡在dvmResolveClass的调用逻辑里,应该是houndini库的转换问题
b) 部分手机运行正常,但导出符号都为NULL
在提供x86平台的so后,以上两个问题也成功解决了。
结语
本文探讨的主要是为解决补丁 Java 方案在 dalvik 下”unexpected DEX”异常提供一个新的思路,在整个 Android 补丁大的技术框架下,只是其中一个环节,有问题,欢迎大家多多交流!