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

什么是插桩?

插桩就是将一段代码插入或者替换原本的代码。字节码插桩顾名思义就是在我们编写的源码编译成字节码(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 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<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 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系列—字节码指令
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容