HotFix

背景

Android的项目结构越来越大,相对应出现Bug的情况也越来越多,传统机制下每当出现一个紧急Bug都必须重新打包、测试、发布,用户需要下载、覆盖安装,成本很高,所以很多团队都在寻找方法,通过补丁的形式修复Bug,不用重新发布再让用户下载安装。

我们的Android客户端目前的问题是一些比较重要的逻辑(比如支付),在出现Bug的时候需要及时修复,等不及再发一个包到市场上等着用户手动安装更新,所以需要引入一套HotFix的机制。

原理

现在流行的HotFix方案的原理基本是一致的,主要就是依靠Android的ClassLoader机制。Android中加载类一般用两种:

PathClassLoader

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.

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,再看这个父类的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public class BaseDexClassLoader extends ClassLoader {

private final String originalPath;

private final DexPathList pathList;

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent)
{

super(parent);
this.originalPath = dexPath;
this.pathList =
new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
}

再看DexPathListfindClass方法:

1
2
3
4
5
6
7
8
9
10
11
12
13

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;
}

再看DexFile的相关源码:

1
2
3
4
5

public Class loadClassBinaryName(String name, ClassLoader loader) {
return defineClass(name, loader, mCookie);
}
private native static Class defineClass(String name, ClassLoader loader, int cookie);

可以看到BaseDexClassLoader类里有一个pathList对象,这个对象其实就是一个包含多个DexFile的有序集合,加载类的时候其实就是按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。所谓的HotFix其实就是在这个过程中,把修改后的类文件打包一个dex文件,插入到pathList的第一个位置,在这个过程中如果出现了重复的类,最终会使用第一个找到的类,所以这时候目标类就已经被替换掉了。

存在的问题

CLASS_ISPREVERIFIED

如果不加处理使用以上的方法,那么在加载补丁的时候首先会出现这个异常:

1
java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation

因为在DexPrepare.cpp将dex转化成odex的过程中,会在DexVerify.cpp进行校验,验证如果static方法、private方法、构造函数等,直接引用到的类(第一层级关系,不会进行递归搜索)和当前类是否在同一个dex,如果是,则会打上CLASS_ISPREVERIFIED标志。所以要想办法阻止相关类被打上CLASS_ISPREVERIFIED标志。
最直接的方法就是改变代码结构,使要热修复的类引用其他dex中的类,当然这样灵活性就降低了。因为bug无处不在,出现bug的时候我们是不知道要修复哪些类的。
另外就是用工具javassist或者ASM进行动态代码注入。

Android 6.0以上的权限

因为在6.0以后一些敏感权限是需要动态申请的,热修复因为都需要下载或者生成一个patch补丁包放在本地,所以是需要在这时候申请读写SD卡这一组权限的。

ProGuard

在给目标类进行动态代码注入的时候,是通过反射根据包名和类名获取Class的,如果这时候代码已经被混淆的话,包名和类名都发生了变化,这个获取过程出错的几率比较大。

HotFix

我使用的框架是GitHub上dodola的HotFix这个项目,同类的项目有Nuwa,在Android端进行热修复的实现思路都是类似的。这一部分看项目源码就可以。

如何解决CLASS_ISPREVERIFIED的问题?
文档里说的比较清楚了:

解决的方法就是在类中引用一个其他dex中的类,但是源码方式的引用会将引用的类打入同一个dex中,所以我们需要找到一种既能编译通过并且将两个互相引用的类分离到不同的dex中,于是就有了这个动态的代码植入方式。

不同解决方案的比较

HotFix

  • 代码虽然写的比较死,但是容易理解,可定制性强
  • 在打包的时候就需要给目标类动态注入代码,相当于打一个TAG,避免出现CLASS_ISPREVERIFIED的问题。如果给项目所有的类都这么做的话工作量比较大。而且补丁也需要自己制作。

Nuwa

  • 自动化做的比较好,制作补丁有专门的gradle插件。
  • 坏消息是作者似乎不准备再维护这个项目了,所以gradle版本升级之后它的gradle插件就不能用了。GitHub上一些人提了issue作者也不准备解决了,坑比较多。

DroidFix

  • 原理和HotFix一致,包括对CLASS_ISPREVERIFIED的处理方法。
  • 对接口的热修复支持不好。
  • 这个项目的源码我没仔细看,有待研究。

RocooFix

  • HotFix的作者的另一个项目,支持重启热修复和实时热修复。实时热修复用的是另一个开源项目Legend。我们暂且只用重启热修复这部分代码。
  • 按照项目描述的是无需关注混淆问题,无需手动制作补丁,而且作者在及时填坑。

补丁版本管理

阿里的AndFix现在给出的方案是每个补丁对应的文件中有个Create-Time的字段,根据这个字段按照顺序打补丁。

安全性

目前的方案,补丁是下载下来放在本地一个目录下的,用户是可以手动替换掉补丁的。可以通过对补丁包的MD5进行验证避免补丁被恶意替换。

总结

  • 我们目前的项目实现热修复的话,用今天讲的这种原理理论上就可以了。
  • 因为现在项目代码不混淆,所以不需要考虑这方面的问题。但是项目现在使用第三方加固的话,尚不清楚加固的原理,根据一些用过热修复的人说的,一些第三方加固会将Android原有的PathClassLoader替换为他们自己的loader,这样的话就需要做单独处理了。
  • CLASS_ISPREVERIFIED用目前通用的动态注入字节码的方式就可以处理。
  • 本地制作补丁包实现自动化写一个脚本或者gradle插件。
  • 多个补丁的版本管理和安全问题也需要后端的配合。
  • 紧急情况可以使用这个方案,一次加载多个dex补丁必然导致性能的问题。
  • 其他的问题只能是在项目里真正使用之后再寻求解决方案了。

参考

延伸