注解 - 插桩,编译后处理筛选

什么是插桩?

插桩就是将一段代码插入或者替换原本的代码。字节码插桩顾名思义就是在我们编写的源码编译成字节码(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打包流程图:

注解 - 插桩,编译后处理筛选_第1张图片
Image text

通过上图可知,只要在图中红色箭头处拦截(生成class文件之后,dex文件之前),就可以拿到当前应用程序中所有的.class文件,再去借助ASM之类的库,就可以遍历这些.class文件中所有方法,再根据一定的条件找到需要的目标方法,最后进行修改并保存,就可以插入指定代码。


ASM 使用

ASM是一个字节码操作库,它可以直接修改已经存在的class文件或者生成class文件。ASM提供了一些便捷的功能来操作字节码内容。与其它字节码操作框架(比如:AspectJ等)相比,ASM更偏向于底层,它是直接操作字节码的,在设计上相对更小、更快,所以在性能上更好,而且几乎可以任意修改字节码。

字节码查看方式

由于class文件本质是16进制数据,所以任意的16进制编辑器都可以查看,如以下方式:

  1. 可以通过16进制编辑器查看: 010 Editor
  2. 终端命令行
#打开class文件:
vim xx.class
#然后输入,就可以显示16进制的class文件了
:%!xxd
#字节码数据对应的指令可以通过javap指令查看
javap -v xx.class
  1. 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 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目录查看已经插桩好的类文件
注解 - 插桩,编译后处理筛选_第2张图片
Image text

注:这里没有进行时间字段的定义,随便插入的时间,忽略就可以了!

点击下载 Demo

总结

1、ASM框架入门并不难,但是也不简单,对基础要求比较高,至少要掌握APK打包流程、自定义Gradle插件、Transform API以及AOP思想

2、使用感受

缺点:如果用过其它AOP框架,比如AspectJ,再来用ASM,会感觉到很难受、不好用,因为太复杂了,编写一个ASM工程对代码量怕是其它aop框架的几倍。原因:它是直接操作字节码指令的,这可是直接和JVM虚拟机打交道的底层内容,能不难吗?

优点:足够强大,几乎所有的CRUD操作都可以完成。由于是直接操作字节码,所以在效率上会比其它框架更高,注意:性能上没什么影响,因为是在编译期完成的。很多上层框架是用ASM作为底层技术的,比如Groovy、cglib等


AspectJ 使用

Github 仓库

后面再单独一章进行总结!

参考:

  1. 注解深入浅出之-注解的使用
  2. Android程序员的硬通货——ASM字节码插桩
  3. Android ASM快速入门
  4. ASM框架学习(二)-ClassVisitor
  5. ASM框架学习(三)-FieldVisitor和MethodVisitor
  6. Java ASM与字节码
  7. 认识 .class 文件的字节码结构
  8. Jvm系列—字节码指令

你可能感兴趣的:(注解 - 插桩,编译后处理筛选)