当你准备开发一个热修复框架的时候,你需要了解的一切

最近的几篇文章都是关于hotpatch或者插件化的,原因是这两个星期公司里在搞相关的东西,当时得知有这样的开发意向的时候还是挺激动的,因为感觉不是在重复造轮子,而是在做一些创造价值的事情。所以一边忙论文和答辩的事,一边调研相关的代码,中间和小伙伴也讨论过很多次,到昨天为止也算是有了一个大概的框架。所以这里写这样一篇文章,一方面是对这段时间的总结,另一方面也算是给对这方面感兴趣的同学一个大纲吧。

前言

其实比较纠结要不要写这样一篇文章,因为其实最近已经出了好多篇hotpatch或者是插件化方面的文章了,都是大神之作,比如kymjs张涛的Android插件化系列文章和田维术的Android 插件化原理解析,相比之下我的文章可能在质量上是比不过他们的。不过还是决定写一写,因为在我写博客的过程发现[写文章]确实有利于总结,有些东西在写的过程中会有一个记忆的加深。大家如果觉得我这篇文章写得不好也可以移步去我提到的那两个专栏,那里面的文章真的非常好,强烈推荐。

class的热修复

首先,对于一个热修复框架,最基础的需求就是对class文件的修复了,这也是我们平时编写代码中最容易出bug的地方。下面让我们来看一看具体该怎么实施class的修复吧。

首先大家都知道,一个class都是通过ClassLoader去生成的,就算是Activity,Service这样有生命周期的类也不例外。而在Android中,有不止一个的ClassLoader,比如PathClassLoader和DexClassLoader,它们两者的区别其实就是前者只能加载本地的classes而后者可以加载任何apk或者jar压缩包中的dex文件。

PathClassLoader:

1
2
3
4
5
6
/**
* Provides a simple {@link ClassLoader} implementation that operates on a list
* of files and directories in the local file system, but does not attempt to
* load classes from the network. Android uses this class for its system class
* loader and for its application class loader(s).
*/

DexClassLoader:

1
2
3
4
5
6
/**
* A class loader that loads classes from {@code .jar} and {@code .apk} files
* containing a {@code classes.dex} entry. This can be used to execute code not
* installed as part of an application.
*
*/

而它们都是继承自BaseDexClassLoader的,所以它们的class加载逻辑都在它们的父类中,我们都知道Java中的class加载是双亲委托的,所以在我们的应用中关于类的加载一般都是通过BaseDexClassLoader去完成的。

1
2
3
4
5
6
7
8
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}

可以看到它的findClass方法的逻辑,通过DexPathList的findClass方法去寻找对应的class,如果找不到则丢出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private final Element[] dexElements;

.....

public Class findClass(String name) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext);
if (clazz != null) {
return clazz;
}
}
}
return null;
}

可以看到DexPathList通过去一个遍历一个Element数组中查找对应的DexFile。这个Element数组是在DexPathList的构造函数中初始化的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory)
{

if (definingContext == null) {
throw new NullPointerException("definingContext == null");
}
if (dexPath == null) {
throw new NullPointerException("dexPath == null");
}
if (optimizedDirectory != null) {
if (!optimizedDirectory.exists()) {
throw new IllegalArgumentException(
"optimizedDirectory doesn't exist: "
+ optimizedDirectory);
}
if (!(optimizedDirectory.canRead()
&& optimizedDirectory.canWrite())) {
throw new IllegalArgumentException(
"optimizedDirectory not readable/writable: "
+ optimizedDirectory);
}
}
this.definingContext = definingContext;
this.dexElements =
makeDexElements(splitDexPath(dexPath), optimizedDirectory);
this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
}

具体的逻辑就不看了,大家只要知道DexPathList通过传递进来的dexPath去生成一个含有DexFile的Element数组就可以了。

这里我们就可以找到一个合适的切入点去进行我们的class文件修复了。既然在Android中是通过这样一套机制去加载类的,那我们是不是可以考虑把patch文件中的element插入到系统的ClassLoader的Element数组之前呢?显然是可以的,这里有两种实现方式:

第一种是现在几个hotpatch框架的方法,前面说过,DexClassLoader是可以加载任何apk或者jar文件的,那我们可以通过自己new出一个DexClassLoader并且加载我们的patch.apk,这样我们就拿到了一个含有patch的BaseDexClassLoader,当然也会有对应的DexPathList,最后要做的就是把patch的DexPathList中的Element数组中的element插入到系统的Element数组之前,(好吧我承认这句话有点拗口。。)这样在系统去查找一个类的时候就会优先找到patch中的class文件了。

这里给出大概的代码:

1
2
3
4
5
6
7
8
private static void inject(String dexPath, String optimizedDirectory) throws IllegalAccessException, NoSuchFieldException, ClassNotFoundException {
DexClassLoader dexClassLoader = new DexClassLoader(dexPath, optimizedDirectory, dexPath, getPathClassLoader());
Object secondElements = getElementArrary(getPathList(getPathClassLoader()));
Object firstElements = getElementArrary(getPathList(dexClassLoader));
Object newElements = combineArray(firstDexElements, secondDexElements);
Object pathList = getPathList(getPathClassLoader());
ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", newElements);
}

对应的getElementArrary和getPathList就是一个通过反射去获得变量的方法,这里就不贴了。

第二种方式是multiDex的实现方案,其实是差不多的,只不过它并没有去new一个ClassLoader这么麻烦,而是直接操作了Element数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
File optimizedDirectory)

throws IllegalArgumentException, IllegalAccessException,
NoSuchFieldException, InvocationTargetException, NoSuchMethodException {

/* The patched class loader is expected to be a descendant of
* dalvik.system.BaseDexClassLoader. We modify its
* dalvik.system.DexPathList pathList field to append additional DEX
* file entries.
*/

Field pathListField = findField(loader, "pathList");
Object dexPathList = pathListField.get(loader);
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
}

可以看到这里直接拿到了DexPathList对应,并且调用了它的makeDexElements去生成一个新的Element数组,然后通过调用expandFieldArray方法将其和系统的Element数组合并。

这两种方式其实实现原理是一样的,不过一个通过ClassLoader去生成Element数组,一个则是直接操作变量。

当然仅仅这样是不够的,相信大家都知道通过这种方式去进行hotpatch会存在一个CLASS_ISPREVERIFIED的问题,这里需要做的就是动态注入字节码了,让patch的类不要被打上这个标记,而且可以说这才是class的patch中的重点——如何实现自动化的字节码注入,可以通过gradle的transform api,也可以自己去实现一个task通过dependsOn的方式插入到你想插入的task之间。

重要程度:✨✨✨✨✨

实现难度:✨✨✨

应用频率:✨✨✨✨✨

资源的热修复

这一节让我们来谈谈资源的热修复吧。这一点相对于class的修复可以算是没那么强的需求,不过要打造一个完美的热修复框架,这一点还是必不可少的。如果大家有兴趣可以先去看我写的上一篇文章[记一次苦逼的资源逆向分析]。在那篇文章中我讲道,当时发现了Instant Run是可以实现资源的修复的,但是它的做法是全量的,也就是说将所有的资源,不管是否修改过全部打进arsc文件中,这样做对于我们的hotpatch框架来说显然是不可以接受的,这意味着我们的patch包也要想对应的包含所有的资源,这其实已经违反了hotpatch的初衷,所以这一块的难点就在于[如何通过使用或改造appt去实现增量的arsc文件]。在那篇文章的结尾我提供了几种思路,这里我们来回顾一下:

(1) 改造aapt,在它收集所有的资源并且生成了R文件之后,我们通过传入一份文件(里面包含改变过的资源索引)来动态的[踢掉]ReasourceTable中没有改变过的资源,这样最终生成的arsc文件就是增量的。

(2) 在aapt中存在–split这样一个命令。我们可以通过改造这个命令去实现需求。

(3) 我们都知道Android的系统资源是不会被加到arsc文件中的,具体的aapt命令为-I,那我们是不是也可以通过再造aapt,去把我们没有修改过的资源也像系统资源一样不添加到arsc中呢?

(4) 我们甚至可以绕开aapt,不用它转而自己去生成arsc文件,主要的逻辑都是ResourceTable.cpp的flatten方法中,可以照葫芦画瓢嘛。

我当时已经算是完成了一点方案(1)的实现,不过在后来和小伙伴的沟通中发现他已经通过方案(3)把整个过程都走通了,于是我也就不再去深入研究方案(1)了。

这里我想说的是,这四种方案,在完全没有头绪的情况下,(4)是肯定可以实现的,因为它不存在什么系统验证的机制,完全通过手动方式去生成arsc文件。但是这也是最难的一种,你必须要对arsc的文件结构有非常深入的了解才可能去完成这样的实现。而方案(2)(3)和(1)相比是相对比较省力的,因为它的侵入性较小,只需要改一小部分的代码,其余都是通过系统帮你去完成的,而(1)的话你要在系统资源打包的过程中强行插入一个步骤,这样遗留下来的坑不知道会有多大。

总体来说,如果你想对arsc文件有深入的理解,你可以选择方案(4)。如果你想省事省力并且做到尽可能的低侵入性,你可以选择(2)和(3)。而你如果不怕麻烦,并且想深入理解Android的资源打包过程的话,你可以选择(1)。

重要程度:✨✨✨✨

实现难度:方案(1)✨✨✨✨ 方案(2)(3)✨✨✨ 方案(4)✨✨✨✨✨

应用频率:✨✨✨

so文件的热修复

解决了class文件的修复和资源的修复,接下去就是so文件了。回想一下,我们平时是如何加载so文件的?

1
System.loadLibrary("myLib");

通过System.loadLibrary这样一个方法。

1
2
3
public static void loadLibrary(String libName) {
Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader());
}

可以看到最终调用了Runtime的同名函数,并且传进去了一个ClassLoader。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public void loadLibrary(String nickname) {
loadLibrary(nickname, VMStack.getCallingClassLoader());
}

void loadLibrary(String libraryName, ClassLoader loader) {
if (loader != null) {
String filename = loader.findLibrary(libraryName);
if (filename == null) {
// It's not necessarily true that the ClassLoader used
// System.mapLibraryName, but the default setup does, and it's
// misleading to say we didn't find "libMyLibrary.so" when we
// actually searched for "liblibMyLibrary.so.so".
throw new UnsatisfiedLinkError(loader + " couldn't find \"" +
System.mapLibraryName(libraryName) + "\"");
}
String error = doLoad(filename, loader);
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
return;
}

String filename = System.mapLibraryName(libraryName);
List<String> candidates = new ArrayList<String>();
String lastError = null;
for (String directory : mLibPaths) {
String candidate = directory + filename;
candidates.add(candidate);

if (IoUtils.canOpenReadOnly(candidate)) {
String error = doLoad(candidate, loader);
if (error == null) {
return; // We successfully loaded the library. Job done.
}
lastError = error;
}
}

if (lastError != null) {
throw new UnsatisfiedLinkError(lastError);
}
throw new UnsatisfiedLinkError("Library " + libraryName + " not found; tried " + candidates);
}

这里我们只需要关注loader != null的情况,在这种情况下它会调用loader的findLibrary函数,找到对应的文件路径通过doLoad函数去load对应的so文件。而这里的loader很显然就是BaseDexClassLoader了。

1
2
3
4
@Override
public String findLibrary(String name) {
return pathList.findLibrary(name);
}

和前面所提到的findClass非常相似,也是通过DexPathList的同名函数去寻找的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private final File[] nativeLibraryDirectories;

.........

public String findLibrary(String libraryName) {
String fileName = System.mapLibraryName(libraryName);
for (File directory : nativeLibraryDirectories) {
File file = new File(directory, fileName);
if (file.exists() && file.isFile() && file.canRead()) {
return file.getPath();
}
}
return null;
}

通过遍历nativeLibraryDirectories数组中的文件去获得对应的路径。而这个nativeLibraryDirectories数组和前面所提到的Element数组一样,也是在DexPathList初始化的时候去填充的。所以这里我们的思路和class的patch是一样的,扩展nativeLibraryDirectories数组并且将patch中的插到最前面。但是还是有不一样的地方,下面让我们看看具体哪里不一样。

首先,由于DexPathList源码中的逻辑原因,如果你采用新建DexClassLoader的方案,这里不能像class一样传对应的文件路径,而是要传文件夹的路径。但是如果你是像前面class的方案2一样直接操作变量的,则没有这个需求,直接传so文件路径就行。

其次,so文件和class文件不一样,它存在一个cpu结构的问题,在我们的应用对应的jniLibs目录下,会有arm文件夹,x86文件夹等等,所以这里我们也不得不去考虑这个问题,幸运的是,我们可以通过以下方式去获得对应手机的cpu结构:

1
2
3
4
5
Build.SUPPORTED_32_BIT_ABIS;
Build.SUPPORTED_64_BIT_ABIS;
Build.SUPPORTED_ABIS;
Build.CPU_ABI;
Build.CPU_ABI2;

前面三个是api21新增的,后面两个是老的,现在已经被废弃,不过还是可以使用的,具体的cpu结构大概可以参考这篇文章

我们要做的是就是在patch加载的时候动态的判断手机的cpu结构,并且将对应目录下的so文件目录添加到nativeLibraryDirectories数组中。

重要程度:✨✨✨

实现难度:✨✨✨

应用频率:✨

Activity,Service等的动态注册

这一节是关于Activity,Service这样的四大组件的动态注册了,其实这个不应该属于热修复的范畴,更像是插件化要做的东西,具体也就是hook Instramentation或者hook AMS以及H,大家可以参考我前面写的那一篇[让我们来聊一聊插件化吧]这篇文章,或者是第一节[前言]中提到的那两个专栏。

重要程度:✨

实现难度:✨✨✨✨

应用频率:✨

如何实现patch的增量编译

这是这篇文章的最后一个小节,也是我个人认为热修复框架中比较核心的一点,怎么样才能实现patch包的增量编译。回想一下,在上面的几个小节都会有这样的需求,如何找出改变过的class文件,如何找出改变过的资源,如何找出改变过的so文件。这样的操作让程序员手动去做肯定是不现实,没有人愿意做这样的工作,而且也容易出错。那怎么办呢?这里我们可以利用gradle去帮我们做自动化。

在class的patch这一节的最后我们讲到过如何动态插入字节码,利用transform api或者自定义task,这里我们同样可以用这样的方式,其实就是[先找出改变过的东西,class,资源和so文件],然后[对改变过的class动态插入字节码并打到patch中]。

对于第一种方式,我们需要定义一个承自Transform的子类,并且重写它的几个方法,其中最主要的就是transform这个方法了,你可以在其中做操作。然后自定义一个类继承自Plugin,重写其apply方法,在其中注册对应的Transform子类,最后在对应的build.gradle中使用这个插件就可以了。

而对于第二种方式呢,不需要定义Transform的子类,只需要定义一个Plugin类,所有的逻辑在apply中重写就可以。这种方式之于使用Transform的好处是比较的灵活,想插在哪两个task之间就插在哪两个task之间,而Transform是由TtransformManager管理的,应用的时机是确定的。不过对于两种方式孰优孰劣,之前jasonross,也就是nuwa的作者也提醒过我说[混淆等task的顺序官方之前说后面考虑可以更改的],所以使用哪一种方式大家自己斟酌吧。

说完了实现的方式,那具体逻辑是什么呢?这里我就说个大概吧,代码就不上了。其实也很简单,就是生成两份所有,class,资源和so文件的hash值的文件,对比一下,如果哪个文件的hash值变了就把它加入patch中就可以了。

最后,如果你想把你的hotpatch框架应用到实际项目中,还需要考虑的东西有很多,比如混淆,比如multiDex操作等等。这里还是有实现方案的,groovy提供了闭包的概念,大家可以通过在一个给定task前后添加闭包的方式,也就是doFirst和doLast操作去面向切面的编程,也就可以去hook对应的task了(比如你可以在混淆的task之后添加一个闭包,拿到对应的mapping文件,这样就有了混淆前后class的对应关系了)。置于具体怎么做就交给大家自己去实现了~具体大家想要深入了解gradle插件开发的话可以看这两篇文章:看这里~ 看那里~

重要程度:✨✨✨✨✨

实现难度:✨✨✨✨✨(这里给这么高的难度的主要原因是个人对groovy并不怎么精通,甚至说才刚入门,写起来确实比较困难)

应用频率:✨✨✨✨✨