Android 热修复原理及Gradle插件源码解析(以Nuwa为例)
现在,热修复的具体实现方案开源的也有很多,原理也大同小异,本篇文章以Nuwa为例,深入剖析。
Nuwa的github地址
https://github.com/jasonross/Nuwa
以及用于hotpatch生成的gradle插件地址
https://github.com/jasonross/NuwaGradle
而Nuwa的具体实现是根据QQ空间的热修复方案来实现的。从QQ空间终端开发团队的文章中可以总结出要进行热更新只需要满足下面两点就可以了:
动态加载补丁dex,并将补丁dex插入到dexElements最前面要实现热更新,需要热更新的类要防止被打上ISPREVERIFIED标记,关于这个标记,请阅读上面QQ空间团队的文章。对于第一点,实现很简单,通过DexClassLoader对象,将补丁dex对象加载进来,再通过反射将补丁dex插入到dexElements最前面即可。具体可参考谷歌的Multidex的实现。
而对于第二点,关键就是如何防止类被打上ISPREVERIFIED这个标记。
简单来说,就是将所有类的构造函数中,引用另一个hack.dex中的类,这个类叫Hack.class,然后在加载补丁patch.dex前动态加载这个hack.dex,但是有一个类的构造函数中不能引用Hack.class,这个类就是Application类的子类,一旦这个类的构造函数中加入Hack.class这个类,那么程序运行时就会找不到Hack.class这个类,因为还没有被加载。也就是说,一个类直接引用到的类不在同一个dex中即可。这样,就能防止类被打上ISPREVERIFIED标记并能进行热更新。
我们先来看Nuwa的实现,再去看Nuwa的插件的实现。
使用Nuwa的时候需要在attachBaseContext方法中初始化
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
Nuwa.init(this);
}
Nuwa预先将Hack.class这个类(空实现)打成apk文件,放在asserts目录中,在init方法中,做的就是将asserts目录中的这个文件拷贝到文件目录下。
public static void init(Context context) {
File dexDir = new File(context.getFilesDir(), DEX_DIR);
dexDir.mkdir();
String dexPath = null;
try {
dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir);
} catch (IOException e) {
Log.e(TAG, "copy " + HACK_DEX + " failed");
e.printStackTrace();
}
loadPatch(context, dexPath);
}
首先创建文件目录将asserts目录下的hack.apk拷到该目录,然后调用loadPatch方法将该apk动态加载进来。loadPatch方法也是之后进行热修复的关键方法,你的所有补丁文件都是通过这个方法动态加载进来。
public static void loadPatch(Context context, String dexPath) {
if (context == null) {
Log.e(TAG, "context is null");
return;
}
if (!new File(dexPath).exists()) {
Log.e(TAG, dexPath + " is null");
return;
}
File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR);
dexOptDir.mkdir();
try {
DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath());
} catch (Exception e) {
Log.e(TAG, "inject " + dexPath + " failed");
e.printStackTrace();
}
}
loadPatch方法中主要是调用DexUtils.injectDexAtFirst()方法将dex插入到dexElements最前面。该方法如下。
public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
Object newDexElements = getDexElements(getPathList(dexClassLoader));
Object allDexElements = combineArray(newDexElements, baseDexElements);
Object pathList = getPathList(getPathClassLoader());
ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
}
根据传入的dex的文件目录defaultDexOptPath,构造DexClassLoader对象dexClassLoader,然后通过getDexElements方法获得原来的dexElements对象,之后拿到dexClassLoader对象中的dexElements对象,调用combineArray方法将这两个对象进行结合,将我们传进来的dex插到该对象的最前面,之后调用ReflectionUtils.setField()方法,将dexElements进行替换。combineArray方法中做的就是扩展数组,将第二个数组插入到第一个数组的最前面
private static Object combineArray(Object firstArray, Object secondArray) {
Class localClass = firstArray.getClass().getComponentType();
int firstArrayLength = Array.getLength(firstArray);
int allLength = firstArrayLength + Array.getLength(secondArray);
Object result = Array.newInstance(localClass, allLength);
for (int k = 0; k < allLength; ++k) {
if (k < firstArrayLength) {
Array.set(result, k, Array.get(firstArray, k));
} else {
Array.set(result, k, Array.get(secondArray, k - firstArrayLength));
}
}
return result;
}
之后如果你有补丁要应用,直接调用Nuwa.loadPatch()方法,传入补丁的目录,重启应用之后就可以进行热更新了。这是Nuwa应用层的实现,可以看到,并不复杂。相对复杂的是Gradle插件层的实现。Gradle插件要做的事就是拿到所有class,在其构造函数中注入Hack.class,使其直接引用另一个dex中的文件,防止被打上ISPREVERIFIED标记。并且混淆的时候要应用上一次release版本的mapping文件。现在有两点关键内容:
如何拿到所有的class 如何在构造函数中注入代码我们先来解决第二点,如何注入代码,Nuwa使用的是asm注入代码。
现在假设我们已经存在了hack.apk,并且里面已经有了Hack.class文件,其源代码如下
package cn.edu.zafu.hotpatch.asm;
/**
* @author lizhangqu
* @since 2016-03-06 10:31
*/
public class Hack {
}
我们编写一个测试类Test,里面有一个测试方法,我们需要将Hack.class注入到Test的构造函数中,让其直接引用另一个dex中的类。
public class Test {
public void method1(){
String str="111";
}
}
我们编译一下,得到Test.clss,将其复制到一个目录dir。然后终端进入到该目录,使用javap命令查看字节码
可以看到图中有 < init >字样,该处就是构造函数,然后看到4:return,这是构造函数的结束的地方。现在我们读入该文件,并对其进行字节码修改,然后写入该目录下dest目录下。在这之前,需要加入asm的依赖,至于asm的使用,请自行查询。
compile 'org.ow2.asm:asm:5.0.4'
我们先将该文件读入,获得输入流,调用referHackWhenInit方法,将输入流传入,用ClassVisitor对象访问该对象,实现MethodVisitor方法,在该方法中访问对象中的方法,对方法名进行判断,如果是构造函数,则对其进行字节码注入操作,接下来运行main方法,查看dest目录下生成的文件。
public class Main {
public static void main(String[] args) throws IOException {
File srcFile = new File("/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/Test.class");
File destDir = new File("/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/dest/");
if (!destDir.exists()) {
destDir.mkdirs();
}
InputStream is = new FileInputStream(srcFile);
byte[] bytes = referHackWhenInit(is);
File destFile = new File(destDir, "Test.class");
FileOutputStream fos = new FileOutputStream(destFile);
fos.write(bytes);
fos.close();
}
private static byte[] referHackWhenInit(InputStream inputStream) throws IOException {
ClassReader cr = new ClassReader(inputStream);
ClassWriter cw = new ClassWriter(cr, 0);
ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
@Override
public MethodVis