android热修复实践

什么是热修复

  我们知道按照现有的模式,一旦我们上线的app版本有了bug,我们不得不先解决bug,然后测试无误后,重新打包,然后再发布到应用市场上去,这一系列的操作,不仅浪费时间,而且也影响用户的体验,若能像web端一样,更改了代码就能立马生效,那般轻松也正是开发者有朝一日所希望的。现如今Android插件化热更新技术非常火热,也开源了很多的项目如Dexposed,AndFix,ClassLoader,nuwa等等。说来说去,热修复的作用就是可以动态的修复你的bug,一旦有bug要修复,就可以通过事先的接口从网上下载无bug的代码来替换有bug的代码,这样就省事多了,用户体验也好。

 

热修复的原理

  Android的类加载机制分为两种,PathClassLoader(用来加载系统类和应用类)和DexClassLoader(用来加载jar,apk,dex文件,加载jar,apk也是最终抽取里面的Dex文件进行加载),并且他们都继承BaseDexClassLoader。
1.看下pathClassLoader代码

1
2
3
4
5
6
7
8
9
10
11
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String libraryPath,
ClassLoader parent) {
super(dexPath, null, libraryPath, parent);
}
}

2.DexClassLoader代码

1
2
3
4
5
6
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
}

  两个ClassLoader就两三行代码,只是调用了父类的构造函数

3.BaseDexClassLoader代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
@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;
}

  在BaseDexClassLoader构造函数中创建一个DexPathList类的实例,这个DexPathList的构造函数会创建一个dexElements数组

1
2
3
4
5
6
7
8
public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) {
...
this.definingContext = definingContext;
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
//创建一个数组
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);
...
}

  然后BaseDexClassLoader重写了findClass方法,调用了pathList.findClass,跳到DexPathList类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* package */final class DexPathList {
...
public Class findClass(String name, List<Throwable> suppressed) {
//遍历该数组
for (Element element : dexElements) {
//初始化DexFile
DexFile dex = element.dexFile;
if (dex != null) {
//调用DexFile类的loadClassBinaryName方法返回Class实例
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
return null;
}
...
}

  会遍历这个数组,然后初始化DexFile,如果DexFile不为空那么久调用DexFile类的loadClassBinaryName方法返回Class实例,总结就是ClassLoader会遍历这个数组,然后加载这个数组中的dex文件,而ClassLoader在加载了正确的类后,就不会去加载有bug的那个类了,我们把这个正确的类放在Dex文件中,让这个Dex文件排在dexElements数组前面即可。

AndFix实践

  最近我刚在公司的一个项目中添加了热修复,使用的是阿里的AndFix.
  AndFix支持Android2.3到7.0版本,并且支持arm和x86系统架构的设备,完美支持Dalvik和ART的Runtime。

AndFix原理

AndFix原理
  他的原理如图所示,就是方法替换,把有bug的方法替换成补丁文件中的方法。

方法替换过程:andFix方法替换过程

AndFix使用

1.首先我们要添加AndFix依赖

1
2
3
dependencies {
compile 'com.alipay.euler:andfix:0.5.0@aar'
}

2.在Application中初始化PatchMannger

1
2
3
4
5
6
7
8
9
/**
* init 热修复
*/
private void initPatchManager() {
mPatchManager = new PatchManager(this);
//注意每次appversion变更都会导致所有补丁被删除,如果appversion没有改变,则会加载已经保存的所有补丁。
mPatchManager.init(appversion);//current version
mPatchManager.loadPatch();
}

appVersion获取方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 获取版本号
*
* @return 当前应用的版本号
*/
public static String getVersion(Context context) {
try {
PackageManager manager = context.getPackageManager();
PackageInfo info = manager.getPackageInfo(context.getPackageName(), 0);
return info.versionName;
} catch (Exception e) {
e.printStackTrace();
return context.getString(R.string.can_not_find_version_name);
}
}

3.打包一个修复了bug的同版本号的apk

4.使用官方提供的工具apkpatch生成.apatch补丁文件
点击上面的链接下载apkpatch之后解压

5.将两个apk文件和该app的签名文件放入到该目录中

6.通过命令行进入到该目录下,然后使用命令行
apkpatch.bat -f 新apk -t 旧apk -o 输出目录 -k app签名文件 -p 签名文件密码 -a 签名文件别名 -e 别名密码

7.在输出目录下就有一个.aptch文件,这个文件就apatch补丁文件,改名为fix.apatch(随意)

8.在公司的项目中,我使用的是通过推送将该补丁文件推送到用户本地去,然后通过自动下载来更新bug。我们使用的推送是集成个推,个推推送的透传消息,就可以传json键值对,以下就是我的PushBean实体类中的字段。

1
2
3
4
5
6
private String type; //类型
private String extra; //额外参数
private String content; //title内容
private String app_v; //应用的版本
private String path_v; //补丁的版本
private String url; //补丁的下载地址

9.当收到透传消息,判断type类型,如果type等于apatch,则说明需要热更新修复了

1
2
3
4
5
6
7
8
if (pushBean != null && "apatch".equals(pushBean.getType())) { //热修复
//判断当前版本是否有补丁需要下载更新
try {
RepairBugUtil.getInstance().comparePath(this, pushBean);
} catch (Exception e) {
e.printStackTrace();
}
}

10.判断当前的版本是否有补丁需要下载更新,如果服务器端的应用版本和本地的应用版本一样,但是补丁版本(版本自己定,不一样即可)不一样,则需要下载更新

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
/**
* 比较本地版本和补丁版本
*
* @param context
* @param RemoteBean
* @throws Exception
*/
public void comparePath(Context context, PushBean RemoteBean) throws Exception {
String pathInfo = (String) SharedPreferencesUtils.getParam(context, AppConstants.PATH_INFO, "");
PushBean localBean = new Gson().fromJson(pathInfo, PushBean.class);
//远程的应用版本跟当前应用的版本比较
if (ValidateUtil.getVersion(context).equals(RemoteBean.getApp_v())) {
//远程的应用版本跟本地保存的应用版本一样,但补丁不一样,则需要下载重新
/**
*第一种情况:当本地记录的Bean为空的时候(刚安装的时候可能为空)并且远程的Bean的path_v不为空的时候需要下载补丁。
* 第二种情况:当本地记录的path_v和远程Bean的path_v不一样的时候需要下载补丁。
*/
if (localBean == null && !TextUtils.isEmpty(RemoteBean.getPath_v())
|| localBean.getApp_v().equals(RemoteBean.getApp_v()) &&
!localBean.getPath_v().equals(RemoteBean.getPath_v())) {
if (!new File(AppConstants.APATCH_DIR).exists()) {
new File(AppConstants.APATCH_DIR).mkdirs();// 创建存储目录
}
//下载补丁文件
downloadAndLoad(RemoteBean, AppConstants.URL_PREFIX + RemoteBean.getUrl());
String json = new Gson().toJson(RemoteBean);
//将pushbean对象转成json格式保存,以便下次使用
SharedPreferencesUtils.setParam(context, AppConstants.PATH_INFO, json);
}
}
}

11.下载补丁文件,在项目中使用的是Nohttp,该网络请求框架非常的方便,并且都实现了对进度的监听,所以如果你的项目中需要下载或上传很多文件,推荐使用Nohttp。

1
2
3
4
5
6
7
public void downloadAndLoad(PushBean bean, String downloadUrl) {
//创建下载队列
DownloadQueue downloadQueue = NoHttp.newDownloadQueue();
DownloadRequest downloadRequest = NoHttp.createDownloadRequest(downloadUrl, AppConstants.APATCH_DIR, bean.getUrl(), true, false);
//将下载任务添加进队列中
downloadQueue.add(0, downloadRequest, downloadListener);
}

12.创建DownLoadListener监听,在文件下载完后,就可以
调用全局的PatchManager对象通过addPatch方法来加载该文件

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
private DownloadListener downloadListener = new DownloadListener() {
@Override
public void onDownloadError(int what, Exception exception) {
}
@Override
public void onStart(int what, boolean resume, long preLenght, Headers header, long count) {
// 下载开始
}
@Override
public void onProgress(int what, int progress, long downCount, long speed) {
// 更新下载进度和下载网速
Log.e(TAG, progress + "");
}
@Override
public void onFinish(int what, String filePath) {
// 下载完成
try {
// .apatch file path
App.mPatchManager.addPatch(filePath);
Log.d(TAG, "apatch:" + filePath + " added.");
//复制且加载补丁成功后,删除下载的补丁
File f = new File(filePath);
if (f.exists()) {
boolean result = new File(filePath).delete();
if (!result)
Log.e(TAG, filePath + " delete fail");
}
} catch (IOException e) {
Log.e(TAG, "", e);
} catch (Throwable throwable) {
}
}
@Override
public void onCancel(int what) {
// 下载被取消或者暂停
}
};

13.到此,补丁文件被加载后,bug就修复了,就是这么简单,最后附上RepairBugUtil工具类

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
public class RepairBugUtil {
private static final String TAG = "AndFix";
private static class SingletonHolder {
public static final RepairBugUtil INSTANCE = new RepairBugUtil();
}
public static RepairBugUtil getInstance() {
return SingletonHolder.INSTANCE;
}
public void downloadAndLoad(PushBean bean, String downloadUrl) {
DownloadQueue downloadQueue = NoHttp.newDownloadQueue();
DownloadRequest downloadRequest = NoHttp.createDownloadRequest(downloadUrl, AppConstants.APATCH_DIR, bean.getUrl(), true, false);
downloadQueue.add(0, downloadRequest, downloadListener);
}
/**
* 比较本地版本和补丁版本
*
* @param context
* @param RemoteBean
* @throws Exception
*/
public void comparePath(Context context, PushBean RemoteBean) throws Exception {
String pathInfo = (String) SharedPreferencesUtils.getParam(context, AppConstants.PATH_INFO, "");
PushBean localBean = new Gson().fromJson(pathInfo, PushBean.class);
//远程的应用版本跟当前应用的版本比较
if (ValidateUtil.getVersion(context).equals(RemoteBean.getApp_v())) {
//远程的应用版本跟本地保存的应用版本一样,但补丁不一样,则需要下载重新
/**
*第一种情况:当本地记录的Bean为空的时候(刚安装的时候可能为空)并且远程的Bean的path_v不为空的时候需要下载补丁。
* 第二种情况:当本地记录的path_v和远程Bean的path_v不一样的时候需要下载补丁。
*/
if (localBean == null && !TextUtils.isEmpty(RemoteBean.getPath_v())
|| localBean.getApp_v().equals(RemoteBean.getApp_v()) &&
!localBean.getPath_v().equals(RemoteBean.getPath_v())) {
if (!new File(AppConstants.APATCH_DIR).exists()) {
new File(AppConstants.APATCH_DIR).mkdirs();// 创建存储目录
}
downloadAndLoad(RemoteBean, AppConstants.URL_PREFIX + RemoteBean.getUrl());
String json = new Gson().toJson(RemoteBean);
SharedPreferencesUtils.setParam(context, AppConstants.PATH_INFO, json);
}
}
}
private DownloadListener downloadListener = new DownloadListener() {
@Override
public void onDownloadError(int what, Exception exception) {
}
@Override
public void onStart(int what, boolean resume, long preLenght, Headers header, long count) {
// 下载开始
}
@Override
public void onProgress(int what, int progress, long downCount, long speed) {
// 更新下载进度和下载网速
Log.e(TAG, progress + "");
}
@Override
public void onFinish(int what, String filePath) {
// 下载完成
try {
// .apatch file path
App.mPatchManager.addPatch(filePath);
Log.d(TAG, "apatch:" + filePath + " added.");
//复制且加载补丁成功后,删除下载的补丁
File f = new File(filePath);
if (f.exists()) {
boolean result = new File(filePath).delete();
if (!result)
Log.e(TAG, filePath + " delete fail");
}
} catch (IOException e) {
Log.e(TAG, "", e);
} catch (Throwable throwable) {
}
}
@Override
public void onCancel(int what) {
// 下载被取消或者暂停
}
};
}

AndFix优缺点

  最后由于AndFix采用native hook的方式,这套方案直接使用dalvik_replaceMethod替换class中方法的实现。由于它并没有整体替换class, 而field在class中的相对地址在class加载时已确定,所以AndFix无法支持新增或者删除filed的情况(通过替换init与clinit只可以修改field的数值)。

AndFix缺点
  也正因如此,Andfix可以支持的补丁场景相对有限,仅仅可以使用它来修复特定问题。结合之前的发布流程,我们更希望补丁对开发者是不感知的,即他不需要清楚这个修改是对补丁版本还是正式发布版本(事实上我们也是使用git分支管理+cherry-pick方式)。另一方面,使用native替换将会面临比较复杂的兼容性问题。
AndFixCompare
AndFix

  相比其他方案,AndFix的最大优点在于立即生效。事实上,AndFix的实现与Instant Run的热插拔有点类似,但是由于使用场景的限制,微信在最初期已排除使用这一方案。

感谢

AndFix

NoHttp