Android进阶从字节码插桩技术了解美团热修复实例详解
作者:layz4android
引言
热修复技术如今已经不是一个新颖的技术,很多公司都在用,而且像阿里、腾讯等互联网巨头都有自己的热修复框架,像阿里的AndFix采用的是hook native底层修改代码指令集的方式;腾讯的Tinker采用类加载的方式修改dexElement;而美团则是采用字节码插桩的方式,也就是本文将介绍的一种技术手段。
我们知道,如果上线出现bug,通常是发生在方法的调用阶段,某个方法异常导致崩溃;字节码插桩,就是在编译阶段将一段代码插入该方法中,如果线上崩溃,需要发布补丁包,同时在执行该方法时,如果检测到补丁包的存在,将会走插桩插入的逻辑,而不是原逻辑。
如果想要知道美团实现的热修复框架原理,那么首先需要知道,robust该怎么用
对于每个模块,如果想要插桩需要引入robust插件,所以如果自己实现一个简单的robust的功能,就需要创建一个插件,然后在插件中处理逻辑,我个人喜欢在buildSrc里写插件然后发布,当然也可以自己创建一个java工程改造成groovy工程
plugins { id 'groovy' id 'maven-publish' } dependencies { implementation gradleApi() implementation localGroovy() implementation 'com.android.tools.build:gradle:3.1.2' }
如果创建一个java模块,如果要【改装】成一个groovy工程,就需要做上述的配置🔼
1 插件发布
初始化之后,我一般会先建2个文件夹
plugin用于自定义插件,定义输入输出; task用于任务执行。
class MyRobustPlugin implements Plugin<Project>{ @Override void apply(Project project) { //项目配置阶段执行,配置完成之后, project.afterEvaluate { println '插件开始执行了' } } }
如果需要发布插件到maven仓库,或者放在本地,可以通过maven-publish(gradle 7.0+)插件来实现
afterEvaluate { publishing { publications{ releaseType(MavenPublication){ from components.java groupId 'com.demo' artifactId 'robust' version '0.0.1' } } repositories { maven { url uri('../repo') } } } }
publications:这里可以添加你要发布的maven版本配置 repositories:maven仓库的地址,这里就是写在本地一个文件夹
重新编译之后,在publish文件夹下会生成很多任务,执行发布到maven仓库的任务,就会在本地的repo文件夹下生成对应的jar包
接下来我们尝试用下这个插件
buildscript { repositories { google() mavenCentral() jcenter() //这里配置了我们的插件依赖的本地仓库地址 maven { url uri('repo') } } dependencies { classpath "com.android.tools.build:gradle:7.0.3" classpath "com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10" classpath "com.demo:robust:0.0.1" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } }
配置完成后,在app模块添加插件依赖
apply plugin:'com.demo'
这里会报错,com.demo这个插件id找不到,原因就是,其实插件是一个jar包,然后我们只是创建了这个插件,并没有声明入口,在编译jar包时找不到清单文件,因此需要在资源文件夹下声明清单文件
implementation-class=com.tal.robust.plugin.MyRobustPlugin
创建插件名字的属性文件,声明插件的入口,就是我们自己定义的插件,再次编译运行
这也意味着,我们的插件执行成功了,所以准备工作已完成,如果需要插桩的模块,那么就需要依赖这个插件
2 Javassist
Javassist号称字节码手术刀,能够在class文件生成之后,打包成dex文件之前就将我们自定义的代码插入某个位置,例如在getClassId方法第62行代码的位置,插入逻辑判断代码
2.1 准备工作
引入Javassist,插件工程引入Javassist
implementation 'org.javassist:javassist:3.20.0-GA'
2.2 Transform
Javassist作用于class文件生成之后,在dex文件生成之前,所以如果想要对字节码做处理,就需要在这个阶段执行代码插入,这里就涉及到了一个概念 --- transform;
Android官方对于transform做出的定义就是:Transform用于在class打包成dex这个中间过程,对字节码做修改
在build文件夹中,我们可以看到这些文件夹,像merged_assets、merged_java_res等,这是Gradle的Transform,用于打包资源文件到apk文件中,执行的顺序为串行执行,一个任务的输出为下一个任务的输入,而在transforms文件夹下就是我们自己定义的transform
implementation 'com.android.tools.build:transform-api:1.5.0'
导入Transform依赖👆🏻
class MyRobustTransform extends Transform{ /** * 在transforms文件夹下的文件夹名字 * @return */ @Override String getName() { return "MyRobust" } /** * Transform要处理的输入文件类型 : 字节码 * @return */ @Override Set<QualifiedContent.ContentType> getInputTypes() { return TransformManager.CONTENT_CLASS } /** * 作用域:整个项目 * @return */ @Override Set<QualifiedContent.Scope> getScopes() { return TransformManager.SCOPE_FULL_PROJECT } /** * 是否为增量编译 * @return */ @Override boolean isIncremental() { return false } @Override void transform(TransformInvocation transformInvocation) throws IOException, TransformException, InterruptedException { } }
如何让自定义的Transform生效,需要在插件中注册这个Transform
@Override void apply(Project project) { println '插件开始执行了' //注册Transform def ext = project.extensions.getByType(AppExtension) if(ext != null){ ext.registerTransform(new MyRobustTransform(project)); } }
对于每个模块,Gradle编译时都是创建一个Project对象,这里就是拿到了当前模块gradle中的android扩展,然后调用了registerTransform函数注册Transform,MyRobustTransform中的transform函数会被调用,将class、jar、resource等文件做处理
把一开始的流程图细分一下,其实class字节码在处理的时候是经历了多个transform,这里可以把transform看做是任务,每个任务执行完成之后,都将输出交由下一个task作为输入,我们自定义的transform是被放在transform链的头部
Task :app:transformClassesWithMyRobustForDebug
2.3 transform函数注入代码
OK,我们注册完成之后,这个Transform任务就能够执行了,执行的时候,会执行transform函数中的代码,我们注入代码也是在这个函数中进行
@Override void transform(TransformInvocation transformInvocation) { super.transform(transformInvocation) println "transform start" transformInvocation.inputs.each { input -> //对于class字节码,需要处理 input.directoryInputs.each { dic -> println "dic路径 $dic.file.absolutePath" classPool.appendClassPath(dic.file.absolutePath) //插入代码 -- javassist //找到class在哪,需要遍历class findTargetClass(dic.file, dic.file.absolutePath) def nextTransform = transformInvocation.outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY) FileUtils.copyDirectory(dic.file, nextTransform) } //对jar包不处理,直接扔给下一个Transform input.jarInputs.each { jar -> println "jar包路径 $jar.file.absolutePath" classPool.appendClassPath(jar.file.absolutePath) def nextTransform = transformInvocation.outputProvider.getContentLocation(jar.name, jar.contentTypes,jar.scopes, Format.JAR) FileUtils.copyFile(jar.file, nextTransform) } } println "transform end" }
在transform函数中有一个参数TransformInvocation,能够获取输入,因为自定义transform是放在头部,所以能够获取到的就是jar包、class字节码等资源,如下:
public interface TransformInput { /** * Returns a collection of {@link JarInput}. */ @NonNull Collection<JarInput> getJarInputs(); /** * Returns a collection of {@link DirectoryInput}. */ @NonNull Collection<DirectoryInput> getDirectoryInputs(); }
2.3.1 Jar包处理
对于jar包,我们不需要处理,直接作为输出扔给下一级的transform处理,那么如何获取到输出,就是通过TransformInvocation获取TransformOutputProvider,获取输出文件的位置,将jar包拷贝进去即可
//对jar包不处理,直接扔给下一个Transform input.jarInputs.each { jar -> println "jar包路径 $jar.file.absolutePath" classPool.appendClassPath(jar.file.absolutePath) def nextTransform = transformInvocation.outputProvider.getContentLocation(jar.name, jar.contentTypes,jar.scopes, Format.JAR) FileUtils.copyFile(jar.file, nextTransform) }
2.3.2 字节码处理
对于字节码处理,transform拿到的就是javac文件夹下的全部class文件
通过日志打印就能得知,只从这个位置取class文件
//对于class字节码,需要处理 input.directoryInputs.each { dic -> println "dic路径 $dic.file.absolutePath" classPool.appendClassPath(dic.file.absolutePath) //插入代码 -- javassist //找到class在哪,需要遍历class findTargetClass(dic.file, dic.file.absolutePath) def nextTransform = transformInvocation.outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY) FileUtils.copyDirectory(dic.file, nextTransform) }
在拿到classes文件夹根目录之后,只需要递归遍历这个文件夹,然后拿到全部的class文件,执行代码插入
/** * 递归查找class文件 * @param file classes文件夹 * @param fileName ../build/javac/debug/classes 路径名 */ private void findTargetClass(File file, String fileName) { //递归查找 if (file.isDirectory()) { file.listFiles().each { findTargetClass(it, fileName) } } else { //如果是文件 modify(file, fileName) } }
递归查找,我们拿本小节开始的那个图,如果拿到了BuildConfig.class文件,那么就需要获取当前字节码文件的全类名,然后从字节码池子中获取这个字节码信息
/** * 获取字节码文件全类名 * @param file BuildConfig.class * @param fileName ../build/javac/debug/classes 路径名 */ private void modify(File file, String fileName) { def fullName = file.absolutePath if (!fullName.endsWith(SdkConstants.DOT_CLASS)) { return } if (fileName.contains("BuildConfig.class") || fileName.contains("R")) { return } //获取当前class的全类名 com.tal.demo02.MainActivity.class def temp = fullName.replace(fileName, "").replace("/", ".") def className = temp.replace(SdkConstants.DOT_CLASS, "").substring(1) println "className $className" //从字节码池中找到ctClass def ctClass = classPool.get(className) if (className.contains("com.tal.demo02")) { //如果是在当前这个包名下的类,才会执行插桩操作 insertCode(ctClass, fileName) } }
怎么获取字节码文件的全类名,其实这里是用了一个取巧的方式,因此我们能拿到字节码文件所在的绝对路径,然后把classes文件夹路径去掉,将 / 替换为 . ,然后再把.class后缀去掉,就拿到了全类名。
2.4 Javassist织入代码
前面我们已经拿到了字节码的全类名,那么就可以从Javassist提供的ClassPool字节码池中,通过全类名获取CtClass,CtClass包含了当前字节码的全部信息,可以通过类似反射的方式,来获取方法、参数等属性,加以构造
2.4.1 ClassPool
ClassPool可以看做是一个字节码池,在ClassPool中维护了一个Hashtable,key为类的名字也就是全类名,通过全类名能够获取CtClass
public ClassPool(ClassPool parent) { this.classes = new Hashtable(INIT_HASH_SIZE); this.source = new ClassPoolTail(); this.parent = parent; if (parent == null) { CtClass[] pt = CtClass.primitiveTypes; for (int i = 0; i < pt.length; ++i) classes.put(pt[i].getName(), pt[i]); } this.cflow = null; this.compressCount = 0; clearImportedPackages(); }
在遍历输入文件的时候,我们把字节码的路径添加到ClassPool中,那么在查找的时候(调用get方法),其实就是从这个路径下查找字节码文件,如果查找到了就返回CtClass
classPool.appendClassPath(jar.file.absolutePath)
2.4.2 CtClass
通过CtClass能够像使用反射的方式那样获取方法CtMethod
private void insertCode(CtClass ctClass, String fileName) { //拿到了这个类,需要反射获取方法,在某些方法下面加 try { def method = ctClass.getDeclaredMethod("getClassId") if(method != null){ //在这个方法之前插入 method.insertBefore("if(a > 0){\n" + " \n" + " return \"\";\n" + " }") ctClass.writeFile(fileName) } }catch(Exception e){ }finally{ ctClass.detach() } }
通过CtMethod可以设置,在方法之前、方法之后、或者方法中某个行号中插入代码,最终通过CtClass的writeFile方法,将字节码重新规整,最终像处理Jar文件一样,将处理的文件交给下一级的transform处理。
最终可以看一下效果,在MainActivity中一个getClassId方法,一开始只是返回了id_0009989799,我们将一部分代码织入后,字节码变成下面的样子。
public String getClassId() { return this.a > 0 ? "" : "id_0009989799"; }
所以,美团Robust在热修复时,是以同样的方式(美团采用的是ASM字节码插桩,本文使用的是Javassist),在每个方法中织入了一段判断逻辑代码,当线上出现问题之后,通过某种方式使得代码执行这个判断逻辑,实现了即时修复
以上就是Android进阶从字节码插桩技术了解美团热修复实例详解的详细内容,更多关于Android 美团热修复的资料请关注脚本之家其它相关文章!