一、前言
任何程序都无法保证上线后不会出现紧急bug,选择的修复方式不同,其代价也大不相同。所谓热修复,是相对于正常的版本迭代修复而言的,它可以及时在应用内下载补丁更新程序逻辑,修复bug;而不需要等到下一个版本发布。举个简单的例子,假如有一行代码的逻辑写错了,并且已经编译出APK,安装到了用户的手机上,此时有两种处理方式:
- 等待下一个版本发布,其中修复了错误代码,即迭代修复
- 给用户推送补丁,及时修复错误代码,即热修复
下图对比两者区别:
从上图可以看出热修复相对于迭代修复有很大优势:
- 成本优势——避免了重新向渠道更新APK版本
- 时间优势——几乎是即时修复,不必等待版本覆盖时间
- 体验优势——避免重新安装版本,用户无感修复
热修复技术可以为应用增加一份安全保障,也为程序更新提供了一种新的可能途径。
二、热修复技术原理
从技术角度来说,我们的目的是非常明确的:把错误的代码替换成正确的代码。注意这里的替换,并不是直接擦写dx文件,而是提供一份新的正确代码,让应用运行时绕过错误代码,执行新的正确代码。
想法简单直接,但实现起来并不容易。目前主要有三类技术方案:
- native底层替换方案
- 类加载方案
- Instant Run方案
(1)native底层替换方案
Android/Java代码的最小组织方式是方法(Method,实际上,每一个dex文件最多可以包含65536(0xffff)个方法),每个方法在ART虚拟机中都有一个ArtMethod结构体指针与之对应,ArtMethod结构体中包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等等。换句话说,虚拟机就是通过ArtMethod结构体来操纵Java方法的。ArtMethod结构如下:
class ArtMethod FINAL {
...
protected:
GcRoot<mirror::Class> declaring_class_;
std::atomic<std::uint32_t> access_flags_;
// Offset to the CodeItem.
uint32_t dex_code_item_offset_;
// Index into method_ids of the dex file associated with this method.
uint32_t dex_method_index_;
uint16_t method_index_;
uint16_t hotness_count_;
struct PtrSizedFields {
// Depending on the method type, the data is
// - native method: pointer to the JNI function registered to this method
// or a function to resolve the JNI function,
// - conflict method: ImtConflictTable,
// - abstract/interface method: the single-implementation if any,
// - proxy method: the original interface method or constructor,
// - other methods: the profiling data.
void* data_;
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// the interpreter.
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
...
}
其中有一个关键指针,它是方法的执行入口:
entry_point_from_quick_compiled_code_
也就是说,这个指针指向方法体编译后对应的汇编指令。那么,如果我们能hook这个指针,由原来指向有bug的方法,变成指向正确的方法,就达到了修复的目的。这就是native层替换方案的核心原理。具体实现方案可以是改变指针指向(AndFix),也可以直接替换整个结构体(Sophix)。
需要注意的是,底层替换方案虽然是即使生效的,但是因为不会加载新类,而是直接修改原类,所以修改的代码不能增加新的方法,否则会造成索引数与方法数不匹配,无法通过索引找到正确方法,字段同理。
(2)类加载方案
native底层替换方案hook的是method指针,类加载方案则将目标定在类上。我们写的.java代码,最终是由ClassLoader加载的。
上面提到过每一个dex文件最多可以包含65536(0xffff)个方法,超过了就需要用到分包方案,也就是说,每个APK中可能包含多个dex文件。而每个dex文件,最终对应DexPathList中的一个Element实例:
static class Element {
private final File path;
private final DexFile dexFile;
private ClassPathURLStreamHandler urlHandler;
private boolean initialized;
public Element(DexFile dexFile, File dexZipPath) {
this.dexFile = dexFile;
this.path = dexZipPath;
}
......
}
如果加载一个类,会调用DexPathList中的findClass函数:
public Class<?> findClass(String name, List<Throwable> suppressed) {
DexPathList.Element[] var3 = this.dexElements; //多个dex文件对应Element数组
int var4 = var3.length;
for(int var5 = 0; var5 < var4; ++var5) {
DexPathList.Element element = var3[var5];
Class<?> clazz = element.findClass(name, this.definingContext, suppressed); //以此从dex文件中查找目标Class
if (clazz != null) {
return clazz;
}
}
if (this.dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(this.dexElementsSuppressedExceptions));
}
return null;
}
如上代码,当需要加载一个类时,会依次从dex文件检索,直至找到目标类后停止:
实际上,类替换方案的核心思想就是:将修改后的patch(包含bug类文件)打包成dex文件,然后hook ClassLoader加载流程,将这个dex文件插入到Element数组的第一个元素。因为加载类是依次进行的,所以虚拟机从第一个Element找到类后,就不会再加载bug类了。
类加载方案也有缺点,因为类加载后无法卸载,所以类加载方案必须重启App,让bug类重新加载后才能生效。
(3)Instant Run方案
Instant Run 方案的核心思想是——插桩,在编译时通过插桩在每一个方法中插入代码,修改代码逻辑,在需要时绕过错误方法,调用patch类的正确方法。
首先,在编译时Instant Run为每个类插入IncrementalChange变量:
IncrementalChange $change;
为每一个方法添加类似如下代码:
public void onCreate(Bundle savedInstanceState) {
IncrementalChange var2 = $change;
//$change不为null,表示该类有修改,需要重定向
if(var2 != null) {
//通过access$dispatch方法跳转到patch类的正确方法
var2.access$dispatch("onCreate.(Landroid/os/Bundle;)V", new Object[]{this, savedInstanceState});
} else {
super.onCreate(savedInstanceState);
this.setContentView(2130968601);
this.tv = (TextView)this.findViewById(2131492944);
}
}
如上代码,当一个类被修改后,Instant Run会为这个类新建一个类,命名为xxx&override,且实现IncrementalChange接口,并且赋值给原类的$change变量。
public class MainActivity$override implements IncrementalChange {
}
此时,在运行时原类中每个方法的var2 != null,通过accessdispatch(参数是方法名和原参数)定位到patch类MainActivitydispatch(参数是方法名和原参数)定位到patch类MainActivityoverride中修改后的方法。
Instant Run是google在AS2.0时用来实现“热部署”的,同时也为“热修复”提供了一个绝佳的思路。美团的Robust就是基于此。
总结:
以上是三种方案的基本原理,每种方案又有不同的实现方案,导致目前热修复出现百家争鸣的现象。无论哪种热修复方案,都不是一蹴而就的,需要在长期的实战中不断完善。
众方案各有所长,且基于自家业务不断更新迭代。统计如下:
特性 | Dexposed | AndFix | Tinker/Amigo | QQ Zone | Robust/Aceso | Sophix |
技术原理 | native底层替换 | 类加载 | Instant Run | 混合 | ||
所属 | 阿里 | 微信/饿了么 | QQ空间 | 美团/蘑菇街 | 阿里 | |
即时生效 | YES | YES | NO | NO | YES | 混合 |
方法替换 | YES | YES | YES | YES | YES | YES |
类替换 | NO | NO | YES | YES | YES | YES |
类结构修改 | NO | NO | YES | NO | NO | YES |
资源替换 | NO | NO | YES | YES | NO | YES |
so替换 | NO | NO | YES | NO | NO | YES |
支持gradle | NO | NO | YES | YES | YES | YES |
支持ART | NO | YES | YES | YES | YES | YES |
可以看出,阿里系多采用native底层方案,腾讯系多采用类加载机制。其中,Sophix是商业化方案;Tinker/Amigo支持特性较多,同时也更复杂,如果需要修复资源和so,可以选择;如果仅需要方法替换,且需要即时生效,Robust是不错的选择。
三、自定义热修复方案
以类加载机制为例,自定义一个简单的热修复demo,核心代码如下(尚未验证通过,待研究插件化技术之后补齐):
public class Hotfix {
public static void patch(Context context, String patchDexFile, String patchClassName)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
//获取系统PathClassLoader的"dexElements"属性值
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object origDexElements = getDexElements(pathClassLoader);
//新建DexClassLoader并获取“dexElements”属性值
String otpDir = context.getDir("dex", 0).getAbsolutePath();
Log.i("hotfix", "otpdir=" + otpDir);
DexClassLoader nDexClassLoader = new DexClassLoader(patchDexFile, otpDir, patchDexFile, context.getClassLoader());
Object patchDexElements = getDexElements(nDexClassLoader);
//将patchDexElements插入原origDexElements前面
Object allDexElements = combineArray(origDexElements, patchDexElements);
//将新的allDexElements重新设置回pathClassLoader
setDexElements(pathClassLoader, allDexElements);
//重新加载类
pathClassLoader.loadClass(patchClassName);
}
private static Object getDexElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);
//然后获取“pathList”实例的“dexElements”属性
Field dexElementField = pathList.getClass().getDeclaredField("dexElements");
dexElementField.setAccessible(true);
//读取"dexElements"的值
Object elements = dexElementField.get(pathList);
return elements;
}
//合拼dexElements
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
//读取obj长度
int length = Array.getLength(obj);
//读取obj2长度
int length2 = Array.getLength(obj2);
Log.i("hotfix", "length=" + length + ",length2=" + length2);
//创建一个新Array实例,长度为ojb和obj2之和
Object newInstance = Array.newInstance(componentType, length + length2);
for (int i = 0; i < length + length2; i++) {
//把obj2元素插入前面
if (i < length2) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
//把obj元素依次放在后面
Array.set(newInstance, i, Array.get(obj, i - length2));
}
}
//返回新的Array实例
return newInstance;
}
private static void setDexElements(ClassLoader classLoader, Object dexElements) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);
//然后获取“pathList”实例的“dexElements”属性
Field declaredField = pathList.getClass().getDeclaredField("dexElements");
declaredField.setAccessible(true);
//设置"dexElements"的值
declaredField.set(pathList, dexElements);
}
}
四、Robust方案对接
Robust是美团团队基于Instant Run 技术开发的开源(dian zan)热修复框架,Github地址:https://github.com/Meituan-Dianping/Robust
下面以Robust 4.9版本为例,详细介绍一下其对接流程,主要步骤如下:
- 添加robust插件
- 配置插件特性——robust.xml
- 配置补丁加载方法——自定义PatchManipulate和RobustCallback子类
- 编译基础版本(生成mapping.txt,methodMap.robust)
- 修复代码
- 生成补丁——patch.jar
- 补丁下载/推送
- 调用修复命令
(1)添加robust插件
共有两处需要添加,在项目外层build.gradle添加:
dependencies {
classpath 'com.meituan.robust:gradle-plugin:0.4.90'
classpath 'com.meituan.robust:auto-patch-plugin:0.4.90'
}
在app module的build.gradle添加:
apply plugin: 'com.android.application'
//此两项紧跟com.android.application,生成补丁时打开auto-patch-plugin插件
//apply plugin: 'auto-patch-plugin'
apply plugin: 'robust'
(2)配置插件特性——robust.xml
将robust.xml配置文件拷贝到app根目录下,并按需求配置插件特性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<switch>
<!--true代表打开Robust,请注意即使这个值为true,Robust也默认只在Release模式下开启-->
<!--false代表关闭Robust,无论是Debug还是Release模式都不会运行robust-->
<turnOnRobust>true</turnOnRobust>
<!--<turnOnRobust>false</turnOnRobust>-->
<!--是否开启手动模式,手动模式会去寻找配置项patchPackname包名下的所有类,自动的处理混淆,然后把patchPackname包名下的所有类制作成补丁-->
<!--这个开关只是把配置项patchPackname包名下的所有类制作成补丁,适用于特殊情况,一般不会遇到-->
<!--<manual>true</manual>-->
<manual>false</manual>
<!--是否强制插入插入代码,Robust默认在debug模式下是关闭的,开启这个选项为true会在debug下插入代码-->
<!--但是当配置项turnOnRobust是false时,这个配置项不会生效-->
<!--<forceInsert>true</forceInsert>-->
<forceInsert>false</forceInsert>
<!--是否捕获补丁中所有异常,建议上线的时候这个开关的值为true,测试的时候为false-->
<catchReflectException>true</catchReflectException>
<!--<catchReflectException>false</catchReflectException>-->
<!--是否在补丁加上log,建议上线的时候这个开关的值为false,测试的时候为true-->
<!--<patchLog>true</patchLog>-->
<patchLog>false</patchLog>
<!--项目是否支持progaurd-->
<proguard>true</proguard>
<!--<proguard>false</proguard>-->
<!--项目是否支持ASM进行插桩,默认使用ASM,推荐使用ASM,Javaassist在容易和其他字节码工具相互干扰-->
<useAsm>true</useAsm>
<!--<useAsm>false</useAsm>-->
</switch>
<!--需要热补的包名或者类名,这些包名下的所有类都被会插入代码-->
<!--这个配置项是各个APP需要自行配置,就是你们App里面你们自己代码的包名,
这些包名下的类会被Robust插入代码,没有被Robust插入代码的类Robust是无法修复的-->
<packname name="hotfixPackage">
<name>com.xibeixue.hotfix</name>
<!--<name>com.sankuai</name>-->
<!--<name>com.dianping</name>-->
</packname>
<!--不需要Robust插入代码的包名,Robust库不需要插入代码,如下的配置项请保留,还可以根据各个APP的情况执行添加-->
<exceptPackname name="exceptPackage">
<name>com.meituan.robust</name>
<name>com.meituan.sample.extension</name>
</exceptPackname>
<!--补丁的包名,请保持和类PatchManipulateImp中fetchPatchList方法中设置的补丁类名保持一致( setPatchesInfoImplClassFullName("com.meituan.robust.patch.PatchesInfoImpl")),
各个App可以独立定制,需要确保的是setPatchesInfoImplClassFullName设置的包名是如下的配置项,类名必须是:PatchesInfoImpl-->
<patchPackname name="patchPackname">
<name>com.xibeixue.hotfix</name>
</patchPackname>
<!--自动化补丁中,不需要反射处理的类,这个配置项慎重选择-->
<noNeedReflectClass name="classes no need to reflect">
</noNeedReflectClass>
</resources>
注意:如果是调试,请打开forceInsert,关闭proguard。一般packname和patchPackname需要自行配置,其他选项保持默认即可。
(3)配置补丁加载方法
第(2)步配置的是插件的工作方式,为了生成补丁patch.jar;程序还需要知道如何加载补丁,比如补丁在哪里,要解压到哪里等。这就需要自定义PatchManipulate子类:
public class PatchManipulateImp extends com.meituan.robust.PatchManipulate {
@Override
protected List<Patch> fetchPatchList(Context context) {
//将app自己的robustApkHash上报给服务端,服务端根据robustApkHash来区分每一次apk build来给app下发补丁
//apkhash is the unique identifier for apk,so you cannnot patch wrong apk.
//String robustApkHash = RobustApkHashUtils.readRobustApkHash(context);
Patch patch = new Patch();
patch.setName("123");
//we recommend LocalPath store the origin patch.jar which may be encrypted,while TempPath is the true runnable jar
patch.setLocalPath(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "patch");
/*上面的路径看似设置的是目录,其实不是,在get方法中默认追加了.jar;temp默认则追加_temp.jar.可以理解为设置补丁的文件名.建议放在程序内部目录,提高安全性*/
/*com.xibeixue.hotfix.PatchesInfoImpl要和robut.xml中patchPackname节点里面的包名保持一致*/
patch.setPatchesInfoImplClassFullName("com.xibeixue.hotfix.PatchesInfoImpl");
List patches = new ArrayList<Patch>();
patches.add(patch);
return patches;
}
@Override
protected boolean verifyPatch(Context context, Patch patch) {
patch.setTempPath(context.getCacheDir() + File.separator + "robust" + File.separator + "patch");
//in the sample we just copy the file
try {
copy(patch.getLocalPath(), patch.getTempPath());
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("copy source patch to local patch error, no patch execute in path " + patch.getTempPath());
}
return true;
}
@Override
protected boolean ensurePatchExist(Patch patch) {
return true;
}
public void copy(String srcPath, String dstPath) throws IOException {
Log.i("hotfix","srcPath=" + srcPath);
File src = new File(srcPath);
if (!src.exists()) {
throw new RuntimeException("source patch does not exist ");
}
File dst = new File(dstPath);
if (!dst.getParentFile().exists()) {
dst.getParentFile().mkdirs();
}
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
// Transfer bytes from in to out
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} finally {
out.close();
}
} finally {
in.close();
}
}
}
注意:setPatchesInfoImplClassFullName时需要和robust.xml中的patchPackname包名保持一致。
如果要对补丁加载过程监听,需要自定义RobustCallback子类:
public class RobustCallBackSample implements com.meituan.robust.RobustCallBack {
@Override
public void onPatchListFetched(boolean result, boolean isNet, List<Patch> patches) {
Log.d("RobustCallBack", "onPatchListFetched result: " + result);
Log.d("RobustCallBack", "onPatchListFetched isNet: " + isNet);
for (Patch patch : patches) {
Log.d("RobustCallBack", "onPatchListFetched patch: " + patch.getName());
}
}
@Override
public void onPatchFetched(boolean result, boolean isNet, Patch patch) {
Log.d("RobustCallBack", "onPatchFetched result: " + result);
Log.d("RobustCallBack", "onPatchFetched isNet: " + isNet);
Log.d("RobustCallBack", "onPatchFetched patch: " + patch.getName());
}
@Override
public void onPatchApplied(boolean result, Patch patch) {
Log.d("RobustCallBack", "onPatchApplied result: " + result);
Log.d("RobustCallBack", "onPatchApplied patch: " + patch.getName());
}
@Override
public void logNotify(String log, String where) {
Log.d("RobustCallBack", "logNotify log: " + log);
Log.d("RobustCallBack", "logNotify where: " + where);
}
@Override
public void exceptionNotify(Throwable throwable, String where) {
Log.e("RobustCallBack", "exceptionNotify where: " + where, throwable);
}
}
(4)编译基础版本
到目前为止,就可以编译基础版本了,这时插件会生成两个文件
//方法记录文件,该文件在打补丁的时候用来区别到底哪些方法需要被修复
build/outputs/robust/methodsMap.robust
//该文件列出了原始的类、方法和字段名与混淆后代码间的映射,需要开启proguard配置项后才会出现
build/outputs/mapping/mapping.txt
将这两个文件拷贝到app根目录下的robust文件夹下(没有就自行创建),后面生成补丁时会用到。
(5)修复代码
//修复代码,需要添加Modify注释或者调用RobustModify.modify()方法,作为修复标记
public void run() {
// Log.i("hotfix", "我有一个严重Bug需要修复!");
Log.i("hotfix", "我的Bug已经被修复!");
}
//添加代码需要添加Add注释,作为标记
public void run2(){
Log.i("hotfix", "我是一个新添加的方法!");
}
(6)生成补丁
生成补丁,只需要打开auto-patch-plugin补丁插件,重新编译即可:
//打开补丁插件
apply plugin: 'auto-patch-plugin'
apply plugin: 'robust'
此时,会在app根目录下的robust文件夹下生成patch.jar补丁文件。
(7)补丁下载、推送
Robust热修复框架并没有补丁下载模块,需要自行和后台服务协商下载或推送方案。但是patch.jar必须下载到PatchManipulateImp指定的localPath。另外,如果下载到sd卡,一定要申请sd卡读写权限!
(8)调用修复命令
在合适的时机,调用修复命令,一般下载后尽早调用:
new PatchExecutor(getApplicationContext(), new PatchManipulateImp(),
new RobustCallBackSample()).start();
调用修复命令后,不用重启进程,再次调用被修复方法时,发现已经开始执行修复逻辑了!
Demo源码:https://github.com/JiaxtHome/hotfix
五、总结
尽管热修复(或热更新)相对于迭代更新有诸多优势,市面上也有很多开源方案可供选择,但目前热修复依然无法替代迭代更新模式。有如下原因:
- 热修复框架多多少少会增加性能开销,或增加APK大小
- 热修复技术本身存在局限,比如有些方案无法替换so或资源文件
- 热修复方案的兼容性,有些方案无法同时兼顾Dalvik和ART,有些深度定制系统也无法正常工作
- 监管风险,比如苹果系统严格限制热修复
所以,对于功能迭代和常规bug修复,版本迭代更新依然是主流。一般的代码修复,使用Robust可以解决,如果还需要修复资源或so库,可以考虑Tinker。