android字节码插桩研究

3,067 阅读9分钟

背景

之前在极客时间上面学习张绍文老师的《Android开发高手课》的时候,有一章节讲了android中编译插桩的三种方法:AspectJ、ASM、Redex。觉得这个东西好厉害,就想着要弄懂它,在后面章节的Sample练习中也详细讲解了ASM与TransForm结合在android插桩中的运用。但是这个知识点还是有点难度的,想要弄懂这个知识点还是需要很多储备知识的。

知识点

要想理解android中字节码插桩的运用,需要掌握以下几个知识点:

  • 面向切面编程思想(AOP)
  • 自定义gradle插件
  • Transform相关知识
  • ASM相关知识
  • 将plugin、Transform、ASM结合起来使用

AOP简介

AOP(Aspect Oriented Program)是一种面向切面编程的思想。这种思想是相对于OOP(Object Oriented Programming)来说的。这里可以参考邓凡平老师的深入理解Android之AOP。Java中的面向对象编程的特点是继承、多态和封装。这就使功能被划分到一个一个模块中,模块之间通过设计好的接口交互。OOP的精髓就使把功能或者问题模块化。
但是在现实中,我们会有一些这样的需求,比如:在项目中所有模块都添加日志统计模块,统计每个方法的运行时间等。这个如果用OOP的思想来实现的话,需要在每个模块的每个方法中添加需要的代码。而通过AOP就能很好的解决这个问题,AOP可以理解为在代码运行期间,动态地将代码切入到类中的指定方法、指定位置上的编程思想。注意这是一种编程思想,它的具体实现方式有很多,比如java中的动态代理,aspectj以及我们今天要讲的通过asm来实现。

自定义Gradle插件

本来想先讲Transform相关的知识的,但是,Transform一般在自定义插件中使用,所以如果不先介绍自定义插件的话,可能看不懂要讲的Transform,这里就简单介绍一下自定义插件。
这里推荐在AndroidStudio中自定义Gradle插件,这篇文章详细讲解了如何在android studio中创建Gradle插件,这里就不再细述。创建好了之后我们会在groovy文件夹下面创建一个继承Plugin类的子类,如下:

package com.soulmate.plugin.lifecycle

import org.gradle.api.Plugin
import org.gradle.api.Project

class CustomPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
       project.task("testTask"){
            doLast{
                println "hello from the CustomPlugin"
            }
        }
    }
}

当我们在Terminal中输入gradle testTask的时候,会看到输出“hello from the CustomPlugin”,后面通过Transform来处理时,也是在apply方法中进行处理的。

Transform简介

在官方文档中是这么形容Transform:

Starting with 1.5.0-beta1,the Gradle Plugin includes a Transform API allowing 
3rd party plugins to manipulate compiled class files before they are converted to dex files     

The goal of this API is to simplify injecting custom class manipulations without having to deal with tasks,
and to offer more flexibility on what is manipulated. The internal code processing (jacoco, progard, multi-dex) 
have all moved to this new mechanism already in 1.5.0-beta1

简单翻译一下就是Gradle工具从1.5.0版本开始提供Transform API,在编译后的class文件转换成dex文件之前,通过Transform API来处理编译后的class文件。

Transform API的目标是不需要通过处理任务来简化注入自定义类的操作,在处理上面提供了更大的灵活性。包括(proguard、multi-dex等)都在1.5.0中迁移到这个新机制中。

简单总结就是Transform API是操作编译后的.class文件,而我们知道.class文件中是java编译后的字节码,所以Transform相当于提供了一个操作字节码的入口。(具体java中的字节码相关知识可以网上搜索,这里我强烈推荐一下《深入理解Java虚拟机》这本书,这本书上面对字节码有很详细的讲解)。而由于字节码的操作比较复杂,我们一般需要借助工具来处理java字节码,ASM工具就是一个非常好的字节码处理工具,后面我们会介绍ASM在处理字节码方面的运用。

Transform的代码结构

我们写一个TestTransform继承Transform然后看一些重写的方法。

public class TestTransform extends Transform {
    private static Project project

    TestTransform(Project project) {
        this.project = project
    }
    
    @Override
    public String getName() {
        return “TestTransform”;
    }

    /**
     * 需要处理的数据类型,有两种枚举类型
     * CLASSES 代表处理的编译后的class文件,RESOURCES 代表要处理的java资源
     */
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    /**
     * 值Transform 的作用范围,有一下7种类型:
     * 1.EXTERNAL_LIBRARIES        只有外部库
     * 2.PROJECT                   只有项目内容
     * 3.PROJECT_LOCAL_DEPS        只有项目的本地依赖(本地jar)
     * 4.PROVIDED_ONLY             只提供本地或远程依赖项
     * 5.SUB_PROJECTS              只有子项目
     * 6.SUB_PROJECTS_LOCAL_DEPS   只有子项目的本地依赖项(本地jar)
     * 7.TESTED_CODE               由当前变量(包括依赖项)测试的代码
     */
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    //是否支持增量编译
    @Override
    public boolean isIncremental() {
        return false;
    }
    
    //这个方法用来进行具体的输入输出处理,这里可以获取输入的目录文件以及jar包文件
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
    }
}

这里要补充一下,现在自定义的Transform有了,自定义的plugin也有了,如何将两者关联起来了。这时我们需要用到一个类AppExtension,这个类继承自BaseExtension。我们在TestPlugin类中改写apply方法:

package com.soulmate.plugin.lifecycle

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class CustomPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        AppExtension appExtension = project.extensions.findByType(AppExtension.class)
        appExtension.registerTransform(new TestTransform(project))
    }
}

这样我们就将自定义的插件和Transform关联起来了。接下来我们介绍一下ASM相关的知识,然后最后在讲解在transform()方法中使用ASM来处理相应的需求

ASM简介

ASM是一个java字节码操控框架。它能被用来 动态生成类或者增强既有类的功能。ASM采用的是Visitor设计模式对字节码进行访问和修改,核心类主要有以下几个:

  • ClassReader: 它将字节数组或者class文件读入内存当中,并以树的数据结构表示,树中的一个节点代表class文件中的某个区域。可以将ClassReader看做是接受访问者(accept)的实现类,其每一个元素都可以被Visitor访问
  • ClassVisitor(抽象类):ClassReader对象创建之后,调用ClassReader#accept()方法,传入一个ClassVisitor对象。在ClassReader中遍历树结构的不同节点时会调用ClassVisitor对象不同的visit()方法,从而实现对字节码的修改。在ClassVisitor中的一些访问会产生子过程,比如visitMethod会产生MethodVisitor,visitField会产生FieldVisitor。我们也可以对这些Visitor进行实现,从而达到对这些子节点的字节码的访问和修改。如自带的AdviceAdapter就是继承自MethodVisitor.
  • ClassWriter:继承自ClassVisitor,它是生成字节码的工具类,它一般是责任链的最后一个节点,其之前的每一个ClassVisitor都是致力于对原始字节码做修改,而ClassWriter的操作则是把每个节点修改后的字节码输出为字节数组。

ASM工作流程

  1. ClassReader读取字节码到内存中,生成用于表示该字节码的内部表示的树,ClassReader对应于访问者模式被访问的元素
  2. 组装ClassVisitor责任链,这一系列ClassVisitor完成对字节码一系列不同的字节码修改工作
  3. 然后调用ClassReader#accept()方法,传入ClassVisitor对象,此ClassVisitor是责任链的头结点,经过责任链中每一个ClassVisitor对已加载进内存的字节码的树结构上的每个节点的访问和修改
  4. 最后,在责任链的末端,调用ClassWriter中的visitor进行修改后的字节码的输出工作。

ASM使用demo

这里主要给的是巴巴巴巴巴巴掌的文章手摸手增加字节码往方法体内插代码,这个例子对于理解asm中具体的插入代码方式有非常直观的理解。这里我就不贴出具体代码了,我只是将main()方法中的

FileOutputStream fos = new FileOutputStream("out/Bazhang223.class");
fos.write(code);
fos.close();

替换成了

FileOutputStream fos = new FileOutputStream("Bazhang223.class");
fos.write(code);
fos.close();

运行main方法后,会在as的根目录下面生成Bazhang223.class文件。打开这个class文件,你会发现你想要添加的两个输出已经添加成功了。

Plugin、Transform和ASM的结合使用

前面我们已经对每一个都进行了介绍,现在我们对这三者的概念应该有了清晰的认识,接下来就要看看如何将三者结合起来使用了。
自定义plugin这个不用说,肯定是首先需要做的事。

然后我们需要做的是重写自定义自定义的Transform子类中的transform()方法,这个方法非常重要,这个方法是所有业务逻辑的入口,在这个方法里面你可以遍历所有目录和jar包,获取所有的class文件,然后做需要的处理。具体遍历的代码如下。

//Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历
inputs.each { TransformInput input ->
    //遍历directoryInputs
    input.directoryInputs.each { DirectoryInput directoryInput ->
        //do Something
    }

    //遍历jarInputs
    input.jarInputs.each { JarInput jarInput ->
        //do Something
    }
}

既然我们可以获取所有的class文件了,那么现在我们就可以对每个class文件进行修改了,修改class文件就用到了ASM。这里就以《android高手开发课》上面的例子讲一下,将每个class文件转换成字节数组,然后传给下面的方法:

public static run(InputStream is) throws IOException {
    ClassReader classReader = new ClassReader(is);
    //COMPUTE_MAXS 说明使用 ASM 自动计算本地变量表最大值和操作数栈的最大值
    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    ClassVisitor classVisitor = new TraceClassAdapter(Opcodes.ASM5, classWriter);
    //EXPAND_FRAMES 说明在读取 class 的时候同时展开栈映射帧 (StackMap Frame),在使用 AdviceAdapter 里这项是必须打开的
    classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
}

具体在ASM中如何修改这里就不详细说了,可以参考《android高手开发课》中的代码。

好了,到这里我们终于将这三者的关系讲完了,这样你应该对字节码插桩的实现有了清晰的认识了。后面你就可以结合网上的一些案例来自己实现字节码插桩了。

总结

编译插桩技术还是非常重要的,我们平时用到的很多框架包括butterknifeDagger以及数据库ORM框架都会在编译过程中生成代码。所以对于一名开发人员来说还是要很好的掌握这门技术的。

参考文献