Android ASM快速入门

ASM介绍

ASM是一个字节码操作库,它可以直接修改已经存在的class文件或者生成class文件。ASM提供了一些便捷的功能来操作字节码内容。

与其它字节码操作框架(比如:AspectJ等)相比,ASM更偏向于底层,它是直接操作字节码的,在设计上相对更小、更快,所以在性能上更好,而且几乎可以任意修改字节码。


为什么要用ASM?

我们都知道任何的字节码操作库最终都是要修改.class文件的,在Android平台上通常是.class.dex阶段。下面一起来看看.class文件是什么样子:

class文件.png

左边这一堆符号就是字节码,可以看出字节码是由16进制数据组成的;而右边的部分是将16进制数转换成相对容易阅读的指令。

如何查看字节码?
由于class文件本质是16进制数据,所以任意的16进制编辑器都可以查看。这里提供2种查看方法:
1、可以通过16进制编辑器查看
010 Editor

2、终端命令行

#打开class文件:
vim xx.class

#然后输入,就可以显示16进制的class文件了
:%!xxd

字节码数据对应的指令可以通过javap指令查看

javap -v xx.class

注意:网上很多文章说这是汇编指令,这种说法是严重错误的,这绝对不是汇编指令,它只不过把16进制的字节码文件代表的意思形象的表达出来,但本质上还是字节码啊;而JVM底层操作的才是汇编指令,反汇编需要专门的软件,比如:Hopper。16进制的字节码和字节码指令的关系就类似于汇编指令和2进制的机器码一样。

字节码文件是由一个很复杂的文件格式组成,如果你感兴趣,可以参考:
认识 .class 文件的字节码结构Java字节码指令

如果你对class文件格式很了解的话,你可以直接通过编辑器修改字节码文件,但是这样的要求比较高,而且操作起来极度不变,于是ASM出现了,它对字节码指令再做一层封装,对外提供了一系列ASM API。


ASM核心API讲解

ASM Core API提供了3个类来操作字节码,分别是:

  • ClassReader:对具体的class文件进行读取与解析;

  • ClassWriter:将修改后的class文件通过文件流的方式覆盖掉原来的class文件,从而实现class修改;

  • ClassVisitor:可以访问class文件的各个部分,比如方法变量注解等,这也是修改原代码的地方。

注意:
ClassReader解析class文件过程中,解析到某个结构就会通知到ClassVisitor内部的相应方法(比如:解析到方法时,就会回调ClassVisitor.visitMethod方法);

ClassVisitor是有一定调用顺序的:

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

下面通过一个小案例来讲解ASM的使用流程:
打印Activity生命周期

start code之前,先思考几个问题:

1、如何获取所有编译后的class文件,如何拦截,并替换他们?
2、如何修改某个class文件?
3、修改完成之后,如何替换原有的class,达到hook的目的?

下面,我们就一起来解决这几个问题。

Q1:在Android开发中,字节码插桩的时机是在apk打包过程的classdex阶段;而Gradle已经给我们提供了一个工具在这个阶段做一些事情,比如修改class内容,它就是Transform API。通过Transform就可以获取到所有的.class文件,包括jar包中的,然后你可以根据自己的需求过滤出需要的.class文件。这一步非常关键,需要你掌握Gradle插件和Transform的知识,如果你还不太熟悉它们,可以参考:
Android 自定义Gradle插件的3种方式
Android Gradle Transform 详解

Q2:在上一步的Transform API中可以拿到需要处理的class文件,但是并不能知道class的具体内容和格式,所以需要借助一个工具来解析class文件结构,这个工具就是ASM,它不仅能解析class,还提供了大量的API来修改class内容,几乎所有的CRUD操作都可以完成。

Q3:在修改完class之后,只需要按照原来文件的路径,通过FileOutputStream文件流的形式去覆盖原文件即可。

下面开始代码详解,由于本文着重讲解ASM Core API相关的内容,所以Gradle插件和Transform的内容不会讲的特别细致,但是关键部分和流程还是会讲的。

1、这里用buildSrc的模式来定义插件,首先定义插件实现类
就是注册了Transform实现类而已

class CustomPlugin implements Plugin<Project>{
    @Override
    void apply(Project project) {
       AppExtension appExtension= project.extensions.getByType(AppExtension)
        //注册Transform
        appExtension.registerTransform(new MyTransform())
    }
}

2、Transform实现类
遍历class文件,查找目标class,便于后面的修改

class MyTransform extends Transform {

    @Override
    String getName() {
        return "MyTransform"
    }

    //输入文件类型,有CLASSES和RESOURCES
    @Override
    Set<QualifiedContent.ContentType> 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
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    //指明当前Transform是否支持增量编译
    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)

        //inputs中是传过来的输入流,其中有两种格式,一种是jar包格式一种是目录格式。
        def inputs = transformInvocation.getInputs()
        //获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做不然编译会报错
        def 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()) {
                 
                ...省略ASM相关操作

                File dest = outputProvider.getContentLocation(
                directoryInput.getName(),
                directoryInput.getContentTypes(),
                directoryInput.getScopes(),
                Format.DIRECTORY);
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
                FileUtils.copyDirectory(directoryInput.getFile(), dest)
            }
        }
    }
}

这里最核心的就是transform()方法了,通过它可以获取要处理的源文件。这里只是把目标源文件拷贝到目标路径。

注意:
1、由于getScopes()方法设置的是对整个项目处理,所以需要同时处理jar包和文件目录。实际上如果你设置的是PROJECT_ONLY,那可以只处理文件目录而不用管jar包;
2、文件拷贝这一步必须要做,不然会出现找不到文件的异常

到这里都还只是Transform的内容,下面把class修改的部分加上去,也就是ASM Core API相关的部分。

transform方法中,针对文件目录做如下修改,其余地方不变

  for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
        handleDirectoryInput(directoryInput, outputProvider)
  }
   /**
     * 处理文件目录下的class文件
     */
    void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {

        //列出目录所有文件(包含子文件夹,子文件夹内文件)
        directoryInput.file.eachFileRecurse { File file ->
            def fileName = file.name
            if (checkClassFile(fileName)) {
                System.out.println('filename----' + fileName)
                //对class文件进行读取与解析
                ClassReader classReader = new ClassReader(file.bytes)
                //对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.path)
                outputStream.write(bytes)
                outputStream.close()
            }
        }

               //Transform 拷贝文件到transforms目录        
                File dest = outputProvider.getContentLocation(
                directoryInput.getName(),
                directoryInput.getContentTypes(),
                directoryInput.getScopes(),
                Format.DIRECTORY);
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
                FileUtils.copyDirectory(directoryInput.getFile(), dest)
    }

    /**
     * 检查class文件是否符合条件
     * @param name
     * @return
     */
    boolean checkClassFile(String name) {
        return name.endsWith("Activity.class")
    }

代码详解:
1、通过DirectoryInput遍历所有的文件,如果有子文件夹,也会找到其包含的class文件,然后只处理其中的Activity文件(我这里是通过文件名来过滤的,可能不是很严谨,不过不影响大局,后面会说更好的方式)。

2、在找到目标Activity文件之后,就可以做字节码操作相关的事情了。先来说一下大体的流程:

  • 2.1、ClassReader:对class文件进行读取与解析;
  • 2.2、ClassWriter:对class文件写入;
  • 2.3、ClassVisitor:可以访问class文件的各个部分,在ClassReader解析到某一个结构就会通知到ClassVisitor的相应方法,比如解析到方法会回调ClassVisitor#visitMethod,其它属性、注解等也有对应的方法;
  • 2.4、 classReader.accept:依次调用 ClassVisitor的各个方法
  • 2.5、 classWriter.toByteArray():将最终修改的字节码以 byte 数组形式返回。
  • 2.6、FileOutputStream:通过文件流写入方式覆盖掉原先的内容,实现class文件的改写。

ClassVisitor

public class LifecycleClassVisitor extends ClassVisitor {
    private String className;

    public LifecycleClassVisitor(ClassVisitor cv) {
        /**
         * 参数1:ASM API版本,源码规定只能为4,5,6
         * 参数2:ClassVisitor不能为 null
         */
        super(Opcodes.ASM6, 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);
        this.className = name;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        System.out.println("ClassVisitor visitMethod name-------" + name);
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);

        if (name.startsWith("on")) {
            //处理onXX()方法
            return new LifecycleMethodVisitor(mv, className, name);
        }
        return mv;
    }

    @Override
    public void visitEnd() {
        super.visitEnd();
    }
}

这个类很好理解,就是访问类的时候会调用它,在解析到class的各部分的时候会调用visitXX()方法,而且这些方法的调用是有顺序的,更多详细的解释可以参考:ASM框架学习(二)-ClassVisitor

在这里,我只想在Activity生命周期的方法里插入一句代码,所以我只关注visitMethod()方法即可这里通过方法名找到对应的方法,然后就可以完成代码的插入了。这个方法返回了一个MethodVisitor对象,如果你要修改方法的内容就会用到它,

MethodVisitor

public class LifecycleMethodVisitor extends MethodVisitor {
    private String className;
    private String methodName;

    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) {
//        System.out.println("MethodVisitor visitAnnotation desc------"+desc);
//        System.out.println("MethodVisitor visitAnnotation visible------"+visible);
//        AnnotationVisitor annotationVisitor = mv.visitAnnotation(desc, visible);
//        if (desc.contains("CheckLogin")){
//            return new TestAnnotationVistor(annotationVisitor);
//        }
//        return annotationVisitor;
//    }



    //方法执行前插入
    @Override
    public void visitCode() {
        super.visitCode();
        System.out.println("MethodVisitor visitCode------");

        mv.visitLdcInsn("TAG");
        mv.visitLdcInsn(className + "------->" + methodName);
        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){
//            mv.visitLdcInsn("TAG");
//            mv.visitLdcInsn(className + "------->" + methodName);
//            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
//            mv.visitInsn(Opcodes.POP);
//        }
        super.visitInsn(opcode);
        System.out.println("MethodVisitor visitInsn------");

    }

    @Override
    public void visitEnd() {
        super.visitEnd();
        System.out.println("MethodVisitor visitEnd------");
    }
}

MethodVisitor代码详解:
这是真正修改字节码内容的地方,这个类也有一系列visitXX()方法,并且是按顺序执行的,这里着重讲3个方法,更多详情参考:
ASM框架学习(三)-FieldVisitor和MethodVisitor

visitCode:
表示开始访问方法,表示方法执行前插入。
这段代码实际上大概是这样

Log.e("TAG", "MainActivity------->onCreate()");

visitCode()中的这段代码,大家应该有点印象,在文章开头讲解class文件结构的时候展示过字节码指令的格式。之前有讲过,class文件本质上是16进制数据,为了更好的理解16进制数的意义,出现了字节码指令;而ASM框架就是对字节码指令再做了一次封装,于是乎你在这里看到了一些指令相关的内容

说了这么多,你可能还是会说,这个ASM API也太复杂了,简直难以理解,更不用说自己手写了。的确是这样的,如果你写过汇编代码,你会更加绝望,本来几行java代码,用汇编指令写出来可能需要几十上百行;而字节码指令虽然难度有所降低,但对于很多应用层的开发者而言,仍然是很有难度的,不过还好有这么一款工具,可以把我们写的Java代码转换成对应的ASM代码,待会儿会说。

visitInsn:
访问零操作指令,比如访问return指令。
如果想在方法最后织入代码,写在visitEnd方法内是无效的,因为回调它的时候方法已经访问结束了。但是有一个迂回的解决办法,方法执行结束前都会有一个return指令,即使你的方法返回值为void,那编译成字节码时会默认补上一个return指令。

所以在方法末尾织入代码可以这样写:

    //方法执行后插入
    @Override
    public void visitInsn(int opcode) {
        if (opcode==Opcodes.RETURN){
            mv.visitLdcInsn("TAG");
            mv.visitLdcInsn(className + "------->" + methodName);
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
            mv.visitInsn(Opcodes.POP);
        }
        super.visitInsn(opcode);
        System.out.println("MethodVisitor visitInsn------");
    }

关于指令
return指令根据返回对象的类型不同,会有不同的指令,比如:

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

这里的指令范围非常广,这些指令常量被封装到Opcodes类中,你可以自行查看。

ASM Bytecode Outline插件的使用

刚才说过写ASM API是一件非常麻烦的事情,但是可以借助ASM Bytecode Outline插件来完成。

1、在Android Studio中搜索安装ASM Bytecode Outline

安装插件

2、使用的时候先写出对应的Java代码,然后右键,选择Show Bytecode outLine,把相应的asm代码拷贝出来即可;

使用插件

到这里,ASM的完整流程就算结束了。


总结

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

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

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

源码:
https://github.com/zhouxu88/ASMBaseDemo

参考:
Android ASM框架详解
ASM Core Api 详解
Transform详解

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