一、什么是插桩
QQ空间曾经发布的《热修复解决方案》中利用 Javaassist库实现向类的构造函数中插入一段代码解决 CLASS_ISPREVERIFIED 问题。包括了 Instant Run 的实现以及参照 Instant Run 实现的热修复美团Robus等都利用到了插桩技术。
插桩就是将一段代码插入或者替换原本的代码。字节码插桩顾名思义就是在我们编写的源码编译成字节码(Class)后,在Android下生成dex之前修改Class文件,修改或者增强原有代码逻辑的操作。
![94a3ec12bf0ff7c12af399be9984d9c4.png](https://i-blog.csdnimg.cn/blog_migrate/c30026f598fb870499641c1ab983c2e3.jpeg)
![3cc60e2613eee89a37106d4be95138d3.png](https://i-blog.csdnimg.cn/blog_migrate/9abf0a1f048b2b5b0bdb1a8142611f30.jpeg)
我们需要查看方法执行耗时,如果每一个方法都需要自己手动去加入这些内容,当不需要时也需要一个个删去相应的代码。
一个、两个方法还好,如果有10个、20个得多麻烦!所以可以利用注解来标记需要插桩的方法,结合编译后操作字节码来帮助我们自动插入,当不需要时关掉插桩即可。
这种AOP思想让我们只需要关注插桩代码本身。
二、字节码操作框架
上面我们提到QQ空间使用了 Javaassist来进行字节码插桩,除了 Javaassist之外还有一个应用更为广泛的 ASM框架同样也是字节码操作框架,Instant Run包括 AspectJ就是借助 ASM来实现各自的功能。
我们非常熟悉的JSON格式数据是基于文本的,我们只需要知道它的规则就能够轻松的生成、修改JSON数据。
同样的Class字节码也有其自己的规则(格式)。
操作JSON可以借助GSON来非常方便的生成、修改JSON数据。而字节码Class,同样可以借助Javassist/ASM来实现对其修改。
![cf8eed55b4602fa0e3055efa0cb5c8c6.png](https://i-blog.csdnimg.cn/blog_migrate/c20648a547dc9732f36e313d7c1c68ad.jpeg)
字节码操作框架的作用在于生成或者修改Class文件,因此在Android中字节码框架本身是不需要打包进入APK的,只有其生成/修改之后的Class才需要打包进入APK中。
它的工作时机在上图Android打包流程中的生成Class之后,打包dex之前。
三、ASM的使用
由于 ASM具有相对于 Javassist更好的性能以及更高的灵活行,我们这篇文章以使用ASM为主。在真正利用到Android中之前,我们可以先在 Java程序中完成对字节码的修改测试。
3.1、在AS中引入ASM
ASM可以直接从 jcenter()仓库中引入,所以我们可以进入:https://bintray.com/进行搜索
![84a2a2120a6863f80e12aca525de2323.png](https://i-blog.csdnimg.cn/blog_migrate/ad6733afcc437c5b6316ce44787c0ea7.jpeg)
点击图中标注的工件进入,可以看到最新的正式版本为:7.1。
![389e35e3f9b24f24cf40c00d998200fb.png](https://i-blog.csdnimg.cn/blog_migrate/429071a352772690e4c00156a4d16645.jpeg)
因此,我们可以在AS中加入:
![0686a4f349cc2c0f7c039083bafb7a5c.png](https://i-blog.csdnimg.cn/blog_migrate/f5419670284b787c42470adc79d91a6c.jpeg)
同时,需要注意的是:我们使用 testImplementation引入,这表示我们只能在Java的单元测试中使用这个框架,对我们Android中的依赖关系没有任何影响。
AS中使用gradle的Android工程会自动创建Java单元测试与Android单元测试。测试代码分别在test与androidTest。
3.2、准备待插桩Class
在 test/java下面创建一个Java类:
public class InjectTest { public static void main(String[] args) { }}
由于我们操作的是字节码插桩,所以可以进入 test/java下面使用 javac对这个类进行编译生成对应的class文件。
javac InjectTest.java
3.3、执行插桩
因为 main方法中没有任何输出代码,我们输入命令:javaInjectTest执行这个Class不会有任何输出。那么我们接下来利用 ASM,向 main方法中插入一开始图中的记录函数执行时间的日志输出。
在单元测试中写入测试方法
/*** 1、准备待分析的class*/FileInputStream fis = new FileInputStream("xxxxx/test/java/InjectTest.class");/*** 2、执行分析与插桩*///class字节码的读取与分析引擎ClassReader cr = new ClassReader(fis);// 写出器 COMPUTE_FRAMES 自动计算所有的内容,后续操作更简单ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);//分析,处理结果写入cw EXPAND_FRAMES:栈图以扩展格式进行访问cr.accept(new ClassAdapterVisitor(cw), ClassReader.EXPAND_FRAMES);/*** 3、获得结果并输出*/byte[] newClassBytes = cw.toByteArray();File file = new File("xxx/test/java2/");file.mkdirs();FileOutputStream fos = new FileOutputStream("xxx/test/java2/InjectTest.class");fos.write(newClassBytes);fos.close();
关于ASM框架本身的设计,我们这里先不讨论。上面的代码会获取上一步生成的class,然后由ASM执行完插桩之后,将结果输出到 test/java2目录下。其中关键点就在于第2步中,如何进行插桩。
把class数据交给 ClassReader,然后进行分析,类似于XML解析,分析结果会以事件驱动的形式告知给accept的第一个参数 ClassAdapterVisitor。
public class ClassAdapterVisitor extends ClassVisitor { public ClassAdapterVisitor(ClassVisitor cv) { super(Opcodes.ASM7, cv); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { System.out.println("方法:" + name + " 签名:" + desc); MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); return new MethodAdapterVisitor(api,mv, access, name, desc); }}
分析结果通过 ClassAdapterVisitor获得,一个类中会存在方法、注解、属性等,因此 ClassReader会将调用 ClassAdapterVisitor中对应的 visitMethod、 visitAnnotation、 visitField这些 visitXX方法。
我们的目的是进行函数插桩,因此重写 visitMethod方法,在这个方法中我们返回一个 MethodVisitor方法分析器对象。一个方法的参数、注解以及方法体需要在 MethodVisitor中进行分析与处理。
package com.enjoy.asminject.example;import com.enjoy.asminject.ASMTest;import org.objectweb.asm.AnnotationVisitor;import org.objectweb.asm.MethodVisitor;import org.objectweb.asm.Type;import org.objectweb.asm.commons.AdviceAdapter;import org.objectweb.asm.commons.Method;/*** AdviceAdapter: 子类* 对methodVisitor进行了扩展, 能让我们更加轻松的进行方法分析*/public class MethodAdapterVisitor extends AdviceAdapter { private Boolean inject; protected MethodAdapterVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) { super(api, methodVisitor, access, name, descriptor); } /*** 分析方法上面的注解* 在这里干嘛???*
* 判断当前这个方法是不是使用了injecttime,如果使用了,我们就需要对这个方法插桩* 没使用,就不管了。** @param desc* @param visible* @return*/ @Override public AnnotationVisitor visitAnnotation(String desc, Boolean visible) { if (Type.getDescriptor(ASMTest.class).equals(desc)) { System.out.println(desc); inject = true; } return super.visitAnnotation(desc, visible); } private int start; @Override protected void onMethodEnter() { super.onMethodEnter(); if (inject) { //执行完了怎么办?记录到本地变量中 invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J")); start = newLocal(Type.LONG_TYPE); //创建本地 LONG类型变量 //记录 方法执行结果给创建的本地变量 storeLocal(start); } } @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode); if (inject){ invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J")); int end = newLocal(Type.LONG_TYPE); storeLocal(end); getStatic(Type.getType("Ljava/lang/System;"),"out",Type.getType("Ljava/io" + "/PrintStream;")); //分配内存 并dup压入栈顶让下面的INVOKESPECIAL 知道执行谁的构造方法创建StringBuilder newInstance(Type.getType("Ljava/lang/StringBuilder;")); dup(); invokeConstructor(Type.getType("Ljava/lang/StringBuilder;"),new Method("","()V")); visitLdcInsn("execute:"); invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"),new Method("append","(Ljava/lang/String;)Ljava/lang/StringBuilder;")); //减法 loadLocal(end); loadLocal(start); math(SUB,Type.LONG_TYPE); invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"),new Method("append","(J)Ljava/lang/StringBuilder;")); invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"),new Method("toString","()Ljava/lang/String;")); invokeVirtual(Type.getType("Ljava/io/PrintStream;"),new Method("println","(Ljava/lang/String;)V")); } }}
MethodAdapterVisitor继承自 AdviceAdapter,其实就是 MethodVisitor 的子类, AdviceAdapter封装了指令插入方法,更为直观与简单。
上述代码中 onMethodEnter进入一个方法时候回调,因此在这个方法中插入指令就是在整个方法最开始加入一些代码。我们需要在这个方法中插入 longs=System.currentTimeMillis();。在 onMethodExit中即方法最后插入输出代码。
@Overrideprotected void onMethodEnter() { super.onMethodEnter(); if (inject) { //执行完了怎么办?记录到本地变量中 invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J")); start = newLocal(Type.LONG_TYPE); //创建本地 LONG类型变量 //记录 方法执行结果给创建的本地变量 storeLocal(start); }}
这里面的代码怎么写?其实就是 longs=System.currentTimeMillis();这句代码的相对的指令。我们可以先写一份代码
void test(){ //插入的代码 long s = System.currentTimeMillis(); /*** 方法实现代码....*/ //插入的代码 long e = System.currentTimeMillis(); System.out.println("execute:"+(e-s)+" ms.");}
然后使用 javac编译成Class再使用 javap-c查看字节码指令。也可以借助插件来查看,就不需要我们手动执行各种命令。
![f5b6f403bf9970bdc59294e5b5677952.png](https://i-blog.csdnimg.cn/blog_migrate/f682ea5b3558b3adac0269eb16b778a3.jpeg)
安装完成之后,可以在需要插桩的类源码中点击右键:
![bcde68feda230ea53d5b07dcc284805e.png](https://i-blog.csdnimg.cn/blog_migrate/eed4522aa6f982c2f33d3b3c188f9c0a.jpeg)
点击 ASM Bytecode Viewer 之后会弹出
![ea0a54bc41c3fdde9c0a4d7e940a1116.png](https://i-blog.csdnimg.cn/blog_migrate/0430a8b625232a766c0c69c8648c5fd4.jpeg)
所以第20行代码: longs=System.currentTimeMillis();会包含两个指令: INVOKESTATIC与 LSTORE。
再回到 onMethodEnter方法中
@Overrideprotected void onMethodEnter() { super.onMethodEnter(); if (inject) { //invokeStatic指令,调用静态方法 invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J")); //创建本地 LONG类型变量 start = newLocal(Type.LONG_TYPE); //store指令 将方法执行结果从操作数栈存储到局部变量 storeLocal(start); }}
而 onMethodExit也同样根据指令去编写代码即可。最终执行完插桩之后,我们就可以获得修改后的class数据。
四、Android中的实现
在Android中实现,我们需要考虑的第一个问题是 如何获得所有的Class文件来判断是否需要插桩 。Transform就是干这件事情的。