一、前言

  任何程序都无法保证上线后不会出现紧急bug,选择的修复方式不同,其代价也大不相同。所谓热修复,是相对于正常的版本迭代修复而言的,它可以及时在应用内下载补丁更新程序逻辑,修复bug;而不需要等到下一个版本发布。举个简单的例子,假如有一行代码的逻辑写错了,并且已经编译出APK,安装到了用户的手机上,此时有两种处理方式:

  1. 等待下一个版本发布,其中修复了错误代码,即迭代修复
  2. 给用户推送补丁,及时修复错误代码,即热修复

下图对比两者区别:

 Android开发之热修复_flutter

 从上图可以看出热修复相对于迭代修复有很大优势:

  1. 成本优势——避免了重新向渠道更新APK版本
  2. 时间优势——几乎是即时修复,不必等待版本覆盖时间
  3. 体验优势——避免重新安装版本,用户无感修复 

热修复技术可以为应用增加一份安全保障,也为程序更新提供了一种新的可能途径。



 

二、热修复技术原理

  从技术角度来说,我们的目的是非常明确的:把错误的代码替换成正确的代码。注意这里的替换,并不是直接擦写dx文件,而是提供一份新的正确代码,让应用运行时绕过错误代码,执行新的正确代码。

Android开发之热修复_kotlin_02

想法简单直接,但实现起来并不容易。目前主要有三类技术方案:

  • 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加载的。

 Android开发之热修复_flutter_03

上面提到过每一个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文件检索,直至找到目标类后停止:

 Android开发之热修复_kotlin_04

实际上,类替换方案的核心思想就是:将修改后的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版本为例,详细介绍一下其对接流程,主要步骤如下:

  1. 添加robust插件 
  2. 配置插件特性——robust.xml
  3. 配置补丁加载方法——自定义PatchManipulate和RobustCallback子类
  4. 编译基础版本(生成mapping.txt,methodMap.robust)
  5. 修复代码
  6. 生成补丁——patch.jar
  7. 补丁下载/推送
  8. 调用修复命令

(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()方法,作为修复标记
@Modify
public void run() {
// Log.i("hotfix", "我有一个严重Bug需要修复!");
Log.i("hotfix", "我的Bug已经被修复!");
}

//添加代码需要添加Add注释,作为标记
@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。