文章目录
- 前言
- 什么是热修复?
- 如何进行热修复?
- 热修复需要解决的问题
- 1.Android常用的热修复解决方案
- 2.ClassLoader类加载机制
- 2.1 Android类加载器
- 2.2 双亲委托机制
- 2.3 类查找流程
- 3.插桩式热修复运行期修复落地
- 3.1 什么是字节码插桩?
- 3.2 ASM
- 3.3 热修复落地实现
- 3.4 热修复存在的版本兼容性问题
- 4.自动化补丁方案
- 4.1 自定义插件
- 4.2 判断哪些文件需要打包进补丁包
- 4.3 代码混淆问题的解决方案
- 面试题
- 1.说一说热修复的方案有哪些,并说说他们的区别?
- 2.热修复用到了哪些技术?说一说双亲委托机制?说说如何通过类加载和反射实现热修复的?
前言
什么是热修复?
应用在上线后出现bug需要及时修复,不用再发布新的安装包,只需要发布补丁包,在用户无感知情况下修复掉bug。
如何进行热修复?
- 服务端:补丁包管理
- 用户端:执行热修复
- 开发端:生成补丁包
热修复需要解决的问题
开发端
- 补丁包是什么?
补丁包就是修复了bug的dex文件和jar包。
- 如何生成补丁包?
将修复了bug的java文件通过javac编译成class文件,然后通过dx工具将class文件打包成dex文件或者是jar包,这就是补丁包。
- 开启混淆后呢?
开启混淆后,类名和包名会发生改变,为了保证混淆产生的包名和类名不变,我们需要通过mapping文件来解决。
- 对比改动自动生成补丁包(gradle)?
gradle生成热修复的插件方式,适合的场景是问题较小、编写的代码不多的情况。
用户端 - 什么时候执行热修复?
越早越好,要在加载bug类之前执行,完成热修复。
- 如果应用已经在运行,并且这个类已经加载过了,还能进行热修复吗?
不能进行热修复了,因为如果这个类已经加载过了,就会存在于缓存中,不会再从dex文件中去查找这个类的class文件。
- 热修复完成后,补丁包dex文件和原来的dex文件,是否需要删除?
不能删除。
如果补丁包dex文件被删除,缓存又被清空的情况下,又会执行原来没有bug的流程;
如果原来的dex文件被删除,那么dex文件中包含的所有逻辑代码都被会删除,我们的app将无法运行。 - 怎么执行热修复(使用补丁包)?
将补丁包下载下来,然后通过反射技术,将补丁包通过DexPathList中的makeElement方法生成修复bug的Element[]数组,再通过反射技术,获取到存在bug的Element[]数组,将修复了bug的Element[]和存在bug的Element[]拼接成一个新的数组,在进行类加载的时候,就会优先加载修复了bug的dex文件中的class,从而实现热修复。
- Android版本兼容性问题
- AndroidN(Android7.0)热点代码存在JIT即时编译,解决方案是自定义一个PathClassLoader,去掉缓存代码逻辑,就可以先加载修复了bug的代码。
- Dalvik(Android5.0)虚拟机存在不同的dex文件使用兼容性问题,如果没有直接使用别的dex文件中的类,该dex文件中这个类就会被打上标签标识,从而无法加载修复了bug的dex文件。
解决方案是,通过字节码插桩技术,对存在bug的dex文件中的字节码文件进行代码编写,引用别的dex文件中的类,消除标签标识,从而可以通过热修复加载修复bug的dex文件中的class,完成热修复。 - 不同的版本,一些方法的方法名和参数个数和参数类型不尽相同,所以也需要进行适配。
1.Android常用的热修复解决方案
Tinker(腾讯) | QZone(腾讯) | AndFix(阿里) | Robust(美团) | |
---|---|---|---|---|
类替换 | Y | Y | N | N |
so替换 | Y | Y | N | N |
资源替换 | Y | Y | N | N |
全平台生效 | Y | Y | Y | Y |
及时生效 | N | N | Y | Y |
性能损耗 | 较小 | 较大 | 较小 | 较小 |
补丁包大小 | 较小 | 较大 | 一般 | 一般 |
开发透明 | Y | Y | N | N |
复杂度 | 较小 | 较小 | 复杂 | 复杂 |
gradle支持 | Y | N | N | N |
rom体积 | 较大 | 较小 | 较小 | 较小 |
成功率 | 较高 | 较高 | 一般 | 最高 |
AndFix(补丁包是.dex文件
)
是由阿里开发的热修复框架,但是已经废弃,很多年没有维护了。
他的热修复实现原理如下:
在native层动态替换Java层的方法,通过native层hook Java层代码。
Robust(补丁包是.dex文件
)
由美团开发,目前抖音就采用这种热修复方案,可以及时生效。
他的热修复实现原理如下:
利用gradle插件,在我们写的类中,编译时生成一个静态接口变量,在方法中会对这个接口变量进行判断。
如果我们需要进行热修复,就写一个接口的实现类,然后通过类加载和反射,找到我们写的类并创建实例,赋值给要修复bug的类中的静态接口变量即可,当我们的方法中判断到这个接口变量非空时,就会去执行实现类中的业务逻辑,从而达到了修复bug的目的,并且是及时生效的。
Tinker(补丁包是差分包
)
由腾讯开发,目前微信就采用了这中热修复方案,优点是可以可以修复类、so包和资源文件。
他的热修复实现原理如下:
由有bug的apk文件和修复bug的apk文件共同生成一个差分包patch,这个patch文件就是我们的补丁包,下载这个差分包和有bug的apk,就可以生成修复bug的apk。如果没有资源文件修复,那么就会生成的就是一个.dex文件,可以通过类加载和反射技术,加载修复bug的类,从而实现热修复。
2.ClassLoader类加载机制
2.1 Android类加载器
ClassLoader继承关系:
- ClassLoader
- BootClassLoader
用于加载Android Framework层class文件
- BaseDexClassLoader
包含了DexPathList{dexElement[]、findClass()、makeElement[]}
- PathClassLoader
额外提供的动态类加载器
加载指定的dex、以及jar、zip、apk中的classes.dex
- DexClassLoader
Android应用程序类加载器
加载指定的dex、以及jar、zip、apk中的classes.dex
- PathClassLoader
- BootClassLoader
使用举例:
public class MyApplication extends Application {private final String TAG = MyApplication.class.getSimpleName();@Overridepublic void onCreate() {super.onCreate();//获取使用ClassLoader的场景ClassLoader classLoader1 = TKActivity.class.getClassLoader();ClassLoader classLoader2 = AppCompatActivity.class.getClassLoader();ClassLoader classLoader3 = Application.class.getClassLoader();ClassLoader classLoader4 = getClassLoader();Log.e(TAG, "classLoader1:" + classLoader1.toString());Log.e(TAG, "classLoader2:" + classLoader2.toString());Log.e(TAG, "classLoader3:" + classLoader3.toString());Log.e(TAG, "classLoader4:" + classLoader4.toString());}
}打印日志:
E/MyApplication: classLoader1:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/base.apk"],nativeLibraryDirectories=[/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/lib/arm64, /system/lib64, /product/lib64]]]
E/MyApplication: classLoader2:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/base.apk"],nativeLibraryDirectories=[/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/lib/arm64, /system/lib64, /product/lib64]]]
E/MyApplication: classLoader3:java.lang.BootClassLoader@4c33cb2
E/MyApplication: classLoader4:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/base.apk"],nativeLibraryDirectories=[/data/app/com.tangkun.xiangxuestudy-4SJ9lfa-q8--dmTGHgBPbA==/lib/arm64, /system/lib64, /product/lib64]]]
2.2 双亲委托机制
当我们在页面中使用PathClassLoader去加载一个类的全路径时,代码如下:
getClassLoader().loadClass("com.tangkun.study.MainActivity");
此时,getClassLoader方法返回的是PathClassLoader,但是该类中没有loadClass方法;所以我们从他的父类BaseDexClassLoader中去寻找这个方法,这个类中同样没有loadClas方法;所以我们再从他的父类ClassLoader中去查找这个方法,代码如下:
//ClassLoader.java
//这一段就是双亲委托机制代码,要找到某个class文件,先委托给父类parent去查找,
//如果父类没有查找到,则通过子类去查找class文件,并且找到的class文件会被存放在缓存中
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{//若class被加载过了,会存到缓存中。所以先从缓存中查找是否存在class//缓存中的class是从native层中查找得来Class<?> c = findLoadedClass(name);if (c == null) {try {//parent是BootClassLoaderif (parent != null) {//调用BootClassLoader.loadClass去查找class;//这里会调用到native层的findLoadedClass方法,从缓存中查找classc = parent.loadClass(name, false);} else {c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {}//如果父类无法加载到class,则从PathClassLoader中去查找classif (c == null) {//findClass是一个抽象方法,在子类BaseDexClassLoader中实现了该方法c = findClass(name);}}return c;
}//BaseDexClassLoader.java
private final DexPathList pathList;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {List<Throwable> suppressedExceptions = new ArrayList<Throwable>();//pathList是传入的DexPathList对象,所以我们从DexPathList类中查看findClass方法Class c = pathList.findClass(name, suppressedExceptions);//省略非核心代码return c;
}//DexPathList.java
//一个dex文件对应dexElements数组中一个元素Element;
//因为我们打出来的apk包中可能存在多个dex文件,所以是数组。
//DexPathList包含了内部类Element
private Element[] dexElements;
public Class<?> findClass(String name, List<Throwable> suppressed) {//从dex文件数组中遍历每个dex文件for (Element element : dexElements) {//Android中,多个java文件会被编译成多个.class文件,多个class文件会被打包成一个dex文件//一个dex文件中包含多个class文件,所以从class文件集合中去查找某一个确定的class文件//最后会调用到native层去查找这个classClass<?> clazz = element.findClass(name, definingContext, suppressed);if (clazz != null) {return clazz;}}//省略非核心代码return null;
}
2.3 类查找流程
class查找流程:
- 先从缓存中去查找class是否存在;
- 如果缓存中不存在,则委托给父类,由父类查找class是否存在;
- 若父类无法查找到class,则由当前子类去查到该类。
3.插桩式热修复运行期修复落地
制作补丁包流程:
1、把Bug修复掉后,先生成类的class文件。
2、执行命令:
dx --dex --output=patch.jar com/tangkun/study/Utils.class
应用补丁包: patchElment(补丁包生成的) + oldElement(APK原有的) 赋值给oldElement
1、获取程序的PathClassLoader对象
2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
3、反射获取pathList的dexElements对象 (oldElement)
4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
5、合并patchElement+oldElement = newElement (Array.newInstance)
6、反射把oldElement赋值成newElement
makePathElements参数:
1、补丁包:List[new File(“/sdcard/patch.jar”)]
2、optimizedDirectory 传一个私有目录就行比如:context.getCacheDir()
3、ArrayList suppressedExceptions = new ArrayList();
3.1 什么是字节码插桩?
所谓字节码插桩,就是在字节码文件中进行代码编写,而我们的class文件就属于字节码文件。
我们编写的java文件在经过编译后,会生成class文件,class文件是2进制格式的,0101这种格式,我们肯定是无法直接在这种格式的文件上进行编码的。所以我们需要借助于别的工具来进行字节码文件代码编写的,比如:第三方框架ASM
。
如果直接对字节码操作是什么样的一个流程呢?
- 通过文件输入流将字节码文件读到字节数组(
byte[]
)中; - 对字节数组(
byte[]
)中的数据进行修改; - 通过文件输出流将字节数组(
byte[]
)写回字节码文件。
3.2 ASM
概念:ASM是用来操作字节码(.java文件生成的.class文件
)的框架,按照class文件的格式,解析、修改、生成class,可以动态生成类或者增强现有类的功能。
类似于Gson框架,是用来操作json格式字符串的。
怎么使用ASM?
首先需要在build.gradle依赖ASM的的库,然后让我们需要使用字节码插桩的地方(比如:类、方法、属性),添加上ASM注解并带上插桩的实现类,然后在插桩的实现类中完成相应的功能。
开始和结束的地方,仍然还是需要使用文件输入输出流,用于读取和写入字节码文件;中间就是要我们自己实现借助于ASM来完成插桩代码的编写工作;在手写插桩代码之前,我们可以将需要实现的功能写入我们的java文件中,然后编译生成class文件,然后借助javap命令查看class文件中的代码(也可以通过ASM Bytecode Viewer插件来查看,我的AS由于版本原因,即使安装了这个插件还是看不了java文件的字节码代码;但是可以通过编译后生成的class文件,然后再利用这个插件查看,嘿嘿(*^▽^*)
),然后借助于这些代码和ASM的功能,来完成插桩代码的编写。
然后我们就可以得到修复bug后的class文件,然后通过SDK的dx工具,通过执行命令行的命令,可以将class文件转换成dex文件或者是jar文件,我们就直接转换成dex文件,给后面热修复使用。
3.3 热修复落地实现
1、获取程序的PathClassLoader对象
2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
3、反射获取pathList的dexElements对象 (oldElement)
4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
5、合并patchElement+oldElement = newElement (Array.newInstance)
6、反射把oldElement赋值成newElement
3.4 热修复存在的版本兼容性问题
AndroidN(也就是Andorid7.0)
会进行JIT即时编译编译,会将app经常使用的一些代码也叫做热点代码,存储到profile文件中,在手机空闲的时候,会执行这些热点代码,从而产生了缓存。
因此,会导致我们热修复失败,因为即使我们通过Hook技术将修复了bug的class放在了有bug的class数组前面;但是由于缓存的原因,不会访问我们这个修复了bug的Element数组;而直接从缓存中获取class进行加载,而这个缓存中的class是存在bug的。所以,热修复无效。
如何解决上面这个问题呢?
采取运行时替换掉PathClassLoader方案,重新创建一个自定义的PathClassLoader,但是不重写缓存的代码,虽然会存在一些性能的影响,但是相较于有bug,这点性能的损耗还是值得的;自定义的PathClassLoader由于没有重写缓存逻辑,因此我们在进行热修复后,会优先执行修复了bug的class,从而问题得到解决。
Android5.0 Dalvik虚拟机
5.0及以下使用的虚拟机是Darvik,存在不同的dex文件之间使用问题,如果有bug的dex文件的类中没有直接引用过别的dex文件中的类时(
需要对别的dex文件中类进行导包才行,反射不可以
),这个类就会被标记成isVerified;在有这个标记之后,有bug的dex文件就无法使用别的dex文件中的类。
如何解决上面的问题呢?
采用字节码插桩技术,在有bug的类编译成的class文件中,通过字节码插桩技术,将另外一个dex文件中的类通过插桩技术写入并且导包,从而将有bug的类取消isVerified标记。我们就可以通过热修复,将修复bug的dex文件中的class添加到有bug的class数组前面,从而优先执行我们修复bug的class中的代码。
扩展知识:
不同版本还存在方法名称、方法中参数个数以及类型的差异,所以,我们还需要对这些情况做版本适配。
比如:我们DexPathList类中的makePathElements方法,是用来生成Element[]数组的。我们通过热修复,将修复bug的dex文件添加到这个数组的前面,达到优先执行修复bug的目的。但是这个方法在不同的版本中存在一定的差异,举例:
Android9.0.0: makePathElements(List<File> files, File optimizedDirectory,List<IOException> suppressedExceptions)
Android5.1.0: makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions)
4.自动化补丁方案
4.1 自定义插件
自己开发插件有3种方式:
- Build script脚本
- 把插件写在build.gradle中,一般用于简单的逻辑,只在该build.grale中文件可见。
- buildSrc目录
- 将插件源代码放在buildSrc/src/main/groovy/中,只在该项目中可见。
- 实现Plugin接口,并重写apply方法。
- 独立项目
- 一个独立的Java项目/模块,可以将文件发布到仓库(Jcenter),使其他项目方便引入。
4.2 判断哪些文件需要打包进补丁包
可以采用缓存机制,将之前的class文件和这个class文件生成的md5值存起来;
- 如果缓存中没有这个class文件,则表示我们的这个class文件是新建的,所以需要打包进入补丁包
- 如果缓存中有这个class文件,那么就去比较缓存中class文件的md5值与我们重新生成的class文件的md5值是否一致,不一致表示新的class文件有代码更改,同样需要打包进入补丁包;
- 如果缓存文件和新生成的class文件的md5值相同,则无需打包进入补丁包。
扩展知识:
可以通过Java代码,执行命令行,然后进行打包操作:
- 把.java编译为.class
Runtime.getRuntime().exe("javac -bootclasspath android.jar路径 java源码和R.java路径");
- 把.class编译为.dex
Runtime.getRuntime().exe("dx --dex classes路径");
4.3 代码混淆问题的解决方案
需要保证这一次混淆后的类名和上一次混淆后的类名相同,通过-applyMapping配置
代码混淆的时候会生成一份mapping文件,通过将mapping文件缓存下来,然后找到没有混淆的文件名、方法名等,然后进行处理。
面试题
1.说一说热修复的方案有哪些,并说说他们的区别?
Tinker(腾讯) | QZone(腾讯) | AndFix(阿里) | Robust(美团) | |
---|---|---|---|---|
类替换 | Y | Y | N | N |
so替换 | Y | Y | N | N |
资源替换 | Y | Y | N | N |
全平台生效 | Y | Y | Y | Y |
及时生效 | N | N | Y | Y |
性能损耗 | 较小 | 较大 | 较小 | 较小 |
补丁包大小 | 较小 | 较大 | 一搬 | 一搬 |
开发透明 | Y | Y | N | N |
复杂度 | 较小 | 较小 | 复杂 | 复杂 |
gradle支持 | Y | N | N | N |
rom体积 | 较大 | 较小 | 较小 | 较小 |
成功率 | 较高 | 较高 | 一搬 | 最高 |
AndFix(补丁包是.dex文件
)
是由阿里开发的热修复框架,但是已经废弃,很多年没有维护了。
他的热修复实现原理如下:
在native层动态替换Java层的方法,通过native层hook Java层代码。
Robust(补丁包是.dex文件
)
由美团开发,目前抖音就采用这种热修复方案,可以及时生效。
他的热修复实现原理如下:
利用gradle插件,在我们写的类中,编译时生成一个静态接口变量,在方法中会对这个接口变量进行判断。
如果我们需要进行热修复,就写一个接口的实现类,然后通过类加载和反射,找到我们写的类并创建实例,赋值给要修复bug的类中的静态接口变量即可,当我们的方法中判断到这个接口变量非空时,就会去执行实现类中的业务逻辑,从而达到了修复bug的目的,并且是及时生效的。
Tinker(补丁包是差分包
)
由腾讯开发,目前微信就采用了这中热修复方案,优点是可以可以修复类、so包和资源文件。
他的热修复实现原理如下:
由有bug的apk文件和修复bug的apk文件共同生成一个差分包patch,这个patch文件就是我们的补丁包,下载这个差分包和有bug的apk,就可以生成修复bug的apk。如果没有资源文件修复,那么就会生成的就是一个.dex文件,可以通过类加载和反射技术,加载修复bug的类,从而实现热修复。
2.热修复用到了哪些技术?说一说双亲委托机制?说说如何通过类加载和反射实现热修复的?
热修复用到了类加载和反射技术。
双亲委托机制,就是在通过PathClassLoader加载某个类的时候,首先委托给他的父类去加载这个类,当父类没有加载成功时,才由子类去加载这个类。
要实现热修复功能,就需要用到Hook技术。
就是在加载有bug的class之前,先去加载我们修复了bug的class类,即可实现热修复技术。
class类是包含在dex文件中的,我们的app打包成apk的时候,就会生成一个.dex文件。如果我们要进行热修复,就需要生成修复了bug的dex文件,然后利用反射技术,在获取到PathClassLoader的前提下,然后通过反射去获取他的父类BaseDexClassLoader,然后再获取里面的属性(DexPathList)pathList,再获取这个属性中的Element[]数组,这个Element[]数组就是有bug的数组。
数组中的每个Element元素就对应一个dex文件,所以我们只需要再通过反射技术,将修复了bug的dex文件下载下来,然后通过makeElement方法生成修复了bug的Element[]数组。
最终将修复了bug的Element[]数组与有bug的Element[]数组组成一个新的数组,修复了bug的数组元素在前,存在bug的数组元素在后;在类加载加载class的时候,就会先加载到修复了bug的Element数组元素中的class类,从而修复了bug。