Android热修复笔记
前言
代码修复有两大主要方案,一种是阿里系的底层替换方案,另一种是腾讯系的类加载方案。
底层替换方案
优势
时效性最好,加载轻快,即时生效。
劣势
这个方案限制颇多,由于这类方案是根据公开的Android源码中的结构写死的,一旦厂商对ArtMethod结构体进行修改,那么就会出问题。
AndFix
替换方法流程
这里以Android版本大于4.4为例,进行分析
由流程图看出,替换方法所需ArtMethod中的字段有三个
- entry_point_from_interprete_
- entry_point_from_quick_compiled_code_
- dex_cache_resloved_methods
ArtMethod到底是什么呢?
ART运行时在内部又会使用另外两个不同的术语来描述类和方法,其中将类描述为Class,而将类方法描述为ArtMethod
由于对于ArtMethod的结构写死,所以AndFix不支持很多机型的原因,也就是因为这些机型修改了底层的虚拟机结构。
在AndFix基础上优化
通过对于ArtMethod字段替换到整个结构的替换。
分配Size流程如下:
如下图,通过在相邻两个方法中取对应ArtMethod的起始地址的差值来判断一个ArtMethod的大小。
访问权限
如何解决被替换的方法有权限访问这个类的其他private方法?
在机器码层面,调用逻辑和之前Activity的例子没有差别,并没有做任何的权限检查,所以替换后会正常执行而不是报错。
同包名下的权限问题
如果补丁中的类在访问同包名下的类时会报出访问权限异常。
原因是因为在虚拟机中,替换了的方法与原来的方法不是同一个ClassLoader来加载,所以导致两个类无法被判断为同包名。
解决办法:通过设置新类的ClassLoader为原来类的就行了。通过反射即可进行设置。
1 | Filed classLoaderField = Class.class.getDeclaredField("classLoader"); |
反射调用非静态方法产生的问题
当一个非静态方法被替换后,如果代码里在反射调用时,会抛出异常。
1 | // BaseBug.test 方法已经被热替换了 |
在invoke的时候就会抛出 IllegalArgumentException异常。因为虽然都叫com.patch.demo.BaseBug,却是不同的类,不同在于由于把ArtMethod的declaring_class_替换了,因此就是新的补丁类,后者作为被调用的实例对象bb所属类是原有的BaseBug,两者是不同的。
具体原因:
反射时调用InvokeMethod, InvokeMethod ——> VerifyObjectIsClass(receiver, declaring_class) ——> if(!o -> InstanceOF(c)){ //抛异常 }
由于静态类是在类级别的调用,不需要用到对象实例,就不会有这方面的检查。
解决方式:冷启动机制
哪些情况适用热启动?哪些情况适用冷启动?
不适用于热启动情况:
- 引起了原有的类中发生结构变化的修改
- 修复了的非静态方法会被反射调用
其余情况适合用冷启动的方式
你所不知道的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中的先后关系就是两者出现在源码中的先后关系。
尝试加载一个类的三个情况:
- new一个类的对象(new-instance指令)
- 调用类的静态方法(invoke-static指令)
- 获取类的静态域的值(sget指令)
执行的流程是dvmResolve -> Class -> dvmLinkClass -> dvmInitClass。
在dvmInitClass这个函数首先会对父类进行初始化,然后调用本类的clinit方法,所以此时静态field得到初始化和静态代码块得到执行。
父类初始化 ——> 本类的静态field和代码块初始化
非静态field初始化,非静态代码块编译流程是什么样的?
非静态field初始化和非静态代码块被翻译在
构造函数会被android编译器翻译成
String s = new String(“test”);在编译后翻译成如下代码
1 | new-instance vO, Ljava/lang/String; |
- 首先执行new-instance指令,分配堆内存
- 如果之前没加载过,尝试加载类
- invoke-direct指令调用类的init构造函数方法执行对象的初始化
静态field/代码块和非静态field/代码块支持热部署吗?
不支持
对于非静态filed/代码块,热部署视为一个普通方法的变更,此时对于热部署没有影响。
final static域的编译是什么样的?
final static的非引用类型并不是像static静态域一样被翻译到clinit方法中。
1 | public class DexFixDemo{ |
以上java文件转换为snail文件结构如下:
我们可以看到 s2 = “haha”, i2 = 2,这两个并不是在clinit中赋值的。
通过查看dex文件结构,在类定义区看到
所以得出以下结论:
- final static修饰的原始类型和String类型域(非引用类型),并不会翻译到clinit方法中,而是在类初始化执行initSFields方法中得到初始化赋值。
- 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方法中,所以此时没法走热部署。
哪些情况方法会被内联?
- 方法没有被其他任何地方引用到,毫无疑问,该方法会被内联掉。
- 方法足够简单,比如一个方法的实现就只有一行,该方法会被内联掉,那么任何调用该方法的地方都会被该方法的实现替换掉。
- 方法只被一个地方引用到,这个地方会被方法的实现替换掉。
混淆编译的过程是什么样的?
虚拟机对泛型是怎么处理的?
虚拟机巧妙使用了桥方法的方式,解决了类型擦除和多态的冲突。
通过bridge方法来重写父类方法。
jack工具链是什么?
热部署模式限制在哪?
不允许类结构变更以及不允许变更
QQ空间方案和Tinker方案有什么差异?
类校验(dvmVerifyClass)和类优化(dvmOptimizeClass)有什么区别?
插桩是什么?有什么影响?
插桩是指侵入dex打包流程,利用.class字节码修改技术,在所有.class文件的构造函数中引用这个帮助类,插桩由此而来。
插桩会给类加载效率带来比较严重的影响,一个类的加载通常有三个阶段,dvmResolveClass -> dvmLinkClass -> d-vmlinitClass。
如果类没有被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED标志,那么Verify和Optimize都会在类的初始阶段进行。如下图所示:
虽然会有性能开销,但是每个类只会加载一次,由jdk1.2版本后虚拟机的双亲委派模型决定的,但是在刚启动时的白屏启动页会加载大量的类,此时影响还是比较大。
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即可