热修复原理学习(4)冷启动类加载原理(1)

具体实现,首先通过补丁工具反编译dex为smali文件拿到以下文件。

  • preResolveClz

需要打包的类A的描述符,非必须,为了调试方便加上该参数而已

  • referClz

需要打包的类A所在的dex文件的任何一个类描述符,注意,这里不限定必须是引用补丁类A的某个类,实际上只要是同一个dex中的任意一个类都可以。所以我们直接拿原dex中的第一个类即可。

  • classIdx

需要打包的类A在原有dex文件中的类索引ID。

然后通过dlopen 拿到 libdvm.so库的句柄,通过 dlsym拿到该so库的 dvmResolveClass/dvmFindLoadedClass函数指针。首先需要预加载引用类xxx/xxx/class,这样dvmFindLoadedClass(xxx/xxx/class)返回值才不为null,然后 dvmFindLoadedClass()的执行结果得到的 ClassObejct作为第一个参数执行 dvmResolvedClass(class,id ,true)即可。

下面来看下JNI层代码实现。实际上可以看到 preResolveClz参数是非必须的:

jboolean resolveClodPatchClasses(JNIEnv *env, jclass clz, jstring preResolveClz, jstring refererClz, jlong classIdx, dexstuff_t *dexstuff) {

LOGD(“start resolveClodPatchClasses”);

ClassObject *refererObj = dexstuff->dvmFindLoadedClass_fnPtr(

Jstring2CStr(env, refererClz)); //通过refererClz 调用dvmFindLoadedClass加载补丁类

LOGD(“referrer ClassObject: %s\n”, refererObj->decriptor);

if (strlen(refererObj->descriptor) == 0) {

return JNI_FALSE;

}

ClassObject *resolveClass = dexstuff->dvmResolveClass-fnPtr(refererObj, classIdx, true); //调用dvmResolveClass方法

LOGD(“classIDx ClassObject: %s\n”, resolveClass->descriptor);

if (strlen(resolveClass->descriptor) == 0) {

return JNI_FLASE;

}

return JNI_TRUE;

}

这个思路不同于去Hook某个系统方法,而是从native层直接调用,同时更不需要插桩。具体实现需要注意以下3点:

  • dvmResolveClass的第三个参数 fromUnverifiedConstant必须为true。

  • 在Apk多dex的情况下,dvmResolveClass()的第一个参数referrer类必须跟需要打包的类在同一个dex中,但是它们两个类不需要存在任何引用关系,任何一个在同一个dex中的类作为referrer都可以。

  • referrer类必须提前加载。

然而,QFix的方案有它独特的缺陷,由于是在dexopt后绕过的,dexopt会改变原有的很多逻辑,许多odex层面的优化会固定字段和方法的访问偏移,这就会导致比较严重的bug,在2.2节会详细讲解这一影响。最后采用的是自研的全量dex方案,具体在下一章讲解。

1.4 Art下冷启动实现


前面说过补丁在热部署模式下是一个完整的类,补丁的粒度是类。现在的需求是补丁既能走热部署模式也能走冷启动模式,为了减小补丁包的大小,并没有为热部署和冷启动分别准备一套补丁,而是在同一个热部署模式下补丁能够降级直接走冷启动,所以不需要做dex merge。

但是通过前面的阅读,我们知道了为了解决Art下类地址写死的问题,Tinker通过 dex merge成一个全新完整的新dex整体替换掉旧的dexElements数组。事实上,Art虚拟机下面默许已经支持多dex压缩文件的加载。

下面分别来看一下 DVM和ART对 DexFile.loadDex()尝试把一个dex文件解析加载到native中,内存都发生了什么。实际上都是调用了 DexFile.openDexFileNative()这个native方法。看下 native层对应的 C/C++代码具体实现。

(1)在DVM中的实现:

// dalvik/vm/native/dalvik_system_DexFile.cpp

static void Dalvik_dalvik_system_DexFile_openDexFileNative(const u4* args,

JValue* pResult)

{

if (hasDexExtension(sourceName)

&& dvmRawDexFileOpen(sourceName, outputName, &pRawDexFile, false) == 0) { //加载一个原始dex文件

ALOGV(“Opening DEX file ‘%s’ (DEX)”, sourceName);

pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));

pDexOrJar->isDex = true;

pDexOrJar->pRawDexFile = pRawDexFile;

pDexOrJar->pDexMemory = NULL;

} else if (dvmJarFileOpen(sourceName, outputName, &pJarFile, false) == 0) { //加载一个压缩文件

ALOGV(“Opening DEX file ‘%s’ (Jar)”, sourceName);

pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));

pDexOrJar->isDex = false;

pDexOrJar->pJarFile = pJarFile;

pDexOrJar->pDexMemory = NULL;

} else {

ALOGV(“Unable to open DEX file ‘%s’”, sourceName);

dvmThrowIOException(“unable to open DEX file”);

}

}

int dvmJarFileOpen(…){

entry = dexZipFindEntry(&archive, kDexInJarName); /* kDexInJarName==“classes.dex”,说明只加载一个dex */

}

dvmJarFileOpen()方法中,Dalvik尝试加载一个压缩文件的时候只会去把 classes.dex加载到内存中。如果此时压缩文件中有多个dex文件,那么除了classes.dex之外的其他dex文件将会被直接忽略掉。

在Art虚拟机下:方法调用链 DexFile_openDexFileNative -> OpenDex.FilesFromOat -> LoadDexFiles

具体代码就不展示了,我们只需要知道,在Art下默认已经支持加载压缩文件中包含多个dex,首先肯定加载primary dex也就是 classes.dex,后续会加载其他的dex。所以补丁类只需要放到classes.dex中即可,后续出现在其他dex中的“补丁类”是不会被重复加载的。

所以Sophix得到在Art最终的冷启动方案:我们只要把补丁dex命名为classes.dex。原Apk中的dex依次命名为 classes(2,3,4...).dex就可以了,然后一起打包为一个压缩文件,在通过 DexFile.loadDex()得到DexFile对象,最后用该DexFile对象整体替换旧的dexElements数组就可以了。

Sophix方案和Tinker方案的不同点如下所示:

在这里插入图片描述

需要注意:

  • 补丁dex必须命名为classes.dex

  • loadDex()得到的DexFile完整替换掉 dexElements数组而不是插入。

1.5 不得不说的其他点


我们知道DexFile.loadDex()尝试把一个dex文件解析并加载到native内存, 在加载到native内存之前, 如果dex不存在对应的odex, 那么Dalvik下会执行dexopt, Art下会执行dexoat, 最后得到的都是一个优化后的odex。 实际上最后虚拟机执行的是这个odex而不是dex。

现在有这么一个问题,如果dex足够大那么dexopt/dexoat实际上是很耗时的,根据上面我们提到的方案, Dalvik下实际上影响比较小, 因为loadDex仅仅是补丁包。 但是Art下影响是非常大的, 因为loadDex是补丁dex和apk中原dex合并成的一个完整补丁压缩包, 所以dexoat非常耗时。

所以如果优化后的odex文件没生成或者没生成一个完整的odex文件, 那么loadDex便不能在应用启动的时候进行的, 因为会阻塞loadDex线程, 一般是主线程。 所以为了解决这个问题, 我们把loadDex当做一个事务来看, 如果中途被打断, 那么就删除odex文件, 重启的时候如果发现存在odex文件, loadDex完之后, 反射注入/替换dexElements数组, 实现patch。 如果不存在odex文件, 那么重启另一个子线程loadDex, 重启之后再生效。

另外一方面为了patch补丁的安全性, 虽然对补丁包进行签名校验, 这个时候能够防止整个补丁包被篡改, 但是实际上因为虚拟机执行的是odex而不是dex, 还需要对odex文件进行md5完整性校验, 如果匹配, 则直接加载。 不匹配,则重新生成一遍odex文件, 防止odex文件被篡改。

1.6 完整的方案考虑


代码修复冷启动方案由于它的高兼容性, 几乎可以修复任何代码修复的场景, 但是注入前被加载的类(比如:Application类)肯定是不能被修复的。 所以我们把它作为一个兜底的方案, 在没法走热部署或者热部署失败的情况, 最后都会走代码冷启动重启生效, 所以我们的补丁是同一套的。 具体实施方案对Dalvik下和Art下分别做了处理:

  • Dalvik下通过巧妙的方式避免插桩, 没有带来任何类加载效率的影响。

  • Art下本质上虚拟机已经支持多dex的加载, 我们要做的仅仅是把补丁dex作为主dex(classes.dex)加载而已。

2 多态对冷启动类加载的影响

===============================================================================

前面我们知道冷启动方案几乎可以修复任何场景的代码缺陷,但Dalvik下的QFix方案存在很大的限制,下面将深入介绍在目前方案下为什么会有这些限制,同时给出具体的解决方案。

2.1 重新认识多态


实现多态的技术一般叫做动态绑定,是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。多态一般指的是非静态私有方法的多态,field和静态方法不具有多态性。示例如下:

public class B extends A {

Strign name = “B name”;

@Override

void a_t1() {

System.out.println(“B a_t1”);

}

void b_t1(){}

public static void main(String[] args) {

A obj = new B();

System.out.println(obj.name);

obj.a_t1();

}

}

class A {

String name = “A name”;

void a_t1() {

System.out.println(“A a_1…”);

}

void a_t2();

}

输出结果: A name / B a_t1

可以看到name这个field没有多态性,print这个方法具有多态性,这里先分析一下方法多态性的实现。首先 new B()的执行会尝试加载类B,方法调用链 dvmResolveClass->dvmLinkClass->createVtable,此时会为类B创建一个vtable,其实在虚拟机中加载每个类都会为这个类生成一张vtable表,vtable表就是当前类的所有virtual方法的一个数组,当前类和所有继承父类的public/protected/default方法就是virtual方法,因为public/protected/default修饰的方法是可以被继承的。private/static方法不属于这个范畴,因为不能被继承。

这里就不放 createVtable()的代码了,有兴趣的可以自行上网查阅,这里来大概说一下它做了什么,子类vtable的大小等于子类virtual方法数+父类vtable的大小:

  • 整体复制父类的vtable到子类的vtable

  • 遍历子类的virtual方法集合,如果方法原型一致,说明是重写父类方法,那么在相同索引位置处,子类重写方法覆盖掉vtable中父类的方法

  • 若方法原型不一致,那么把该方法添加到vtable末尾。

所以在上述示例中,假如父类A的vtable是 vtable[0]=A.a_t1, vtable[1]=A.a_t2,那么B类的vtable就是 vtable[0]=B.a_t1, vtable[1]=A.a_t2, vtable[2]=B.b_t1。接下来 obj.a_t1()发生了什么。invoke-virtual指令的解释如下:

if(methodCallRange) {

thisPtr = (Object*) GET_REGISTER(vdst);

} else {

thisPtr = (Object*) GET_REGISTER(vdst & 0x0f); //当前对象

}

baseMethod = dvmDexGetReslvedMethod(methodClassDex, ref); //是否已经解析过该方法

if(baseMethod == NULL) {

baseMethod = dvmResolveMethod(curMethod->clazz, ref, METHOD_VIRTUAL);

//没有解析过该方法调用 dvmResolveMethod,baseMethod得到的当然是A.a_t1方法

}

methodToCall = thisPtr->clazz->vtable[baseMethod->methodIndex]; /* A.a_t1方法在类A的vtable中的索引去类B的vtable中查找

首先 obj 引用类型是基类A,所以上述代码中 baseMethod拿到的是 A.a_t1()baseMethod->methodIndex是该方法在类A的vtable中的索引0,obj的实际类型是类B,所以thisPtr->clazz就是类B,那么 B.vtable[0]就是 B.a_t1()方法,所以 obj.a_t1()实际上最后调用的是 B.a_t1()方法。这样就实现了方法的多态。

至于field/static方法为什么不具有多态性,这里不进行详细的代码分析,有需要的可以看 iget/invoke-static的指令解释,简单来讲,是从当前变量的引用类型而不是实际类型中查找,如果找不到,再去父类中递归查找。

所以field和static方法不具备多态性。

2.2 冷启动方案限制


下面来看一下如果新增了一个 public/protected/default方法,会出现什么情况。

public class Demo {

public static void test_addMethod(){

A obj = new A();

obj.a_t2();

}

}

class A {

int a =0;

//新增a_t1方法

void a_t1() {

Log.d(“Sophix”,“A a_t1”);

}

void a_t2() {

Log.d(“Sophix”,“A a_t2”);

}

}

修复后的APK中新增了 a_t1()方法,DEMO类不做任何的修复,测试发现应用补丁后Demo.test_addMethod()得到的结果竟然是 Sophix: A a_t1,这表明 obj.a_t2()执行的是 a_t1()方法,下面深入分析一下本质原因。

在 2.1节提到过,在dex文件第一次加载的时候,会执行dexopt,dexopt有 verify和optimize两个过程,那分别就是类校验和类优化。

这里主要介绍一下 optimize阶段:

//Android4.4 dalvik/vm/analysis/Optimize.cpp

void dvmOptimizeClass(ClassObject* clazz, bool essentialOnly)

{

int i;

for (i = 0; i < clazz->directMethodCount; i++) {

optimizeMethod(&clazz->directMethods[i], essentialOnly); // 1

}

for (i = 0; i < clazz->virtualMethodCount; i++) {

optimizeMethod(&clazz->virtualMethods[i], essentialOnly); // 2

}

}

static void optimizeMethod(Method* method, bool essentialOnly)

{

/*

  • non-essential substitutions:

  • invoke-{virtual,direct,static}[/range] --> execute-inline

  • invoke-{virtual,super}[/range] --> invoke-*-quick

*/

if (!matched && !essentialOnly) {

switch (opc) {

case OP_INVOKE_VIRTUAL:

if (!rewriteExecuteInline(method, insns, METHOD_VIRTUAL)) {

rewriteVirtualInvoke(method, insns, //4

OP_INVOKE_VIRTUAL_QUICK);

}

break;

}

注释1:对direct方法进行类优化(即不能继承的方法)

注释2:对virtual方法进行类优化(即可以继承的方法)

注释3:如果是虚方法,重写 invoke-virtual为虚拟机内部指令 invoke-virtual-quick,这个指令后面跟的立即数(insns)就是该方法在类vtable中的索引值

invoke-virtual-quick 效率比 invoke-virtual更高,因为它直接从实际类型的vtable中获取调用方法指针,而省略了 dvmResolveMethod从变量的引用类型获取方法在vtable索引ID的步骤,所以更高效。

所以很容易知道在上面代码中示例中,方法调用错乱发生的本质原因了。打包前类A的 vtable值时 vtable[0]=a_t2。打包后类新增了a_t1方法, 那么类A的vtable值为 vtable[0]=a_t1, vtable[1]=a_t2,但是 obj.a_t2()这行代码在odex中的指令实际上是 invoke-virtual-quick A.vtable[0],所以导包前调用的是 a_t2()方法,打包后调用的是 a_t1方法,导致了方法的调用错乱。

(其实就是加载期类优化所导致的)

2.3 终极解决方案

写在最后

由于本文罗列的知识点是根据我自身总结出来的,并且由于本人水平有限,无法全部提及,欢迎大神们能补充~

将来我会对上面的知识点一个一个深入学习,也希望有童鞋跟我一起学习,一起进阶。

提升架构认知不是一蹴而就的,它离不开刻意学习和思考。

**这里,笔者分享一份从架构哲学的层面来剖析的视频及资料分享给大家,**梳理了多年的架构经验,筹备近1个月最新录制的,相信这份视频能给你带来不一样的启发、收获。

最近还在整理并复习一些Android基础知识点,有问题希望大家够指出,谢谢。

希望读到这的您能转发分享和关注一下我,以后还会更新技术干货,谢谢您的支持!

转发+点赞+关注,第一时间获取最新知识点

Android架构师之路很漫长,一起共勉吧!
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

2.3 终极解决方案

写在最后

由于本文罗列的知识点是根据我自身总结出来的,并且由于本人水平有限,无法全部提及,欢迎大神们能补充~

将来我会对上面的知识点一个一个深入学习,也希望有童鞋跟我一起学习,一起进阶。

提升架构认知不是一蹴而就的,它离不开刻意学习和思考。

**这里,笔者分享一份从架构哲学的层面来剖析的视频及资料分享给大家,**梳理了多年的架构经验,筹备近1个月最新录制的,相信这份视频能给你带来不一样的启发、收获。

[外链图片转存中…(img-d4pLPD2N-1715340620480)]

[外链图片转存中…(img-FM82Wl4D-1715340620480)]

最近还在整理并复习一些Android基础知识点,有问题希望大家够指出,谢谢。

希望读到这的您能转发分享和关注一下我,以后还会更新技术干货,谢谢您的支持!

转发+点赞+关注,第一时间获取最新知识点

Android架构师之路很漫长,一起共勉吧!
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值