一、前言
市面上目前app的热修复技术众多,比较主流的有:
1)腾讯系:微信的Thinker、QQ空间的超级补丁、手Q的QFix
2)阿里系:AndFix、阿里百川HotFix、Sophix
3)美团:Robust
4)饿了么:Amigo
5)美丽说蘑菇街:Aceso
二、主流热修复方案对比
1、阿里系
名称 | 说明 |
---|---|
AndFix | 开源,实时生效,最新更新是3年前 |
HotFix | 阿里百川,未开源,免费、实时生效 |
Sophix | 未开源,商业收费,实时生效/冷启动修复 |
HotFix
是AndFix
的优化版本,Sophix
是HotFix
的优化版本。目前阿里系主推是Sophix
。
2、腾讯系
名称 | 说明 |
---|---|
Qzone超级补丁 | QQ空间,未开源,冷启动修复 |
QFix | 手Q团队,未开源,冷启动修复 |
Tinker | 微信团队,开源,冷启动修复。提供分发管理,基础版免费 ,持续更新 |
3、其他
名称 | 说明 |
---|---|
Robust | 美团, 开源,实时修复,支持到android 9.0,持续更新 |
Nuwa | 大众点评,开源,冷启动修复,最新更新是4年前 |
Amigo | 饿了么,开源,冷启动修复,仅支持到android 7.1,最新更新是2年前 |
4、方案对比
方案对比 | Sophix | Tinker | nuwa | AndFix | Robust | Amigo |
---|---|---|---|---|---|---|
类替换 | yes | yes | yes | no | no | yes |
So替换 | yes | yes | no | no | no | yes |
资源替换 | yes | yes | yes | no | no | yes |
全平台支持 | yes | yes | yes | no | yes | yes |
即时生效 | 同时支持 | no | no | yes | yes | no |
性能损耗 | 较少 | 较小 | 较大 | 较小 | 较小 | 较小 |
补丁包大小 | 小 | 较小 | 较大 | 一般 | 一般 | 较大 |
开发透明 | yes | yes | yes | no | no | yes |
复杂度 | 傻瓜式接入 | 复杂 | 较低 | 复杂 | 复杂 | 较低 |
Rom体积 | 较小 | Dalvik较大 | 较小 | 较小 | 较小 | 大 |
成功率 | 高 | 较高 | 较高 | 一般 | 最高 | 较高 |
热度 | 高 | 高 | 低 | 低 | 高 | 低 |
开源 | no | yes | yes | yes | yes | yes |
收费 | 收费(设有免费阈值) | 收费(基础版免费,但有限制) | 免费 | 免费 | 免费 | 免费 |
监控 | 提供分发控制及监控 | 提供分发控制及监控 | no | no | no | no |
三、热修复技术方案
总体而言,热修复技术方案主要分为3类:
1)类加载方案(参考android multidex的思想):腾讯系
2)底层替换方案(参考xposed框架的思想):阿里系
3)Instant Run方案(参考Android Studio热部署思想):美团
具体分析可以阅读以下技术博客:
1、Android热修复技术原理详解(最新最全版本)
2、Android热修复原理(一)热修复框架对比和代码修复
3、Android热更新方案Robust–Instant Run代码插桩方案
4、android热修复相关之Multidex解析
5、热修复——深入浅出原理与实现–类加载方案原理
6、Android 冷启动热修复技术杂谈-QQ热修复原理
7、Android 热修复原理-类加载方案原理
8、Android热修复原理-各热修复框架原理
四、sdk热修复技术方案选型
1、首先直接pass掉native hook底层替换方案,这个方案对android版本与机型的适配兼容工作量太大,不适合sdk的开发
2、Instant Run代码预插桩方案,这个方案需要在每个方法前插入判断跳转逻辑的代码,对代码的侵入太大,而且代码进行混淆的话,出补丁比较麻烦
3、最终选用了类加载方案,可以实现类级别与方法级别的修复,同时兼容性也是比较高的方案,毕竟是参考android官方multidex的实现思想,不过网上能够搜索到的类加载方案普遍都会有问题
1)问题点一:
即使加载到补丁的dex插入到dexpathList数组第一位,但是代码依然还是走的是旧的代码逻辑
2)问题点二:
在android P以上的机子,会出现以下报错:
06-20 19:07:24.597 30376 30376 F m.taobao.taoba:entrypoint_utils-inl.h:94]
Inlined method resolution crossed dex file boundary:
from void com.ali.mobisecenhance.Init.doInstallCodeCoverage
(android.app.Application, android.content.Context) in/data/app/com.taobao.taobao-YPDeV7WbuyZckOfy-5AuKw==/base.apk!classes3.dex/0xece238f0to void com.ali.mobisecenhance.code.CodeCoverageEntry.CoverageInit
(android.app.Application, android.content.Context) in/data/user/0/com.taobao.taobao/files/storage/com.taobao.maindex
/dexpatch/1111/com_taobao_maindex.zip!classes4.dex/0xebda4320.
This must be due to duplicate classes or playing wrongly with class loaders
上述就是在Android P上被内联的方法不能在不同的dex(classN.dex为同一个dex)导致的闪退,内联相关知识:ART下的方法内联策略及其对Android热修复方案的影响分析
3)问题点三:
在dalvik虚拟机的手机(android 4.4之前的机子)会出现UNEXPECT_DEX_EXCEPTION
4、原因分析
1)问题点一在于app首次运行时,会优化生成对应的运行代码缓存,所以之后再加入新的补丁也不会进行调用
2)问题点二在于android P会优化调用逻辑,对同一个dex的方法调用进行内联处理,要是执行热修复之后,假如检测被内联的方法不是在同一个dex就会抛出异常
3)问题点三:这个主要是是在dalvik虚拟机有的问题,在android 5.0+的机子正常CLASS_PRIVEREIED问题
5、解决方案
1)对后续需要进行热修复的那部分代码,先生成一个dex文件放到assets目录下
2)在app首次打开时候,就预先加载放在assets这个dex,插入到dexpathList数组第一位,这样系统就会记得这部分代码是需要依赖外部dex,不会对这部分代码进行优化缓存,就可以避免后续下发补丁的时候不起作用
3)同时,因为首次启动需要热修复的那部分代码跟其他代码也不是在同一个dex,故也不会被系统自动内联,这样解决了问题点二
4)只要首次加载一次即可,后续可不再加载那个生成的dex,有新的补丁时再加载即可,不过测试发现首次加载只会耗时100+ms,第二次后续也就10ms以内,不会太影响启动速度
6、关键代码片段
public class Fettler {
private static final String TAG = "min77";
private HashSet<File> fixDexSet;
private Context context;
private FixListener listener;
public static boolean DEBUG = false;
private Fettler(Context context) {
fixDexSet = new HashSet<>();
this.context = context;
}
/**
* 构造Fettler对象,初始化成员变量
*/
public static Fettler with(Context context) {
return new Fettler(context);
}
/**
* 初始化,在application的attachBaseContext()调用
*/
public static void init(Context context) {
with(context).start();
}
/**
* 清理磁盘缓存的dex文件(慎用,会导致需要修复的dex文件失效)
*/
public static void clear(Context context) {
with(context).clear();
}
/**
* 添加补丁包
*/
public Fettler add(String dexPath) {
File dexFile = new File(dexPath);
File targetFile = new File(context.getDir(Constants.TEMP_FOLDER, Context.MODE_PRIVATE) + File.separator + dexFile.getName());
if (targetFile.exists()) targetFile.delete();
FileUtils.copy(dexFile, targetFile);
Log.i(TAG, "===== 成功添加 " + targetFile.getName() + " =====");
return this;
}
/**
* 添加补丁包
*/
public Fettler add(File dexFile) {
File targetFile = new File(context.getDir(Constants.TEMP_FOLDER, Context.MODE_PRIVATE) + File.separator + dexFile.getName());
if (targetFile.exists()) targetFile.delete();
FileUtils.copy(dexFile, targetFile);
Log.i(TAG, "===== 成功添加 " + targetFile.getName() + " =====");
return this;
}
/**
* 添加监听
*/
public Fettler listen(FixListener listener) {
this.listener = listener;
return this;
}
/**
* 热修复
*/
public void start() {
Log.i(TAG, "===== 开始修复 =====");
fixDexSet.clear(); //清理集合
File externalFilesDir = context.getExternalFilesDir(null);
File dexDir = new File(externalFilesDir, "sswl");
if (!dexDir.exists()) {
dexDir.mkdir();
}
Log.i("min77", "dexDir = " + dexDir.getAbsolutePath());
if (dexDir != null && dexDir.listFiles() != null && dexDir.listFiles().length > 0) {//sswl有文件,不管是否是.dex文件都不会走else
Log.i("min77", "dexDir != null && dexDir.listFiles() != null");
//遍历所有dex文件添加到集合
for (File dex : dexDir.listFiles()) {
String fileName = dex.getName();
Log.i("min77", "dexDir.listFiles() fileName = " + fileName);
//非dex文件
if (fileName.endsWith(Constants.DEX_SUFFIX) && !fileName.equals(Constants.MAIN_DEX_NAME))
fixDexSet.add(dex);
}
} else {
String fileName = "sswl.dex";
File dexFile = new File(dexDir, fileName);
if (!dexFile.exists()) {
Log.i("min77", "copyAssetsFileToStorage");
File dex = copyAssetsFileToStorage(fileName);
if (dex != null) {
fixDexSet.add(dex);
}
} else {
fixDexSet.add(dexFile);
}
}
//开始插桩修复
createDexClassLoader(dexDir);
}
/**
* stream方式
*/
public File copyAssetsFileToStorage(String fileName) {
FileOutputStream fos = null;
InputStream is = null;
try {
AssetManager assetManager = context.getAssets();
is = assetManager.open(fileName);
File externalFilesDir = context.getExternalFilesDir(null);
File dexDir = new File(externalFilesDir, "sswl");
File dexFile = new File(dexDir, fileName);
fos = new FileOutputStream(dexFile);
// 使用byte数组读取方式,缓存1KB数据
byte[] buffer = new byte[1024 * 300];
int len;
while ((len = is.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
fos.flush();
Log.i("min77", dexFile.getAbsolutePath() + "拷贝完毕");
return dexFile;
} catch (IOException e) {
Log.e("min77", "copyAssetsFileToStorage error :" + e.getMessage());
e.printStackTrace();
} finally {
try {
if (fos != null) {
fos.close();
}
if (is != null) {
is.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
/**
* 创建自有类加载器生成dexElements对象
*/
private void createDexClassLoader(File dexDir) {
try {
//创建临时解压目录
File filesDir = context.getFilesDir();
File optimizedDirectory = new File(filesDir, "sswl_optimizedDirectory");
// File tempDir = context.getDir(Constants.TEMP_UNZIP_FOLDER, Context.MODE_PRIVATE);
Log.i("min77", "dex : " + dexDir.getAbsolutePath());
if (!optimizedDirectory.exists()) optimizedDirectory.mkdirs();
//遍历dex集合进行插桩修复
for (File dex : fixDexSet) {
Log.i(TAG, "===== 正在修复 " + dex.getAbsolutePath() + " =====");
DexClassLoader classLoader = new DexClassLoader(dex.getAbsolutePath(), optimizedDirectory.getAbsolutePath(), null, context.getClassLoader());
hotFix(classLoader);
}
if (listener != null) listener.onComplete();
Log.i(TAG, "===== 修复完成 =====");
} catch (Throwable e) {
e.printStackTrace();
}
}
/**
* 插桩修复
*/
private void hotFix(DexClassLoader loader) {
try {
//获取自有类加载器中的dexElements对象
Object patchElements = ReflectUtils.getDexElements(ReflectUtils.getPathList(loader));
//获取系统类加载器中的dexElements对象
Object oldElements = ReflectUtils.getDexElements(ReflectUtils.getPathList(context.getClassLoader()));
//合并dexElements数组
Object newElements = ArrayUtils.merge(patchElements, oldElements);
//获取系统类加载器中的pathList对象
Object pathList = ReflectUtils.getPathList(context.getClassLoader());
//将合并后的数组赋值给系统的类加载器pathList对象的dexElements属性
ReflectUtils.setField(pathList, pathList.getClass(), Constants.DEX_ELEMENTS, newElements);
} catch (Throwable e) {
e.printStackTrace();
}
}
/**
* 清理磁盘缓存的dex文件(慎用,会导致需要修复的dex文件失效)
*/
public void clear() {
fixDexSet.clear();
String dexDir = context.getDir(Constants.TEMP_FOLDER, Context.MODE_PRIVATE).getAbsolutePath();
File tempFile = new File(dexDir + File.separator + Constants.TEMP_UNZIP_FOLDER);
for (File dex : tempFile.listFiles()) {
if (dex.exists()) dex.delete();
}
File dexFile = new File(dexDir);
for (File dex : dexFile.listFiles()) {
if (dex.exists()) dex.delete();
}
Log.i(TAG, "===== 清理完成 =====");
}
}