Android热修复笔记

Android热修复笔记

前言

代码修复有两大主要方案,一种是阿里系的底层替换方案,另一种是腾讯系的类加载方案。

底层替换方案

优势

时效性最好,加载轻快,即时生效。

劣势

这个方案限制颇多,由于这类方案是根据公开的Android源码中的结构写死的,一旦厂商对ArtMethod结构体进行修改,那么就会出问题。

AndFix

替换方法流程

这里以Android版本大于4.4为例,进行分析

AndFix替换方案

由流程图看出,替换方法所需ArtMethod中的字段有三个

  1. entry_point_from_interprete_
  2. entry_point_from_quick_compiled_code_
  3. dex_cache_resloved_methods

ArtMethod到底是什么呢?

ART运行时在内部又会使用另外两个不同的术语来描述类和方法,其中将类描述为Class,而将类方法描述为ArtMethod

由于对于ArtMethod的结构写死,所以AndFix不支持很多机型的原因,也就是因为这些机型修改了底层的虚拟机结构。

在AndFix基础上优化

通过对于ArtMethod字段替换到整个结构的替换。

分配Size流程如下:

ArtMethod分配Size流程

如下图,通过在相邻两个方法中取对应ArtMethod的起始地址的差值来判断一个ArtMethod的大小。

地址差值

访问权限

如何解决被替换的方法有权限访问这个类的其他private方法?


在机器码层面,调用逻辑和之前Activity的例子没有差别,并没有做任何的权限检查,所以替换后会正常执行而不是报错。

同包名下的权限问题

如果补丁中的类在访问同包名下的类时会报出访问权限异常。

原因是因为在虚拟机中,替换了的方法与原来的方法不是同一个ClassLoader来加载,所以导致两个类无法被判断为同包名。

解决办法:通过设置新类的ClassLoader为原来类的就行了。通过反射即可进行设置。

1
2
3
Filed classLoaderField = Class.class.getDeclaredField("classLoader");
classLoaderField.setAccessible(true);
classLoaderField.set(newClass,oldClass.getClassLoader());

反射调用非静态方法产生的问题

当一个非静态方法被替换后,如果代码里在反射调用时,会抛出异常。

1
2
3
4
// BaseBug.test 方法已经被热替换了
BaseBug bb = new BaseBug();
Method testMeth = BaseBug.class.getDeclaredMethod("test");
testMeth.invoike(bb);

在invoke的时候就会抛出 IllegalArgumentException异常。因为虽然都叫com.patch.demo.BaseBug,却是不同的类,不同在于由于把ArtMethod的declaring_class_替换了,因此就是新的补丁类,后者作为被调用的实例对象bb所属类是原有的BaseBug,两者是不同的。

具体原因:
反射时调用InvokeMethod, InvokeMethod ——> VerifyObjectIsClass(receiver, declaring_class) ——> if(!o -> InstanceOF(c)){ //抛异常 }

由于静态类是在类级别的调用,不需要用到对象实例,就不会有这方面的检查。

解决方式:冷启动机制

哪些情况适用热启动?哪些情况适用冷启动?

不适用于热启动情况:

  1. 引起了原有的类中发生结构变化的修改
  2. 修复了的非静态方法会被反射调用

其余情况适合用冷启动的方式

你所不知道的Java

内部类

内部类会在编译期编译成跟外部类一样的顶级类

静态内部类/非静态内部类有什么区别?

非静态内部类,在编译期间会产生this$0,表示的是外部类的引用。

既然内部类会在编译期间编译成跟外部类一样的顶级类,那么外部类怎么访问的内部类的私有方法?

因为在编译期间自动会为内部类生成access&**相关方法,这个方法会简单返回私有域s的值。

如何防止编译器生成access&**方法,从而使得能够进行热部署(不会改变方法数,从而也不会改变索引)?

  • 把内部类的所有method/field的private访问权限改成protected或者默认访问权限public
  • 如果一个类有内部类,那么把自身的method/field改成protected或者public

对于新增/删除匿名内部类,如何进行热部署?

编译器根据该匿名内部类在外部类中出现的先后关系,依次累加命名

比如第一个匿名内部类叫DexFixDemo&1。第二个叫DexFixDemo&2,由于补丁工具拿到的是编译后的.class文件,所以无法区分DexFixDemo&1/2类,所以要避免插入一个新的匿名内部类。

当然如果匿名内部类在外部类的尾部,是允许的。

静态field初始化和静态代码块编译流程是什么样的?

静态field和代码块初始化在编译后会放在方法中

静态代码块和静态域初始化在clinit中的先后关系就是两者出现在源码中的先后关系。

尝试加载一个类的三个情况:

  1. new一个类的对象(new-instance指令)
  2. 调用类的静态方法(invoke-static指令)
  3. 获取类的静态域的值(sget指令)

执行的流程是dvmResolve -> Class -> dvmLinkClass -> dvmInitClass。

在dvmInitClass这个函数首先会对父类进行初始化,然后调用本类的clinit方法,所以此时静态field得到初始化和静态代码块得到执行。

父类初始化 ——> 本类的静态field和代码块初始化

非静态field初始化,非静态代码块编译流程是什么样的?

非静态field初始化和非静态代码块被翻译在无参构造函数中。

构造函数会被android编译器翻译成方法,实际上如果存在有参构造函数,那么每个有参构造函数都会执行一个非静态域的初始化和非静态代码块。

String s = new String(“test”);在编译后翻译成如下代码

1
2
new-instance vO, Ljava/lang/String;
invoke-direct {v0}, Ljava/lang/String;-><init>()V
  1. 首先执行new-instance指令,分配堆内存
  2. 如果之前没加载过,尝试加载类
  3. invoke-direct指令调用类的init构造函数方法执行对象的初始化

静态field/代码块和非静态field/代码块支持热部署吗?

不支持方法的热部署,所以静态filed/代码块都不支持更改。

对于非静态filed/代码块,热部署视为一个普通方法的变更,此时对于热部署没有影响。

final static域的编译是什么样的?

final static的非引用类型并不是像static静态域一样被翻译到clinit方法中。

1
2
3
4
5
6
7
8
9
10
public class DexFixDemo{
static Temp t1 = new Temp();
final static Temp t2 = new Temp();

final static String s1 = new String("heihei")l
final static String s2 = "haha";

static int i1 = 1;
final static int i2 = 2;
}

以上java文件转换为snail文件结构如下:

snail文件结构

我们可以看到 s2 = “haha”, i2 = 2,这两个并不是在clinit中赋值的。

通过查看dex文件结构,在类定义区看到

dex类定义区

所以得出以下结论:

  1. final static修饰的原始类型和String类型域(非引用类型),并不会翻译到clinit方法中,而是在类初始化执行initSFields方法中得到初始化赋值。
  2. final static 修饰的引用类型,初始化仍然在clinit方法中。

关于什么是smail文件,可以点此阅读,smali浅析

final static 域优化原理

如果一个field是常量,通过final static优化只有原始类型和String类型域(非引用类型),如果是引用类型,实际上得不到任何优化。

final static域初始化流程是什么样的?

const/4 -> 操作数在dex中的位置就是在opcode后一个字节

非final域的初始化流程是什么样的?

sget -> dvmDexGetResolvedField -> dvmResolveStaticField -> dvmResolveClass -> dvmGetStaticFieldInt(sfiled)

可见sget指令比const/4要复杂很多。

为什么final static引用类型没有得到优化?

因为不管是final,最后都是通过sget-object指令去获取该值。

热部署对于final static域的解决方案

  • 修改final static 基本类型或者String类型域(非引用类型)域,由于编译器间引用到基本类型的地方被立即数替换,引用到String类型(非引用类型)的地方被常量池索引id替换,所以在热部署模式下,最终所有引用该final static域的方法都会被替换。实际上此时仍然可以走热部署。
  • 修改final static引用类型域,是不允许的,因为这个field的初始化会被翻译到clinit方法中,所以此时没法走热部署。

哪些情况方法会被内联?

  1. 方法没有被其他任何地方引用到,毫无疑问,该方法会被内联掉。
  2. 方法足够简单,比如一个方法的实现就只有一行,该方法会被内联掉,那么任何调用该方法的地方都会被该方法的实现替换掉。
  3. 方法只被一个地方引用到,这个地方会被方法的实现替换掉。

混淆编译的过程是什么样的?

代码混淆

虚拟机对泛型是怎么处理的?

虚拟机巧妙使用了桥方法的方式,解决了类型擦除和多态的冲突。
通过bridge方法来重写父类方法。

桥模式

jack工具链是什么?

热部署模式限制在哪?

不允许类结构变更以及不允许变更方法,所以如果补丁发现这几种限制情况,那么只能走冷启动重启才能生效。

QQ空间方案和Tinker方案有什么差异?

类校验(dvmVerifyClass)和类优化(dvmOptimizeClass)有什么区别?

插桩是什么?有什么影响?

插桩是指侵入dex打包流程,利用.class字节码修改技术,在所有.class文件的构造函数中引用这个帮助类,插桩由此而来。

插桩会给类加载效率带来比较严重的影响,一个类的加载通常有三个阶段,dvmResolveClass -> dvmLinkClass -> d-vmlinitClass。

如果类没有被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED标志,那么Verify和Optimize都会在类的初始阶段进行。如下图所示:

虽然会有性能开销,但是每个类只会加载一次,由jdk1.2版本后虚拟机的双亲委派模型决定的,但是在刚启动时的白屏启动页会加载大量的类,此时影响还是比较大。

QFix是怎么绕过类验证的?

QFix

详细来说就是通过dlsym拿到该so库的dvmResolveClass/dvmFindLoadedClass函数指针,首先预加载引用类,这样dvmFindLoadedClass才不会为null,dvmFindLoadedClass执行结果得到ClassObject做为第一个参数执行dvmResolveClass(AnimRes, 2425, true)即可。

Dalvik虚拟机加载一个压缩文件是什么过程?

Dalvik尝试加载一个压缩文件的时候,只会去把classes.dex加载到内存中。除了classes.dex之外的其他dex被直接忽略掉。

Art虚拟机加载一个压缩文件是什么过程?

Art虚拟机的方法调用链如下:DexFile_openDexFileNative -> OpenDexFilesFromOat -> LoadDexFiles

在LoadDexFiles中,我们可以看出,虽然Art默认支持加载压缩文件中包含多个dex的情况, 优先加载primary dex这个classes.dex,虽然后续会加载其他dex,补丁类只需要放到classes.dex即可。后续出现在其它dex中的补丁类是不会被重复加载的

通过替换dex的方案思路是什么样的?

我们只要把补丁dex命名为classes.dex。原apk中的dex依次命名为classes(2,3,4…)dex就好了,然后一起打包为一个压缩文件。然后DexFile.loadDex得到DexFile对象,最后把该DexFile对象整个替换为旧的dexElements数组就可以了。

Tinker和Sophix在合成dex时有什么区别?

  • 补丁dex必须命名为classes.dex
  • loadDex 得到的DexFile完整替换掉dexElements数组而不是插入

在冷启动修复过程中遇到哪些性能问题?怎么解决的?

在加载dex文件到native内存之前,如果dex不存在对应的odex,那么Dalvik下会执行dexopt,Art下会执行dexoat,最后得到的都是一个优化后的odex。虚拟机最后执行的都是odex而不是dex。

如果dex足够大,那么dexopt(Dalvik)/dexoat(Art)是很耗时的。Dalvik稍微好一点在于只会load classes.dex(也就是补丁包),而在Art下,load的是dex和apk的原dex合并的完整补丁包(Tinker方案),这样的dexoat非常耗时。

解决方式:
把loadDex当做一个事物来看,如果中途被打断,那么就删除odex文件,重启的时候如果发现存在odex文件,loadDex完之后,反射注入/替换dexElements数组,实现patch。如果不存在odex文件,重启另一个子线程loadDex,重启之后再生效。
对于补丁包的安全性,我们需要对补丁包进行签名校验与md5校验。

怎么实现Dalvik和Art下共用一套补丁?

  • Dalvik下采用自行研发的全量DEX方案
  • Art下虚拟机已经支持多dex,只需要把补丁dex作为主dex即可