美文网首页Android ASM
Android ASM框架详解

Android ASM框架详解

作者: wanderingGuy | 来源:发表于2019-12-04 20:18 被阅读0次

    前言

    在上篇文章中,我们以AspectJ为引子介绍了AOP及其设计思想,传送门Android AspectJ详解,我们用AspectJ可以方便的实现一些简单的代码织入,而不需要关心底层字节码的实现,而ASM则偏向底层一些,ASM提供的API完全是面向Java字节码编程,如果你对Java字节码的结构和原理不甚了解,很难直接上手。

    但正是因为ASM的原理是直接操作字节码,那么理论上对字节码的任意修改,都可以用ASM实现。因为无论是哪种AOP技术,最终跑在JVM上的都是class字节码。

    而AspectJ所处的位置更偏向应用层,它将操作字节码这件事封装到内部,给外部提供的就是一些筛选切面的注解,并在这个切面下编写java代码,最终是通过AspectJ的ajc编译器实现代码的织入。

    文中的ASM项目示例戳这里

    ASM简介

    ASM是一个字节码操作框架,可用来动态生成字节码或者对现有的类进行增强。ASM可以直接生成二进制的class字节码,也可以在class被加载进虚拟机前动态改变其行为,比如方法执行前后插入代码,添加成员变量,修改父类,添加接口等等。

    ASM官方网站

    ASM通过访问者模式依次遍历class字节码中的各个部分,并不断的通过回调的方式通知上层(这有点像SAX解析xml的过程),上层可在业务关心的某个访问点,修改原有逻辑。

    之所以可以这么做,是因为java字节码是按照严格的JVM规范生成二进制字节流,ASM只是按照这个规范对java字节码的一次解释,将晦涩难懂的字节码背后对应的JVM指令一条条的转换成ASM API。

    比如,一句简单的日志打印

    Log.d("tag", " onCreate");
    

    转换成ASM API将会是下面这样:

    mv.visitLdcInsn("tag");
    mv.visitLdcInsn("onCreate");
    mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
    mv.visitInsn(POP);
    

    如果你稍懂JVM汇编指令的话,可以看出大致意思。

    • 加载常量"tag"入栈
    • 加载常量"onCreate"入栈
    • 执行Log的静态方法d
    • 方法调用出栈

    然后我们通过javap指令查看一下这行代码对应的JVM汇编指令,如下图:

    字节码.png

    这样是不是就很清楚了?就是这四条指令,ASM做的就是按照JVM的规范,生成代码对应的JVM指令并写入字节码文件。

    Class字节码结构

    上面的例子,用到了javap指令,因此我们首先需要对java字节码结构做一个大致的介绍,这样整个ASM流程最底层的原理就算清楚了。

    我们通过javac指令将一个java源文件编译成.class的字节码文件,这个文件直接通过文本编辑器打开将会看到全是16进制的字节码。

    class Demo {
        int i = 0;
        public void test() {
            i += 1;
        }
    }
    
    class字节码.png

    class字节码结构组成结构如图。

    字节码组成结构.png

    各个部分占用字节大小:


    class组成结构.png

    其中u1、u2、u4、u8分别代表1个字节、2个字节、4个字节、8个字节的无符号数。无符号数用于描述数字、索引引用、数量值、字符串值。

    cp_info、field_info这些以info结尾的是表,一个表由一个或多个元素组成,这里元素可以是常量、字段、方法等等。

    • Magic魔数:该项存放了一个 Java 类文件的魔数(magic number)和版本信息。一个 Java 类文件的前 4 个字节被称为它的魔数。每个正确的 Java 类文件都是以 0xCAFEBABE 开头的,这样保证了 Java 虚拟机能很轻松的分辨出 Java 文件和非 Java 文件。
    • Version:包括主版本号和次版本号,该项存放了 Java 类文件的版本信息,类文件的版本信息让虚拟机知道如何去读取并处理该类文件。
    • Constant Pool:该项存放了类中各种文字字符串、类名、方法名和接口名称、final 变量以及对外部类的引用信息等常量。虚拟机必须为每一个被装载的类维护一个常量池,常量池中存储了相应类型所用到的所有类型、字段和方法的符号引用。常量池的大小平均占到了整个类大小的 60% 左右。
    • Access_flag:该项指明了该文件中定义的是类还是接口(一个 class 文件中只能有一个类或接口),同时还指名了类或接口的访问标志,如 public,private, abstract 等信息。
    • This Class:指向表示该类全限定名称的字符串常量的指针。
    • Super Class:指向表示父类全限定名称的字符串常量的指针。
    • Interfaces:一个指针数组,存放了该类或父类实现的所有接口名称的字符串常量的指针。
    • Fields:该项对类或接口中声明的字段进行了细致的描述。需要注意的是,fields 列表中仅列出了本类或接口中的字段,并不包括从超类和父接口继承而来的字段。
    • Methods:该项对类或接口中声明的方法进行了细致的描述。例如方法的名称、参数和返回值类型等。需要注意的是,methods 列表里仅存放了本类或本接口中的方法,并不包括从超类和父接口继承而来的方法。
    • Class attributes:该项存放了在该文件中类或接口所定义的属性的基本信息。

    比如按我们Demo.class字节码的信息,cafe babe是魔数,按表顺序后面跟的四个字节0000 0034是分别是次版本和主版本,转换成10进制是52.0,查看java虚拟机版本映射关系表,52表示JDK 1.8,也就是该类是用JDK 1.8进行编译的。

    之后的两个字节0012表示常量池大小,为十进制的18,由于常量池常量下标从1开始,也就是有17个常量。

    0a00后面的内容就是第一个具体的常量信息。

    常量分为两类字面量和符号引用

    • 字面量:与Java语言层面的常量概念相近,包含文本字符串、声明为final的常量值等。
    • 符号引用:编译语言层面的概念,包括以下3类:
      • 类和接口的全限定名
      • 字段的名称和描述符
      • 方法的名称和描述符

    0a对应十进制的10,10表示MethodRef,即方法引用。

    字节码结构的后续内容较多,并不是本文重点,不再展开,除此之外还需要掌握JVM常见的指令,比如aload、invokespecial、ldc等等,感兴趣的小伙伴可参考认识 .class 文件的字节码结构,补充学习。

    但在目前,即使我们不懂这些也不妨碍我们开发,因为ASM提供了相应工具帮助我们编写ASM API代码,莫慌~~

    javap

    字节码严格遵守着JVM规范,直接读字节码文件是疯狂的事情,我们可通过javap指令可以将字节码反编译成易懂的汇编指令。

    javap -v Demo.class

    -v 表示verbose,将会打印 行号+本地变量表信息+反编译汇编代码+常量池等全部信息。

    • javap -l 行号+本地变量表
    • javap -c 反编译汇编代码Code区。

    在Android Studio中,可通过jclasslib插件查看更清晰。

    jclasslib.png

    ASM API

    ASM通过访问者模式,将类文件的内容从头到尾扫描一遍,每次扫描到相应内容时,会回调ClassVisitor内部相应的方法。

    常见的visitor如下表。

    类型 visitor
    Class ClassVisitor
    Field FieldVisitor
    Method MethodVisitor
    Annotation AnnotationVisitor

    ClassVisitor的调用顺序为:

    visit 
    visitSource? 
    visitOuterClass? 
    ( visitAnnotation | visitAttribute )*
    ( visitInnerClass | visitField | visitMethod )*
    visitEnd
    

    MethodVisitor的调用顺序为:

    visitAnnotationDefault?
    ( visitAnnotation | visitParameterAnnotation | visitAttribute )*
    ( visitCode
        ( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn |
        visitLocalVariable | visitLineNumber )*
    visitMaxs )?
    visitEnd
    

    完整的访问顺序我们可以通过时序图了解:

    Visitor时序图.png

    ClassReader/ClassWriter

    ClassReader可以方便地让我们对class文件进行读取与解析,解析到某一个结构就会通知到ClassVisitor的相应方法,比如解析到类方法时,就会回调ClassVisitor.visitMethod方法。

    我们可以通过更改ClassVisitor中相应结构方法返回值,实现对类的代码切入,比如更改ClassVisitor.visitMethod()方法的返回值MethodVisitor实例。

    通过ClassWriter的toByteArray()方法,得到class文件的字节码内容,最后通过文件流写入方式覆盖掉原先的内容,实现class文件的改写。

    我们举个例子,我们想为FragmentActivity这个类的onCreate方法中添加一段日志打印,可以按下面的步骤。

    //创建ClassReader,传入class字节码的输入流
    ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
    //创建ClassWriter,绑定classReader
    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
    //创建自定义的LifecycleClassVisitor,并绑定classWriter
    ClassVisitor cv = new LifecycleClassVisitor(classWriter)
    //接受一个实现了 ClassVisitor接口的对象实例作为参数,然后依次调用 ClassVisitor接口的各个方法
    classReader.accept(cv, EXPAND_FRAMES)
    //toByteArray方法会将最终修改的字节码以 byte 数组形式返回。
    byte[] code = classWriter.toByteArray()
    

    最终code就是修改后的字节码数组。

    我们可以将它写入文件输出到本地。

    File file = new File("Test.class");
    FileOutputStream fos = new FileOutputStream(file);
    fos.write(classFile);
    fos.close();
    

    在Android体系下我们通过Gradle Transform工具,在java代码编译成.class文件之后,.class优化为.dex文件前将代码织入。
    使用Transform需要开发一个自定义的gradle plugin,plugin的开发不是本文的核心,我们暂且跳过。

    我们只需要知道在一次transform过程中,Gradle会将本地工程中编译的代码、jar包 / aar包 / 依赖的三方库中的代码,作为输入源交由我们的插件处理,这也就是说ASM同样可以对工程外部的类进行修改或织入

    如果我们需要在指定的类,指定的方法中织入代码,需要编写相应的过滤条件,这也是相比于AspectJ而言不太方便的地方,AspectJ可通过声明切面注解完成精准的织入。

    下面举个例子,假设我们想在FragmentActivity的onCreate方法执行前打印一行日志,可以这么做。

    创建LifecycleClassVisitor类继承于ClassVisitor,复写visitMethod方法。

    public class LifecycleClassVisitor extends ClassVisitor implements Opcodes {
        ...
    
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
            //匹配FragmentActivity
            if ("android/support/v4/app/FragmentActivity".equals(this.mClassName)) {
                if ("onCreate".equals(name) ) {
                    //处理onCreate
                    return new LifecycleOnCreateMethodVisitor(mv);
                }
            }
            return mv;
        }
    }
    

    访问到onCreate这个方法时,我们需要继续自定义一个MethodVisitor,告诉ASM你想如何处理这个方法。

    根据上述的访问时序图我们知道,在方法访问开始时会回调MethodVisitor的visitCode方法,因此我们复写此方法后将会在onCreate方法开头织入代码。

    public class LifecycleOnCreateMethodVisitor extends MethodVisitor {
        ...
        @Override
        public void visitCode() {
            super.visitCode();
            //方法执行前插入
            mv.visitLdcInsn("tag");
            mv.visitLdcInsn("onCreate start");
            mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
            mv.visitInsn(POP);
        }
    
        @Override
        public void visitInsn(int opcode) {
            //方法执行后插入
            if (opcode == Opcodes.RETURN) {
                mv.visitLdcInsn("tag");
                mv.visitLdcInsn("onCreate end");
                mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
                mv.visitInsn(POP);
            }
            super.visitInsn(opcode);
    
        }
    
        @Override
        public void visitEnd() {
            super.visitEnd();
            //warn 若想在方法最后织入代码,写在这里是无效的
        }
    }
    

    这里值得注意的是若想在方法最后织入代码,写在visitEnd方法内是无效的,回调它的时候类已经访问结束了。
    我们只能迂回解决,我们知道方法执行结束前都会有一个return指令,如果你的方法返回值为void,那编译成字节码时会默认补上一个return指令。
    return指令根据返回对象的类型不同,会有不同的指令,比如:

    • areturn 返回值类型为对象类型
    • ireturn 返回值类型为int
    • lreturn 返回值类型为long

    由于我们知道onCreate方法的返回值就是空,所以我们只需要捕获这个return指令就可以了。
    这里的指令范围非常广,比如加减乘除、条件判断、aload等等,这些指令常量被封装到Opcodes类中。

    访问者模式为指令提供的回调就是visitInsn方法,因此就有了上面visitInsn方法的代码。

    由于在方法前后插入代码这种需求很常见,而上述模板代码写起来又太难看,因此ASM还提供了一个AdviceAdapter类,对一些常见的切面做了二次封装。

    如果我们用AdviceAdapter编写上述代码会变得更直观清爽。

    public class OnCreateMethodAdapter extends AdviceAdapter {
        ...
    
        @Override
        protected void onMethodEnter() {
            super.onMethodEnter();
            //方法开头织入代码
            mv.visitLdcInsn("tag");
            mv.visitLdcInsn("onCreate start");
            mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
            mv.visitInsn(POP);
    
        }
    
        @Override
        protected void onMethodExit(int opcode) {
            //方法末尾织入代码
        }
    
    }
    

    ok,到这里我们以在某个方法前后织入一段代码的例子讲完了,ASM能实现关于字节码的任何修改,其中涉及的API可以十分复杂,对于比如修改类名、添加方法等,最好通过查阅ASM官方文档完成开发。

    ASM Bytecode Outline插件

    考虑到直接使用ASM API编写JVM指令比较困难,因此官方提供了一个插件帮助我们完成API的编写。

    asm_outline.png

    我们只需要先在任意位置编写需要织入的java代码,然后便可通过这个插件生成对应的ASM代码,爱了爱了...

    ASM的优缺点

    虽然ASM很强大,但如果你使用了AspectJ之后再开看ASM,就会发现有一些新的问题。

    • 过滤类和方法需要硬编码,且不够灵活,需要对插件进行二次封装,而在AspectJ中已经封装好了切面表达式。
    • 很难实现在方法调用前后织入新的代码,而在AspectJ中一个call关键字就解决了。

    不过,ASM优点更加明显:

    • 由于直接操作的是字节码,因此相比其他框架效率更高。
    • 从ASM5开始已经支持Java8的部分语法,比如lamabda表达式。
    • 因为ASM偏向底层,很多其他的上层框架也以ASM作为其底层操作字节码的技术栈,比如Groovy、cglib。

    参考文章

    1. 大话Java字节码指令
    2. 认识 .class 文件的字节码结构
    3. Java字节码指令
    4. 通过javap命令分析java汇编指令
    5. ASM 3.0 介绍
    6. ASM Core Api 详解
    7. Java字节码增强技术探索

    相关文章

      网友评论

        本文标题:Android ASM框架详解

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