热补丁方案有很多,其中比较出名的有腾讯Tinker、阿里的AndFix、美团的Robust以及QZone的超级补丁方案。他们的优劣如下:
一、Tinker 热修复
Tinker通过 Dexdiff 算法将原apk和修复后的apk中的dex文件进行对比,生成差分包,运行时将差分包中的dex和原包中的dex进行合并,从而加载差分包中修复好的类。因为是运行时加载的dex文件,所以修复完成后不能即时生效,需要重启app。
二、Qzone热修复
QQ空间的热修复原理和tinker有异曲同工之处,它基于dex分包方案,把bug类修复完成之后,单独生成一个dex文件,运行期间加载dex补丁,运行的是修复后的类。在Android中所有我们运行期间需要的类都是由ClassLoader(类加载器)进行加载,因此让ClassLoader加载全新的类替换掉出现Bug的类即可完成热修复。所以也需要重启才能生效。
三、AndFix热修复
在native动态替换java层的方法,通过native层hook java层的代码。执行方法时,会直接将修复后的方法再native层进行替换,达到修复的效果,这种方式修复后直接会生效,不需要重启。
四、Robust美团热修复方案
方法运行时会在方法内插入一段代码,如果有修复内容,会将执行的代码重定向到其他方法中。
参考了Instans Run的原理。这种方案也是不需要重启的
五、我们基于QQ空间的热修复方案进行研究
1. ART与Dalvik
什么是Dalvik:
Dalvik是Google公司自己设计用于Android平台的Java虚拟机。支持已转换为.dex(Dalvik Executable)格式的Java应用程序的运行,.dex格式是专为Dalvik应用设计的一种压缩格式,适合内存和处理器速度有限的系统。
什么是ART:
Android Runtime, Android 4.4 中引入的一个开发者选项,也是 Android 5.0 及更高版本的默认模式。在应用安装的时候Ahead-Of-Time(AOT)预编译字节码到机器语言,这一机制叫Ahead-Of-Time(AOT)预编译。应用程序安装会变慢,但是执行将更有效率,启动更快。
在Dalvik下,应用运行需要解释执行,常用热点代码通过即时编译器(JIT)将字节码转换为机器码,运行效率低。而在ART 环境中,应用在安装时,字节码预编译(AOT)成机器码,安装慢了,但运行效率会提高。
ART占用空间比Dalvik大(字节码变为机器码), “空间换时间"。
预编译也可以明显改善电池续航,因为应用程序每次运行时不用重复编译了,从而减少了 CPU 的使用频率,降低了能耗。
Dexopt与DexAot
这两个操作是Art架构安装时的操作, ART会执行AOT,但针对Dalvik 开发的应用也能在 ART 环境中运作。
dexopt:对dex文件进行验证和优化,优化后的格式为odex(Optimized dex) 文件
dexAot:在安装时对 dex 文件执行dexopt优化之后,再将odex进行 AOT 提前编译操作,编译为OAT可执行文件(机器码)
2. ClassLoader
Java 类加载器
BootClassLoader , 用于加载Android Framework层class文件。
PathClassLoader ,用于Android应用程序类加载器。可以加载指定的dex,以及jar、zip、apk中的classes.dex
DexClassLoader,加载指定的,以及jar、zip、apk 中的classes.dex。
我们可以在activity中打印来进行验证:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
/*
* 测试classLoader的一些使用情况
*/
// 我们外部的类都是用的PathClassLoader
ClassLoader classLoader1 = this.getClassLoader();
LogUtils.i("loader1 === " + classLoader1);
// 父加载器就是BootClassLoader,所以这个类先从framework中去查找,找不到就从我们本地中查找
LogUtils.i("loader1 parent === " + classLoader1.getParent());
// framework层的类加载都是用的BootClassLoader
ClassLoader classLoader2 = Activity.class.getClassLoader();
LogUtils.i("loader === " + classLoader2);
}
打印结果:
3. 源码跟踪
在虚拟机中,加载一个类时,使用的时ClassLoader中的loadClass方法进行加载的,看一下源码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// First, check if the class has already been loaded
// 一个类加载后会加入到缓存中,以后加载时从缓存中读取就可以了
// 如果找不到就从父亲classLoader中查找
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
return c;
}
代码中可以看到,虚拟机加载一个类时,会从父ClassLoader中查找这个类,父ClassLoade找不到会递归到父亲的父亲,如果祖辈都找不到时,才会使用当前的ClassLoader进行查找。这就是传说中的双亲委托机制。为什么这样做呢?
1、避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
2、安全性考虑,防止核心API库被随意篡改。
显而易见,这样不管是父加载器还是自己,都会走到findClass()方法:
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
ClassLoader中findCliss方法只抛出了一个异常?那肯定是它的子类重写并实现它了~, 在android中,ClassLoader都是继承了BaseDexClassLoader(可以看PathClassLoader和DexClassLoader, 往上有些人说PathClassLoader可以加载内部类,DexClassLoader才可以加载外部存储卡的文件,其实这两者都可以加载, 没有任何区别),以上代码就是在BaseDexClassLoader中实现了类的查找。里面是通过pathList来进行查找的。继续看pathList(DexPathList.java)中的实现:
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
可以看到,dexElements是一个数组, 这里遍历了dexElements,DexFile对象可以看作是dex文件, 如果找到了类直接返回了,这里验证了我们上面所说的。QQ空间热修复就是将修复包patch.dex加入到dexElements开始的位置,当虚拟机加载类时,会先从patch.dex中查找,找到了直接返回,找不到还使用原来的,这样就达到了热修复的效果。
dexElements是一个Element类型的数组,源码中这个Element是私有的,如何创建新的Element并加入到dexElements中呢? 先来看看源码中的dexElements是怎么创建的:
// save dexPath for BaseDexClassLoader
this.dexElements = makePathElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions);
在pathList的构造方法中可以看到,通过makePathElements可以创建一个element数组,所以我们通过反射来调用makePathElements方法创建一个新的数组,再获取到原数组,将两个数组合并到dexElements就可以了。
拿SDK23举例:
首先通过classloader找到pathList对象,
再执行pathList中的makePathElements方法创建补丁包的Element数组
反射拿到原来的dexElements数组
将两个数组进行合并,放到一个新的数组中
再反射修改dexElements,将新数组覆盖调原来的数组,完成热修复。
代码如下:
public static void install(ClassLoader classLoader,
File patch) {
List<File> patchs = new ArrayList<>();
patchs.add(patch);
// 查找pathList字段
Field pathListField = ReflectUtils.findField(classLoader, "pathList");
// 1. 获取pathList对象
try {
Object pathList = pathListField.get(classLoader);
if (pathList == null) {
throw new RuntimeException("pathList对象为空");
}
Method method = ReflectUtils.findMethod(pathList, "makePathElements", List.class, File.class, List.class);
ArrayList<IOException> suppressedExceptions = new ArrayList<>();
// 2. 补丁包的elements数组
Object[] patchElements = (Object[]) method.invoke(null, patchs, null, suppressedExceptions);
Field dexElementsField = ReflectUtils.findField(pathList, "dexElements");
// 3. 原来的dex数组
Object[] oldElements = (Object[]) dexElementsField.get(pathList);
// 进行合并
// 4. 首先利用反射创建一个盛放两个数组的新数组
Object[] newElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(),
oldElements.length + patchElements.length);
// 5. 将两个数组放到新数组中,补丁包的要放在前面
System.arraycopy(patchElements, 0, newElements, 0, patchElements.length);
System.arraycopy(oldElements, 0, newElements, patchElements.length, oldElements.length);
// 6. 将原来的dexElement数组用新数组替换掉
dexElementsField.set(pathList, newElements);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
LogUtils.i("error == " + e.getTargetException().getMessage());
}
}
sdk23 , 19 , 14, 4 这些版本创建dexElement数组的方式不一样,或许是方法名不同,或许是参数不同,需要对这几个版本单独做适配,这里只列举了sdk23的反射方法,其他版本原理相同。同时,这一部分内容可参考Tinker热修复方案来进行适配:tinker方案