大家好,我是瑞英。
本篇会描述一个完整的安卓热修复工具的设计与实现。该热修工具基于instantRun原理,并且参照美团开源的robust框架,能够有效进行代码热修、so热修以及资源热修
热修复:无需通过发版,修复线上客户端问题的一种技术方案,特点是快速止损。
本文描述中:base包就是宿主包【需要被修复的包】,patch包是下发到base包的修复包
为何要热修?
客户端线上出现问题,传统的解决方案就是发一个新的客户端版本,让用户主动触发升级应用,覆盖速度十分有限。问题修复时间越长,损失就会越大。需要一种可以快速修复线上客户端问题的技术-称之为热修复。 热修复能够做到用户无感知,快速修复线上问题
热修方案概述
原理上看,目前安卓热修主要分三种:基于类加载、基于底层替换、侵入方法插桩的。
主流热修产品
厂商 | 产品 | 修复范围 | 修复时机 | 稳定性 | 接入成本 | 技术方案 |
---|---|---|---|---|---|---|
腾讯 | tinker | 类、资源、so | 冷启 | 一般 | 高 | 合成差量热修dex并冷启加载 |
阿里 | sophix | 类、资源、so | 冷启动、即时修复都支持(可选) | 高 | 高(商用) | 综合方案(底层替换方案&类加载方案) |
美团 | robust | 方法修复 | 及时修复 | 高 | 低 | 下文详细介绍 |
代码修复方案
底层替换方案
直接在native层,将被修复类对应的artMethod进行替换,即可完成方法修复。
每一个java方法在art中都对应着一个ArtMethod,记录了这个java方法的所有信息:所属类、访问权限、代码执行地址等
特性:
- 无法实现对原有类方法和字段的增减(只支持方法替换)
- 修复了的非静态方法,无法被正常发射调用(因为反射调用的时候会verifyObjectIsClass)
- 实效性好,可立即加载生效无需重启应用
- 需要针对dalvik虚拟机和art虚拟机做适配,需要考虑指令集的兼容问题
- 无法解决匿名内部类增减的情况
- 不支持 <clinit>方法热修
类加载方案
合成修复后全量dex,冷启重新加载类,完成修复
特性:
- 需要冷启生效
- 高兼容性,几乎可以修复任何代码修复的场景
so修复方案
通过反射将指定热修so路径插入到nativeLibraryDirectories
base构建时保留所有so的md5值,patch包构建时,会进行校验,识别出发生变动的热修so,并将其打入patch中
资源修复方案
资源热修包的构建:
base构建时会保留base包的资源id,以及所有资源md5值,patch构建时,利用base id实现资源id固定,同时将新增资源打入patch中,使用新增资源的方法被自动标注为修复方法
资源热修包的加载: 通过反射调用AssetManager.addAssetPath,添加热修资源路径,在activity loadResources时,触发load热修资源
代码修复方案详解
在base包构建时,对需要被热修的方法进行插桩,保留相关base包构建信息【方法、类、属性以及其混淆信息】,在热修包构建时,依赖注解识别出被热修的方法,并结合base包相关信息,最终构建出热修包。
实现修复的原理
在base包构建时,对于方法都插入一个条件分支,执行热修代理调用。如果热修代理方法返回结果为true,则当前方法直接返回热修result,即该方法被成功热修【如下图所示】。当然这种侵入base包构建的热修方案,会导致包体积有所增加。
详解base包插桩指令
根据方法的参数和返回值特性,进行不同proxy方法的插入
-
根据返回值分类:
无返回值,则proxy方法直接返回boolean即可,如此被插桩方法中不需要出现proxyResult.isSupport的判断
有返回值:需要返回ProxyResult
-
根据参数个数进行分类,使得在插桩时,插桩方法的参数尽可能的少且简单,即插入指令尽可能的少。(目前对5个及以下的参数个数进行分类)
只有5个以上的参数方法被插桩时,需要采用Object[]数组传递所有的参数。因为构建数组并且初始化数组元素,所需要的指令较多。
例如:若方法只有一个参数,那么直接传递object对象只需要1条指令,如果通过Object[]传递该对象需要6条指令
scss//有一个参数str:String,存放与局部变量表中 index = 1 //直接传递该object对象 mv.visitMethodInsn(ALOAD, 1) //利用object数组进行传递 mv.visitInsn(1)//数组大小 mv.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/Object") mv.visitInsn(Opcodes.DUP)// 创建数组object[] mv.visitInsn(Opcodes.ICONST_0)// 下标索引 mv.visitVarInsn(Opcodes.ALOAD, 1) //获取局部变量表中该object对象 mv.visitInsn(Opcodes.AASTORE) //存入数组中
-
5个以上参数的方法的热修代理方法(区分为有返回值和无返回值)
-
5个及以下参数的方法的热修代理方法
java
//如下方法是被插桩到base包中的方法
@JvmStatic
fun proxyVoid0Para(obj: Any?, cls: Class<*>, methodNumber: Int): Boolean {
return proxy(arrayOf(), obj, cls, methodNumber).isSupported
}
@JvmStatic
fun proxyVoid4Para(
param1: Any?,
param2: Any?,
param3: Any?,
param4: Any?,
obj: Any?,
cls: Class<*>,
methodNumber: Int
): Boolean {
return proxy(arrayOf(param1, param2, param3, param4), obj, cls, methodNumber).isSupported
}
@JvmStatic
fun proxy0Para(obj: Any?, cls: Class<*>, methodNumber: Int): PatchProxyResult {
return proxy(arrayOf(), obj, cls, methodNumber)
}
@JvmStatic
fun proxy4Para(param1: Any?, param2: Any?, param3: Any?, param4: Any?, obj: Any?, cls: Class<*>, methodNumber: Int): PatchProxyResult {
return proxy(arrayOf(param1, param2, param3, param4), obj, cls, methodNumber)
}
-
proxy方法传递的参数详解
- 当前方法的参数
- 当前类(用于查找当前类是否有热修对象)
- 当前类对象(如果是静态方法则传null,用于对当前类非静态属性的访问)
- 方法编号(用于匹配热修方法)
详解patch包插桩
每一个被修复的类(PatchTestAct)必然会插桩生成两个类:
- Patch类(PatchTestActPatch),这个类中有修复方法
- 一个控制类(实现ChangeQuickRedirect接口,PatchTestActPatchControl),分发执行Patch类中的修复方法
从上述PatchProxy.proxy方法中可以看出。所有被热修的类,会被存在一个重定向map中。执行proxy方法时,若表中有该被插桩类,则对应执行该插桩类的热修对象(ChangeQUickRedirect实现类对象),执行该对象的
accessDispatch方法。每个方法在base构建时都会有一个编号。热修对象通过传入的方法编号,确定最终执行的热修方法。
java
public interface ChangeQuickRedirect {
/**
* 将方法的执行分发到对应的修复方法
* @param methodName 被插桩的方法编号
* @param paramArrayOfObject 参数值列表
* @param obj 被插桩类对象
* @return
*/
Object accessDispatch(String methodNumber, Object[] paramArrayOfObject, Object obj);
/**
* 判断方法是否能被分发到对应的修复方法
*/
boolean isSupport(String methodNumber);
/** * 判断方法是否能被分发到对应的修复方法 */ boolean isSupport(String methodNumber);
}
如上述例子中,要热修该PatchTestAct2.test方法,对该方法加上@Modify注解后,进行热修patch构建后生成的PatchControl类和Patch类分别是:
java
public class PatchTestActPatchControl implements ChangeQuickRedirect {
public static final String MATCH_ALL_PARAMETER = "(\\w*\\.)*\\w*";
private static final Map<Object, Object> keyToValueRelation = new WeakHashMap();
public PatchTestActPatchControl() {
}
public Object accessDispatch(String methodNumber, Object[] paramArrayOfObject, Object var3) {
try {
PatchTestActPatch var4 = null;
if (var3 != null) {
if (keyToValueRelation.get(var3) == null) {
var4 = new PatchTestActPatch(var3);
keyToValueRelation.put(var3, (Object)null);
} else {
var4 = (PatchTestActPatch)keyToValueRelation.get(var3);
}
} else {
var4 = new PatchTestActPatch((Object)null);
}
if ("119".equals(methodNumber)){var4.invokeAddMethod((Context)paramArrayOfObject[0]);
}
if ("120".equals(methodNumber)) {
var4.test((String)paramArrayOfObject[0], (Function1)paramArrayOfObject[1]);
}
} catch (Throwable var7) {
var7.printStackTrace();
}
return null;
}
public boolean isSupport(String methodName) {
return ":119::120:".contains(":" + methodName + ":");
}
private static Object fixObj(Object booleanObj) {
if (booleanObj instanceof Byte) {
byte byteValue = (Byte)booleanObj;
boolean booleanValue = byteValue != 0;
return new Boolean(booleanValue);
} else {
return booleanObj;
}
}
// 看起来好像没有用到这个方法
public Object getRealParameter(Object var1) {
return var1 instanceof PatchTestAct ? new PatchTestActPatch(var1) : var1;
}
}
java
public class PatchTestActPatch {
PatchTestAct originClass;
/**
* 传入原始对象
*/
public PatchTestActPatch(Object var1) {
this.originClass = (PatchTestAct)var1;
}
/**
* 将所访问的变量做一个转换,如果访问的是当前类this,则需要转换为this.originClass对象
*/
public Object[] getRealParameter(Object[] var1) {
if (var1 != null && var1.length >= 1) {
Object[] var2 = (Object[])Array.newInstance(var1.getClass().getComponentType(), var1.length);
for(int var3 = 0; var3 < var1.length; ++var3) {
if (var1[var3] instanceof Object[]) {
var2[var3] = this.getRealParameter((Object[])var1[var3]);
} else if (var1[var3] == this) {
var2[var3] = this.originClass;
} else {
var2[var3] = var1[var3];
}
}
return var2;
} else {
return var1;
}
}
/**
* 被修复的方法
*/
public final void test(String str, Function1<? super String, Unit> a) {
String var3 = "str";
Object[] var5 = this.getRealParameter(new Object[]{str, var3});
Class[] var6 = new Class[]{Object.class, String.class};
EnhancedRobustUtils.invokeReflectStaticMethod("checkNotNullParameter", Intrinsics.class, var5, var6);
String var7 = "a";
Object[] var9 = this.getRealParameter(new Object[]{a, var7});
Class[] var10 = new Class[]{Object.class, String.class};
EnhancedRobustUtils.invokeReflectStaticMethod("checkNotNullParameter", Intrinsics.class, var9, var10);
Object[] var12 = this.getRealParameter(new Object[]{str});
Class[] var13 = new Class[]{Object.class};
Object var14;
if (a == this && 0 == 0) {
var14 = ((PatchTestActPatch)a).originClass;
} else {
var14 = a;
}
Object var10000 = (Object)EnhancedRobustUtils.invokeReflectMethod("invoke", var14, var12, var13, Function1.class);
}
}
每一个新增方法(在base包中不存在的方法):
对这个新增方法所在类打一个InlinePatch.class类,该类中定义这个新增方法
热修代码的处理过程
从字节码到patch.dex中
代码修复中解决的关键问题
本方案支持,方法修复、新增方法、新增类、新增属性、新增override方法。主要解决了以下问题:
- 修复方法中对其他类属性、方法的调用
- 修复代码中,存在调用base包中被删除的方法的指令
- 修复代码中存在匿名内部类的生成和使用、when表达式与enum联用
- 修复方法中存在调用父类方法的指令
- 修复代码中存在invokeDynamic指令(单接口lambda表达式/函数式接口、高阶函数等)
- 新增方法是override方法,并且使用其多态属性
- 修复构造方法、新增构造方法
- 修复方法有@JvmStatic注解,@JvmOverloads注解,这些注解方法被java 和kotlin调用不同而编译出不同的字节码
- r8内联、外联、类合并等系列优化操作,使得编译结果与原始字节码有很大的差异
总结
本文所描述的代码修复方案,相对于美团原始方案做了较大优化,base插桩对插入指令做了精简,且不再对每个类插入属性用于判断当前类是否被热修,而是将被修复类的信息存在一个静态map中。patch插桩完全重新处理,大大拓展了可修复的范围,提高了热修工具可用性。后续也扩展支持了,通过字节码对比自动识别需要修复的代码,无需开发者手动标注。
除上文所述之外,热修也有一些其他方面值得讨论,热修sop、热修包的构建速度提升,以及热修包的下发和加载等。