前言
最近面试某大厂有提到过自己之前做过热修复相关的一些东西,但是早忘了,然后答不上来,也没有良好的记录习惯用于复习。现在重新来一遍,加油。
安卓平台下热修复框架有很多,今天我们来看一下美团的方案。这里我是基于美团 robust 的1.0版本来展开的,美团技术团队也有相应的文章介绍:Robust 技术方案原理介绍。
去年美团有文章介绍他们内部 robust 框架已经升级到了2.0版本,粗略看了一下发现主要是对于代码混淆 由 proguard 到 R8 带来的改变所作出的升级,Robust 2.0 技术方案原理介绍 ,不过目前并没有开源,不知道有没有相应的计划,期待(✧∀✧)。
1、看一下代码结构
目前 github 上的代码已经好久没有更新了, Robust 最后一次更新停止在了四年前。先拉取一下它的代码,看下结构。
可以看到除去 sample 示例部分,整个框架由四个部分组成,同时其他三个模块都有依赖 base 模块
- 自动打包补丁插件 :识别需要进行修复和新增的代码方法、类(由注解
@Modify
标记的方法 / 在方法内调用RobustModify.modify()
,新增的类和方法则由注解@Add
标记 ),进而打出修复用的补丁包 - 基础能力Base:提供各种基础的能力,静态常量的定义、需要的注解、反射工具类的封装、需要注入的代码类定义等
- 编译时插入代码插件:顾名思义,在工程编译时对现有的类进行编译时插桩、对类和方法进行修改。
- Patch:使用时工程需要依赖的 Library,主要工作是让使用者自定义如何获取补丁并开启代码修复。
2、讲一下框架原理
行文初衷重在自己记录,所以主要写一下自己的理解(会有错漏,万望指出)
简单来说 Robust
是在Apk编译构建阶段为每个 .class
文件注入一个接口类型为 ChangeQuickRedirect
的静态变量,并且在这个 .class
里的每一个方法前都注入了使用这个变量的相关逻辑。进行修复时通过反射注入 ChangeQuickRedirect
的具体实现类使这个变量不为 null,从而走这个具体实现的方法逻辑来替换掉原来错误的逻辑,从而达到修复错误的目的。
3、读一下具体代码
2.1 代码插入阶段
我们知道一个Apk的诞生我们写的代码需要经历从 xxx.java/xxx.kt -> xxx.class -> xxx.dex
这样的变化,Robust 就是在代码转变为 xxx.class
完成后,即将要转变成 xxx.dex
之前对 .class
文件进行修改,注入自己的逻辑。这里需要用到 Gradle
的 Transform
Api,这是代码编译构建阶段执行的一个任务,可以自定义。通过自定义 Transform 我们可以拿到所有参与构建的 .class 文件,从而借助 Javassist 和 Asm 等字节码编辑工具,修改当前的 .class 文件插入一些自己想要的逻辑,然后把完成修改后的字节码输出到下一个构建任务。也就是在这里 Robust
给每一个 .class
和相应的方法插入了 ChangeQuickRedirect
的相关逻辑,埋下 hook 点给后续修复留下入口。
在我看来这个过程类似于在使用 OkHttp 的拦截器,添加并使用自定义拦截器,可以拿到网络请求的输入 request ,做出自己的修改后把输出(网络反馈的 response)投入到拦截链上的下一个拦截器。
2.1.1 代码插入插件使用
使用时需要在工程 的 build.gradle
的文件中引用这个插件库。
java
dependencies {
//我本地的仓库引用描述,仅作示例
classpath 'com.meituan.robust:plugin:1.0.0'
}
然后在 App 模块下的 build.gradle
中使用其中的插件
java
apply plugin: 'com.android.application'
//启用编译时插入代码插件
apply plugin: 'robust'
2.1.2 看一下具体的代码执行
启用插件进行代码编译后,流程会来到这个名为 'robust' 的插件中。查看代码中的 robust.properties
文件会发现这个插件的具体实现是由名为 RobustTranform
的类来具体实现的。
java
implementation-class=robust.gradle.plugin.RobustTransform
插件的执行首先会来到 apply
方法,这是插件执行的一个生命周期方法,以下为摘取的部分代码
java
//构建一个action,可以理解为一个子任务,对当前工程文件做 md5 运算拿到唯一值,用于补丁匹配
RobustApkHashAction action = new RobustApkHashAction()
//isForceInsert 是true的话,则强制执行插入
if (!isForceInsert) {
//非debug编译下才启用代码插入
if (!isDebugTask) {
//注册当前Transform任务对字节码进行修改
project.android.registerTransform(this)
project.afterEvaluate(action)
logger.quiet "Register robust transform successful !!!"
}
} else {
//注册当前Transform任务对字节码进行修改
project.android.registerTransform(this)
project.afterEvaluate(action)
}
注册了字节码修改的 Transform
后紧接着流程流程会来到 transform
方法
java
@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
logger.quiet '================robust start================'
//省略部分代码
......
ClassPool classPool = new ClassPool()
//添加参与编译的 android.jar 的核心类路径到类搜索路径
project.android.bootClasspath.each {
classPool.appendClassPath((String) it.absolutePath)
}
//将原来的.class文件转换为可操作的 CtClass 类型,返回类型是List<CtClass>
def box = ConvertUtils.toCtClasses(inputs, classPool)
def cost = (System.currentTimeMillis() - startTime) / 1000
//使用 ASM 还是 Javaassist 来进行字节码修改
if (useASM) {
insertcodeStrategy = new AsmInsertImpl(hotfixPackageList, hotfixMethodList, exceptPackageList, exceptMethodList, isHotfixMethodLevel, isExceptMethodLevel, isForceInsertLambda);
} else {
insertcodeStrategy = new JavaAssistInsertImpl(hotfixPackageList, hotfixMethodList, exceptPackageList, exceptMethodList, isHotfixMethodLevel, isExceptMethodLevel, isForceInsertLambda);
}
//执行具体的插入代码逻辑
insertcodeStrategy.insertCode(box, jarFile);
//把每一个进行字节码修改的方法写出到文件记录,他们都有对应的方法id int 值
writeMap2File(insertcodeStrategy.methodMap, Constants.METHOD_MAP_OUT_PATH)
logger.quiet "===robust print id start==="
for (String method : insertcodeStrategy.methodMap.keySet()) {
int id = insertcodeStrategy.methodMap.get(method);
System.out.println("key is " + method + " value is " + id);
}
logger.quiet "===robust print id end==="
cost = (System.currentTimeMillis() - startTime) / 1000
logger.quiet "robust cost $cost second"
logger.quiet '================robust end================'
}
class ConvertUtils {
//读取构建过程中的 inputs 拿到所有的输入文件包括class 和 jar 文件,转换为对应的 Ctclass
static List<CtClass> toCtClasses(Collection<TransformInput> inputs, ClassPool classPool) {
List<String> classNames = new ArrayList<>()
List<CtClass> allClass = new ArrayList<>();
def startTime = System.currentTimeMillis()
inputs.each {
it.directoryInputs.each {
def dirPath = it.file.absolutePath
classPool.insertClassPath(it.file.absolutePath)
org.apache.commons.io.FileUtils.listFiles(it.file, null, true).each {
if (it.absolutePath.endsWith(SdkConstants.DOT_CLASS)) {
def className = it.absolutePath.substring(dirPath.length() + 1, it.absolutePath.length() - SdkConstants.DOT_CLASS.length()).replaceAll(Matcher.quoteReplacement(File.separator), '.')
if(classNames.contains(className)){
throw new RuntimeException("You have duplicate classes with the same name : "+className+" please remove duplicate classes ")
}
classNames.add(className)
}
}
}
it.jarInputs.each {
classPool.insertClassPath(it.file.absolutePath)
def jarFile = new JarFile(it.file)
Enumeration<JarEntry> classes = jarFile.entries();
while (classes.hasMoreElements()) {
JarEntry libClass = classes.nextElement();
String className = libClass.getName();
if (className.endsWith(SdkConstants.DOT_CLASS)) {
className = className.substring(0, className.length() - SdkConstants.DOT_CLASS.length()).replaceAll('/', '.')
if(classNames.contains(className)){
throw new RuntimeException("You have duplicate classes with the same name : "+className+" please remove duplicate classes ")
}
classNames.add(className)
}
}
}
}
def cost = (System.currentTimeMillis() - startTime) / 1000
println "read all class file cost $cost second"
classNames.each {
try {
allClass.add(classPool.get(it));
} catch (javassist.NotFoundException e) {
println "class not found exception class name: $it "
}
}
//根据类名排序,这样每次后面得到的方法的id都是固定的
Collections.sort(allClass, new Comparator<CtClass>() {
@Override
int compare(CtClass class1, CtClass class2) {
return class1.getName() <=> class2.getName();
}
});
return allClass;
}
}
整个流程还是相对清晰的,主要就是面向编译构建这个过程进行编程。首先,借助 ClassPool
这个类依次把需要关注的 class / jar 文件关联到相应的搜索路径,这是 Javassist 中的一个类,可以把它类比为一个 Map
类型,关联上的类文件路径当做 Key
,取出的类型为 CtClass
,相当于是可操作的 class 文件。拿到所有的 CtClass 之后,接下来就是要依次对这些 class 进行字节码修改。流程来到了 insertcodeStrategy
对象的insertCode
方法。Robust 分别提供了ASM 和 Javassist 的修改策略,可以凭自己的喜好来选择使用,现在看一下 Javassist 的实现。
java
@Override
protected void insertCode(List<CtClass> box, File jarFile) throws CannotCompileException, IOException, NotFoundException {
ZipOutputStream outStream = new JarOutputStream(new FileOutputStream(jarFile));
for (CtClass ctClass : box) {
if (isNeedInsertClass(ctClass.getName()) && !(ctClass.isInterface() || ctClass.getDeclaredMethods().length < 1)) {
//修改class为 public 类型准备接下来的修改
ctClass.setModifiers(AccessFlag.setPublic(ctClass.getModifiers()));
//跳过接口类型和未定义方法的class
if (ctClass.isInterface() || ctClass.getDeclaredMethods().length < 1) {
zipFile(ctClass.toBytecode(), outStream, ctClass.getName().replaceAll("\\.", "/") + ".class");
continue;
}
//设置一个标记位,是否已经注入 ChangeQuickRedirect 类型的静态变量
boolean addIncrementalChange = false;
for (CtBehavior ctBehavior : ctClass.getDeclaredBehaviors()) {
//插入 ChangeQuickRedirect 类型的静态变量
if (!addIncrementalChange) {
//insert the field
addIncrementalChange = true;
ClassPool classPool = ctBehavior.getDeclaringClass().getClassPool();
CtClass type = classPool.getOrNull(Constants.INTERFACE_NAME);
CtField ctField = new CtField(type, Constants.INSERT_FIELD_NAME, ctClass);
ctField.setModifiers(AccessFlag.PUBLIC | AccessFlag.STATIC);
ctClass.addField(ctField);
}
//方法过滤,有些方法不需要代码插入,比如抽象方法、本地方法、构造方法等...
if (!isQualifiedMethod(ctBehavior)) {
continue;
}
//here comes the method will be inserted code
//记录插入代码的方法、前面类名排序就可以使得这里的incrementAndGet比较固定有迹可循
//每个需要插入代码的方法对应一个自增的 int 值
methodMap.put(ctBehavior.getLongName(), insertMethodCount.incrementAndGet());
//给每个方法插入相关具体的代码逻辑
try {
if (ctBehavior.getMethodInfo().isMethod()) {
CtMethod ctMethod = (CtMethod) ctBehavior;
boolean isStatic = (ctMethod.getModifiers() & AccessFlag.STATIC) != 0;
CtClass returnType = ctMethod.getReturnType();
String returnTypeString = returnType.getName();
//construct the code will be inserted in string format
String body = "Object argThis = null;";
if (!isStatic) {
body += "argThis = $0;";
}
String parametersClassType = getParametersClassType(ctMethod);
body += " if (com.meituan.robust.PatchProxy.isSupport($args, argThis, " + Constants.INSERT_FIELD_NAME + ", " + isStatic +
", " + methodMap.get(ctBehavior.getLongName()) + "," + parametersClassType + "," + returnTypeString + ".class)) {";
body += getReturnStatement(returnTypeString, isStatic, methodMap.get(ctBehavior.getLongName()), parametersClassType, returnTypeString + ".class");
body += " }";
//finish the insert-code body ,let`s insert it
ctBehavior.insertBefore(body);
}
} catch (Throwable t) {
//here we ignore the error
t.printStackTrace();
System.out.println("ctClass: " + ctClass.getName() + " error: " + t.getMessage());
}
}
}
//zip the inserted-classes into output file
//输出修改后的class文件
zipFile(ctClass.toBytecode(), outStream, ctClass.getName().replaceAll("\\.", "/") + ".class");
}
outStream.close();
}
插入代码这个方法也比较流程化,对当前 class 和方法进行一定的过滤后,便执行了插入代码的逻辑,首先插入 ChangeQuickRedirect
类型的变量,接着插入一段使用 这个变量的代码。我们看下这段代码具体是什么,起了什么作用。
2.1.3 插入了什么代码?
从上面代码可以看到,插入的代码是先使用字符串 body 描述出来的,如果直接是从字节码层面来修改代码,这无疑是很麻烦的事情,Javassist 则帮我们屏蔽了这一点,转而使用字符串来描述插入的代码,这会更加容易理解和使用。了解这个字符串是什么就知道了插入什么代码。只看代码其实还是比较凌乱,这里我拿一个实际的例子来看会简单一些。
原始方法:
java
private int bugTest(int a, int b) {
int c = (a + b );
Toast.makeText(this,"sum = " + c,Toast.LENGTH_SHORT).show();
return c ;
}
字节码修改后的方法:
java
private int bugTest(int i, int i2) {
Integer num = new Integer(i);
Integer num2 = new Integer(i2);
if (PatchProxy.isSupport(new Object[]{num, num2}, this, changeQuickRedirect, false, 6, new Class[]{Integer.TYPE, Integer.TYPE}, Integer.TYPE)) {
Integer num3 = new Integer(i);
Integer num4 = new Integer(i2);
//传入的参数
//1️⃣ 当前方法的参数
//2️⃣ 当前方法的对象,如果是静态的则会传入 null
//3️⃣ 热修复时注入的静态变量
//4️⃣ 是否是静态方法
//5️⃣ 方法的id
//6️⃣ 参数类型的数组
//7️⃣ 方法的返回类型
return ((Integer) PatchProxy.accessDispatch(new Object[]{num3, num4}, this, changeQuickRedirect, false, 6, new Class[]{Integer.TYPE, Integer.TYPE}, Integer.TYPE)).intValue();
}
int i3 = i + i2;
Toast.makeText(this, "sum = " + i3, 0).show();
return i3;
}
对比之下可以看到,插入的代码主要涉及两个方法的调用,那就是 PatchProxy
的 isSupport
和 accessDispatch
方法。看下这两个方法主要做了什么。
java
public static boolean isSupport(Object[] paramsArray, Object current, ChangeQuickRedirect changeQuickRedirect, boolean isStatic, int methodNumber, Class[] paramsClassTypes, Class returnType) {
//变量为null代表没有进行代码修复,Robust补丁优先执行,其他功能靠后
if (changeQuickRedirect == null) {
//不执行补丁,轮询其他监听者
if (registerExtensionList == null || registerExtensionList.isEmpty()) {
return false;
}
for (RobustExtension robustExtension : registerExtensionList) {
if (robustExtension.isSupport(new RobustArguments(paramsArray, current, isStatic, methodNumber, paramsClassTypes, returnType))) {
robustExtensionThreadLocal.set(robustExtension);
return true;
}
}
return false;
}
//来到这里表示已经注入了变量,准备执行里面的方法
String classMethod = getClassMethod(isStatic, methodNumber);
if (TextUtils.isEmpty(classMethod)) {
return false;
}
Object[] objects = getObjects(paramsArray, current, isStatic);
try {
//执行注入变量的方法
return changeQuickRedirect.isSupport(classMethod, objects);
} catch (Throwable t) {
return false;
}
}
public static Object accessDispatch(Object[] paramsArray, Object current, ChangeQuickRedirect changeQuickRedirect, boolean isStatic, int methodNumber, Class[] paramsClassTypes, Class returnType) {
if (changeQuickRedirect == null) {
RobustExtension robustExtension = robustExtensionThreadLocal.get();
robustExtensionThreadLocal.remove();
if (robustExtension != null) {
notify(robustExtension.describeSelfFunction());
return robustExtension.accessDispatch(new RobustArguments(paramsArray, current, isStatic, methodNumber, paramsClassTypes, returnType));
}
return null;
}
String classMethod = getClassMethod(isStatic, methodNumber);
if (TextUtils.isEmpty(classMethod)) {
return null;
}
notify(Constants.PATCH_EXECUTE);
Object[] objects = getObjects(paramsArray, current, isStatic);
return changeQuickRedirect.accessDispatch(classMethod, objects);
}
//对当前方法的一些描述,可以添加包名等,现在省略了,github上的代码也是一样的,可以按照自己的需求修改
//目前主要的就是描述当前方法是否是静态方法和当前方法的 id
//返回的结果举例: "::false:6" 这样的一个字符串
private static String getClassMethod(boolean isStatic, int methodNumber) {
String classMethod = "";
String methodName = "";
String className = "";
classMethod = className + ":" + methodName + ":" + isStatic + ":" + methodNumber;
return classMethod;
}
//把参数和调用对象封装到一个对象数组中
private static Object[] getObjects(Object[] arrayOfObject, Object current, boolean isStatic) {
Object[] objects;
if (arrayOfObject == null) {
return null;
}
int argNum = arrayOfObject.length;
if (isStatic) {
objects = new Object[argNum];
} else {
objects = new Object[argNum + 1];
}
int x = 0;
for (; x < argNum; x++) {
objects[x] = arrayOfObject[x];
}
if (!(isStatic)) {
objects[x] = current;
}
return objects;
}
可以看到 isSupport
方法最终调用到了热修复时注入的变量的 isSupport
方法中,这里先提前给出方法的实现。
java
//这是补丁里面的方法,在制作补丁的时候生成,":6:"字符串就是在排序后给当前方法做补丁得到的id
//跟前面插入代码时的id 一致
public boolean isSupport(String methodName, Object[] paramArrayOfObject) {
return ":6:".contains(new StringBuffer().append(":").append(methodName.split(":")[3]).append(":").toString());
}
仅仅是一个字符串的包含的简单判断,代表这个方法是不是补丁要修复的方法。如果是的话就来到 accessDispatch
方法,这里涉及到 RobustExtension
类型,目前不知道是处于什么需要编写的扩展,不过它不在热修复主流程中。
在 accessDispatch
方法中,经过了简单的判断后转而调用了注入变量 changeQuickRedirect
的 accessDispatch
方法中。它接收两个参数,一个是方法的描述字符串,字符串包含两个信息,方法是不是静态方法和当前方法的id,另外一个参数则是方法传入的参数数组,如果当前方法不是静态方法则在参数数组的最后加入了当前方法调用的对象。
经过这一番的操作,我们的方法调用就由App中来到了补丁之中,你会发现只要补丁中正确实现了 ChangeQuickRedirect
类型,并且实现了正确的逻辑,把这个类型的对象注入到原来错误的 class 中,就可以实现 bug 的修复。
4、总结
上面我们了解了 Robust
插入代码的部分,知道了它是如何修改当前工程的代码和插入了什么样的代码,这个阶段给后续热修复留下了切入点。这当中涉及了不少技能点是需要自己去注意的,比如 Gralde 插件的编写,Transform Api的使用,Javaassist / ASM 字节码编辑工具的使用等。
到这里顺便分析一下上述阶段有什么缺点,给工程来了什么问题,我认为主要就是有两点吧:
- 代码插入会增加不少体积,robust 添加了包名过滤等一些手段来优化这一点
- 整个工程编译的时间会增加不少
ok,这次的记录就先到这里,下次继续来看下一部分。