什么是插桩?
插桩就是将一段代码插入或者替换原本的代码。字节码插桩顾名思义就是在我们编写的源码编译成字节码(Class)后,在Android下生成dex之前修改Class文件,修改或者增强原有代码逻辑的操作。
QQ空间曾经发布的《热修复解决方案》中利用 Javaassist库实现向类的构造函数中插入一段代码解决 CLASS_ISPREVERIFIED问题。包括了Instant Run的实现以及参照Instant Run实现的热修复美团Robus等都利用到了插桩技术。
字节码操作框架
QQ空间使用了 Javaassist 来进行字节码插桩,除了 Javaassist 之外还有一个应用更为广泛的 ASM 框架同样也是字节码操作框架,Instant Run包括 AspectJ 就是借助 ASM来实现各自的功能。
字节码操作框架的作用在于生成或者修改Class文件,因此在Android中字节码框架本身是不需要打包进入APK的,只有其生成/修改之后的Class才需要打包进入APK中。它的工作时机为Android打包流程中的生成Class之后,打包dex之前。
Android打包流程图:
通过上图可知,只要在图中红色箭头处拦截(生成class文件之后,dex文件之前),就可以拿到当前应用程序中所有的.class文件,再去借助ASM之类的库,就可以遍历这些.class文件中所有方法,再根据一定的条件找到需要的目标方法,最后进行修改并保存,就可以插入指定代码。
ASM 使用
ASM是一个字节码操作库,它可以直接修改已经存在的class文件或者生成class文件。ASM提供了一些便捷的功能来操作字节码内容。与其它字节码操作框架(比如:AspectJ等)相比,ASM更偏向于底层,它是直接操作字节码的,在设计上相对更小、更快,所以在性能上更好,而且几乎可以任意修改字节码。
字节码查看方式
由于class文件本质是16进制数据,所以任意的16进制编辑器都可以查看,如以下方式:
- 可以通过16进制编辑器查看: 010 Editor
- 终端命令行
#打开class文件:
vim xx.class
#然后输入,就可以显示16进制的class文件了
:%!xxd
#字节码数据对应的指令可以通过javap指令查看
javap -v xx.class
- Android Studio 插件 ASM Bytecode Viewer 快捷转换字节码
ASM Bytecode Viewer 是直接查看字节码,没有 ASM Bytecode Outline 方便,它可以直接查看由 ASM API 写好的代码,可以复制使用。
ASM可以直接从 jcenter()仓库中引入,进入 https://bintray.com/ 搜索 org.ow2.asm
ASM Core API提供了3个类来操作字节码,分别是:
- ClassReader : 对具体的class文件进行读取与解析;
- ClassWriter : 将修改后的class文件通过文件流的方式覆盖掉原来的class文件,从而实现class修改;
- ClassVisitor : 可以访问class文件的各个部分,比如方法、变量、注解等,这也是修改原代码的地方。
注意:ClassReader解析class文件过程中,解析到某个结构就会通知到ClassVisitor内部的相应方法(比如:解析到方法时,就会回调ClassVisitor.visitMethod方法)。
Demo (插桩式时长统计)
1. 创建自定义Gradle插件 ,并进行引用,插件中build.gradle文件配置如下
apply plugin: 'java'
dependencies {
implementation gradleApi() // 必须
// 如果要使用android的API,需要引用这个,实现Transform的时候会用到
implementation 'com.android.tools.build:gradle:3.5.0'
// 导入ASM
implementation 'org.ow2.asm:asm:7.2'
implementation 'org.ow2.asm:asm-commons:7.2'
}
repositories {
google() // gradle 必须
jcenter()
mavenCentral() // 必须
}
// 指定编码,Java方式容易有乱码现象
tasks.withType(JavaCompile) {
options.encoding = "UTF-8"
}
2. 在插件中注册监听,如下:
public class MyPlugin implements Plugin {
@Override
public void apply(Project project) {
// 注册 Transform, AppExtension 依赖 gradle,所以该模块需要导入 gradle
AppExtension appExtension = project.getExtensions().getByType(AppExtension.class);
appExtension.registerTransform(new MyTransform());
}
}
3. 在自定义 Transform 中进行扫描所有类文件
public class MyTransform extends Transform {
/** 当前Transform名称 */
@Override
public String getName() {
return MyTransform.class.getSimpleName();
}
/** 输入文件类型,有CLASSES和RESOURCES */
@Override
public Set getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
/**
* 指Transform要操作内容的范围,官方文档Scope有7种类型:
*
* EXTERNAL_LIBRARIES 只有外部库
* PROJECT 只有项目内容
* PROJECT_LOCAL_DEPS 只有项目的本地依赖(本地jar)
* PROVIDED_ONLY 只提供本地或远程依赖项
* SUB_PROJECTS 只有子项目。
* SUB_PROJECTS_LOCAL_DEPS 只有子项目的本地依赖项(本地jar)。
* TESTED_CODE 由当前变量(包括依赖项)测试的代码
* SCOPE_FULL_PROJECT 整个项目
*/
@Override
public Set super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}
/** 指明当前Transform是否支持增量编译 */
@Override
public boolean isIncremental() {
return false;
}
/** transform进行干预文件 */
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException,
InterruptedException, IOException {
super.transform(transformInvocation);
// inputs中是传过来的输入流,其中有两种格式,一种是jar包格式一种是目录格式。
Collection inputs = transformInvocation.getInputs();
// 获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做不然编译会报错
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
// 循环遍历输入流
for (TransformInput input : inputs) {
// 处理Jar中的class文件
for (JarInput jarInput : input.getJarInputs()) {
File dest = outputProvider.getContentLocation(
jarInput.getName(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
// 将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyFile(jarInput.getFile(), dest);
}
// 处理文件目录下的class文件
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
// 处理文件目录下的class文件
handleDirectoryInput(directoryInput, outputProvider);
}
}
}
/** 临时文件集合 */
private List mTemporaryFiles = new ArrayList<>();
/** 处理文件目录下的class文件 */
private void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) throws IOException {
// 列出目录所有文件(包含子文件夹,子文件夹内文件)
File dir = directoryInput.getFile();
// 判断是否为目录
if (directoryInput.getFile().isDirectory()) {
// 查找目录下面所有的文件
mTemporaryFiles.clear();
traverseToFindFiles(dir);
// 遍历所有文件
for (File file : mTemporaryFiles) {
// 处理相应文件
processingTheCorrespondingFile(file);
}
}
// 判断是否为文件
else if (dir.isFile()) {
// 处理相应文件
processingTheCorrespondingFile(dir);
} else {
return;
}
// Transform 拷贝文件到 transforms 目录
File dest = outputProvider.getContentLocation(
directoryInput.getName(),
directoryInput.getContentTypes(),
directoryInput.getScopes(),
Format.DIRECTORY);
// 将修改过的字节码copy到dest,实现编译期间干预字节码
FileUtils.copyDirectory(directoryInput.getFile(), dest);
}
/** 处理相应文件 */
private void processingTheCorrespondingFile(File file) throws IOException {
// 获取当前文件名称
String fileName = file.getName();
// 判断当前文件是否符合要求
if (checkClassFile(fileName)) {
// 打印当前符合条件的文件名称
System.out.println("符合条件的类:" + fileName);
// 准备待分析的class,进行ASM处理
FileInputStream fis = new FileInputStream(file);
// 对class文件进行读取与解析
ClassReader classReader = new ClassReader(fis);
// 对class文件的写入
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
// 访问class文件相应的内容,解析到某一个结构就会通知到ClassVisitor的相应方法
ClassVisitor classVisitor = new LifecycleClassVisitor(classWriter);
// 依次调用 ClassVisitor接口的各个方法
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
// toByteArray方法会将最终修改的字节码以 byte 数组形式返回。
byte[] bytes = classWriter.toByteArray();
// 通过文件流写入方式覆盖掉原先的内容,实现class文件的改写。
// FileOutputStream outputStream = new FileOutputStream( file.parentFile.absolutePath + File.separator + fileName)
// 这个地址在javac目录下
FileOutputStream outputStream = new FileOutputStream(file.getPath());
// 写入流
outputStream.write(bytes);
// 关闭流
outputStream.close();
}
}
/** 遍历查找问题 */
private void traverseToFindFiles(File dir) {
// 获取所有目录
File[] files = dir.listFiles();
// 遍历所有目录节点
for (File file : files) {
// 判断是否为目录
if (file.isDirectory()) {
// 若是目录,则递归该目录下的文件
traverseToFindFiles(file);
}
// 判断是否为文件
else if (file.isFile()) {
// 若是文件,载入集合
mTemporaryFiles.add(file);
}
}
}
/** 检查class文件是否符合条件 */
private boolean checkClassFile(String name) {
return name.endsWith("Activity.class");
}
}
4. 进行代码插入(插桩)
==LifecycleClassVisitor== 类
public class LifecycleClassVisitor extends ClassVisitor {
private String className;
public LifecycleClassVisitor(ClassVisitor cv) {
/**
* 参数1:ASM API版本,源码规定只能为4,5,6,7
* 参数2:ClassVisitor 不能为 null
*/
super(Opcodes.ASM7, cv);
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
System.out.println("1 ===========================================================");
System.out.println("name:" + name + " superName:" + superName + " signature:" + signature + " interfaces:" + interfaces);
this.className = name;
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
System.out.println("2 ===========================================================");
System.out.println("name:" + name + " desc:" + desc + " signature:" + signature + " exceptions:" + exceptions);
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
// name为方法名,desc为描述(签名), 处理方法
return new LifecycleMethodVisitor(mv, className, name);
}
@Override
public void visitEnd() {
super.visitEnd();
}
}
==LifecycleMethodVisitor== 类
public class LifecycleMethodVisitor extends MethodVisitor {
/** 当前类名称 */
private String className;
/** 当前方法名称 */
private String methodName;
/** 当前是否为注解方法 */
private boolean mInject;
public LifecycleMethodVisitor(MethodVisitor methodVisitor, String className, String methodName) {
super(Opcodes.ASM6, methodVisitor);
this.className = className;
this.methodName = methodName;
}
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
// 判断是否为指定注解类
// if (Type.getDescriptor(InjectTimeStatistics.class).equals(desc)) {
// System.out.println(desc);
// mInject = true;
// }
// 也可以判断某个系列的注解
if (desc.contains("InjectTimeStatistics")) {
mInject = true;
}
return super.visitAnnotation(desc, visible);
}
/** 方法执行前插入 */
@Override
public void visitCode() {
super.visitCode();
if (mInject) {
mv.visitLdcInsn(className + " -> TAG");
mv.visitLdcInsn("\u5f00\u59cb\u65f6\u95f4:" + System.currentTimeMillis());
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);
}
}
/** 方法执行后插入 */
@Override
public void visitInsn(int opcode) {
if (opcode == Opcodes.RETURN && mInject) {
mv.visitLdcInsn(className + " -> TAG");
mv.visitLdcInsn("\u7ed3\u675f\u65f6\u95f4:" + System.currentTimeMillis());
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);
}
super.visitInsn(opcode);
}
@Override
public void visitEnd() {
super.visitEnd();
}
}
5. 然后Rebuild Project,这个时候可以在build日志中看到输出的日志
6. 最后在build目录下找Javac目录查看已经插桩好的类文件
注:这里没有进行时间字段的定义,随便插入的时间,忽略就可以了!
点击下载 Demo
总结
1、ASM框架入门并不难,但是也不简单,对基础要求比较高,至少要掌握APK打包流程、自定义Gradle插件、Transform API以及AOP思想
2、使用感受
缺点:如果用过其它AOP框架,比如AspectJ,再来用ASM,会感觉到很难受、不好用,因为太复杂了,编写一个ASM工程对代码量怕是其它aop框架的几倍。原因:它是直接操作字节码指令的,这可是直接和JVM虚拟机打交道的底层内容,能不难吗?
优点:足够强大,几乎所有的CRUD操作都可以完成。由于是直接操作字节码,所以在效率上会比其它框架更高,注意:性能上没什么影响,因为是在编译期完成的。很多上层框架是用ASM作为底层技术的,比如Groovy、cglib等
AspectJ 使用
Github 仓库
后面再单独一章进行总结!
参考:
- 注解深入浅出之-注解的使用
- Android程序员的硬通货——ASM字节码插桩
- Android ASM快速入门
- ASM框架学习(二)-ClassVisitor
- ASM框架学习(三)-FieldVisitor和MethodVisitor
- Java ASM与字节码
- 认识 .class 文件的字节码结构
- Jvm系列—字节码指令