美文网首页
注解的使用(二):插桩,编译后处理筛选

注解的使用(二):插桩,编译后处理筛选

作者: bug喵喵 | 来源:发表于2020-12-04 15:17 被阅读0次

什么是插桩?

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

image

通过上图可知,只要在图中红色箭头处拦截(生成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<Project> {
    @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<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    /**
     * 指Transform要操作内容的范围,官方文档Scope有7种类型:
     * <p>
     * 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<TransformInput> 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<File> 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目录查看已经插桩好的类文件
image

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

点击下载 Demo

总结

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

2、使用感受

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

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

相关文章

网友评论

      本文标题:注解的使用(二):插桩,编译后处理筛选

      本文链接:https://www.haomeiwen.com/subject/iuvbwktx.html