Android ASM字节码插桩实践

1,795 阅读8分钟

讲两句

本文旨在通过一个极其简洁的例子带大家一窥字节码插桩技术,不会对框架进行深入解读,因为涉及到的东西比较多,如果全盘托出,那么对于初学者来说极不友好,会因没有全局感也迷失方向,浪费更多的时间。就好比你学开车前得先学会汽修,那完犊子了。这里写一个及其好理解且简单的例子,先让代码跑起来,看到输出结果。让学者先有个整体思维,再逐步根据需求深入学习。

简单说一下原理

所谓字节码插桩其实就是在编译打包时对字节码进行操作,Android的编译是通过gradle,那么就需要用到gradle相关的知识,操作字节码就需要用到字节码相关的工具,功能比较强大且灵活的非ASM莫属,那就一步到位用ASM。

Android编译打包过程可以简述为:java文件-》class文件-》dex文件。我们插桩就是在class文件转dex的时候,dex文件里就是存放字节码的。

干些什么

我们的例子具体要干写什么?很简单,在每个方法的最前面加一句:【向所有的烦恼说拜拜】,在方法的最后加一句:【向所有的快乐说嗨嗨】

准备

既然字节码插桩是在编译阶段,那么我们如何干预gradle的编译过程呢?这就需要用到gradle插件了,我们平时在build.gradle文件中通过classpath引入的就是gradle插件,用于在编译时干一些事。所以我们也要自定义一个gradle插件,才能做插桩的事。这里我还写了一篇自定义插件的文章:Gradle自定义插件初探,也是写得非常简单,如果熟悉gradle插件,则忽略这篇文章继续往下看。

之所以我们能够进行插桩,是因为自从1.5.0-beta1版本开始, android gradle插件就包含了一个Transform API, 它允许第三方插件在编译后的类文件转换为dex文件之前做处理操作。在项目构建阶段(.class -> .dex转换期间)用来修改.class文件的一套标准API,即把输入的.class文件转变成目标字节码文件。通过Transform API我们可以拿到class文件的输入输出,拿到输入源后用ASM对文件进行修改,然后将修改后的文件保存替换到Transform的输出目录中,就达到了插桩的目的。

gradle插件需要加入的依赖有:

implementation("com.android.tools.build:gradle:3.5.2")
implementation 'org.ow2.asm:asm:9.2'
implementation 'org.ow2.asm:asm-util:9.2'
implementation 'org.ow2.asm:asm-commons:9.2'

因为用到了build:gradle:3.5.2插件,所以需要在根目录的build.gradle文件中加入:

buildscript {
    repositories {
        google()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.2'
    }
}

注册Transform

假设你已经了解了gradle插件,那么我们就在插件的入口进行Transform注册,这样编译时就会调用到Transform的内容。

import com.android.build.gradle.AppExtension;

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

public class CustomPlugin implements Plugin<Project> {
    @Override
    public void apply(Project project) {
        AppExtension appExtension = project.getExtensions().findByType(AppExtension.class);
        if (appExtension == null) {
            return;
        }
        //注册Transform
        appExtension.registerTransform(new MyTransform());
    }
}

Transform内容

import com.android.build.api.transform.DirectoryInput;
import com.android.build.api.transform.Format;
import com.android.build.api.transform.QualifiedContent;
import com.android.build.api.transform.Transform;
import com.android.build.api.transform.TransformException;
import com.android.build.api.transform.TransformInput;
import com.android.build.api.transform.TransformInvocation;
import com.android.build.api.transform.TransformOutputProvider;
import com.android.build.gradle.internal.pipeline.TransformManager;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Collection;
import java.util.Set;

public class MyTransform extends Transform {
    @Override
    public String getName() {
        //名字,输出的文件会默认生成在这个名字的目录下,比如:MyPlugin\app\build\intermediates\transforms\MyTransform..
        return "MyTransform";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return true;
    }

    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
        //可以从中获取jar包和class文件夹路径。需要输出给下一个任务
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        //OutputProvider管理输出路径
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
        //遍历所有的输入,有两种类型,分别是文件夹类型(也就是我们自己写的代码)和jar类型(引入的jar包),这里我们只处理自己写的代码。
        for (TransformInput input : inputs) {
            //遍历所有文件夹
            for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
                //获取transform的输出目录,等我们插桩后就将修改过的class文件替换掉transform输出目录中的文件,就达到修改的效果了。
                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY);
                transformDir(directoryInput.getFile(), dest);
            }
        }
    }

    /**
     * 遍历文件夹,对文件进行插桩
     *
     * @param input 源文件
     * @param dest  源文件修改后的输出地址
     */
    private static void transformDir(File input, File dest) throws IOException {
        if (dest.exists()) {
            FileUtils.forceDelete(dest);
        }
        FileUtils.forceMkdir(dest);
        String srcDirPath = input.getAbsolutePath();
        String destDirPath = dest.getAbsolutePath();
        File[] fileList = input.listFiles();
        if (fileList == null) {
            return;
        }
        for (File file : fileList) {
            String destFilePath = file.getAbsolutePath().replace(srcDirPath, destDirPath);
            File destFile = new File(destFilePath);
            if (file.isDirectory()) {
                //如果是文件夹,继续遍历
                transformDir(file, destFile);
            } else if (file.isFile()) {
                //创造了大小为0的新文件,或者,如果该文件已存在,则将打开并删除该文件关闭而不修改,但更新文件日期和时间
                FileUtils.touch(destFile);
                asmHandleFile(file.getAbsolutePath(), destFile.getAbsolutePath());
            }
        }
    }

    /**
     * 通过ASM进行插桩
     *
     * @param inputPath 源文件路径
     * @param destPath  输出路径
     */
    private static void asmHandleFile(String inputPath, String destPath) {
        try {
            //获取源文件的输入流
            FileInputStream is = new FileInputStream(inputPath);
            //将原文件的输入流交给ASM的ClassReader
            ClassReader cr = new ClassReader(is);
            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
            //构建一个ClassVisitor,ClassVisitor可以理解为一组回调接口,类似于ClickListener
            MyClassVisitor visitor = new MyClassVisitor(cw);
            //这里是重点,asm通过ClassReader的accept方法去解析class文件,去读取每一个节点。
            // 每读到一个节点,就会通过传入的visitor相应的方法回调,这样我们就能在每一个节点的回调中去做操作。
            cr.accept(visitor, 0);
            //将文件保存到输出目录下
            FileOutputStream fos = new FileOutputStream(destPath);
            fos.write(cw.toByteArray());
            fos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在上面的代码中,我们自定义了一个MyTransform,它继承了Transform,Transform的几个基本方法我们了解一下:

getName方法: 指明 Transform 的名字,也对应了该 Transform 所代表的 Task 名称,例如当返回值为 InjectTransform 时,编译后可以看到名为transformClassesWithInjectTransformForxxx 的 task,同时也是transform默认输出目录的一个文件夹。

image.png

getInputTypes方法: 指明 Transform 处理的输入类型,在 TransformManager 中定义了很多类型:

public static final Set<ScopeType> EMPTY_SCOPES = ImmutableSet.of();

// 代表 javac 编译成的 class 文件,常用
public static final Set<ContentType> CONTENT_CLASS = ImmutableSet.of(CLASSES);
public static final Set<ContentType> CONTENT_JARS = ImmutableSet.of(CLASSES, RESOURCES);
// 这里的 resources 单指 java 的资源
public static final Set<ContentType> CONTENT_RESOURCES = ImmutableSet.of(RESOURCES);
public static final Set<ContentType> CONTENT_NATIVE_LIBS = ImmutableSet.of(NATIVE_LIBS);
public static final Set<ContentType> CONTENT_DEX = ImmutableSet.of(ExtendedContentType.DEX);
public static final Set<ContentType> CONTENT_DEX_WITH_RESOURCES = ImmutableSet.of(ExtendedContentType.DEX, RESOURCES);

其中,很多类型是不允许自定义 Transform 来处理的,我们常使用 CONTENT_CLASS 来操作 Class 文件。

getScopes方法: 指明 Transform 输入文件所属的范围, 因为 gradle 是支持多工程编译的。常用的是SCOPE_FULL_PROJECT,代表所有Project。确定了ContentType和Scope后就确定了该自定义Transform需要处理的资源流。比如CONTENT_CLASS和SCOPE_FULL_PROJECT表示了所有项目中java编译成的class组成的资源流。

isIncremental方法: 指明该 Transform 是否支持增量编译。

transform方法: 这是最主要的方法,我们的操作都是在这个方法内,此方法传入一TransformInvocation,通过TransformInvocation可以获得文件输入输出等信息,就可以对源文件进行修改了。

ASM

ASM的几个关键类分别是:

ClassReader: 读取源文件流并对流进行分析等,其accept可对class进行解析,碰到相关的节点后通过传入的visitor回调。

ClassWriter: 其继承了ClassVisitor,是写相关的类,比如修改字节码等。

ClassVisitor: 你可以把它理解为回调接口,当ClassReader的accept方法分析到对应节点时会回调相应的方法,可在回调方法中进行字节码插桩。

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class MyClassVisitor extends ClassVisitor implements Opcodes {
    public MyClassVisitor(ClassVisitor cv) {
        super(ASM7, cv);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
        return methodVisitor == null ? null : new MyMethodVisitor(methodVisitor, access, name, descriptor);
    }
}

我们的目的是在方法前后插入输出内容,所以我们只需要关注方法节点就好了,在上面MyClassVisitor自定义的ClassVisitor中,我们重写visitMethod方法,当ClassReader分析到方法时,就会回调visitMethod方法。visitMethod方法需要返回一个MethodVisitor,MethodVisitor是对方法内部的解析时回调的,所以我们需要写一个自定义的MethodVisitor。

import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.commons.AdviceAdapter;

public class MyMethodVisitor extends AdviceAdapter {
    public MyMethodVisitor(MethodVisitor mv, int access,
                           String name,
                           String descriptor) {
        super(ASM7, mv, access, name, descriptor);
    }

    @Override
    protected void onMethodEnter() {
        super.onMethodEnter();
        //方法执行之前打印
        mv.visitLdcInsn("\u8fdb\u5165\u65b9\u6cd5");
        mv.visitLdcInsn("\u5411\u6240\u6709\u7684\u70e6\u607c\u8bf4\u62dc\u62dc");
        mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
    }

    @Override
    protected void onMethodExit(int opcode) {
        super.onMethodExit(opcode);
        //方法执行之后打印
        mv.visitLdcInsn("\u9000\u51fa\u65b9\u6cd5");
        mv.visitLdcInsn("\u5411\u6240\u6709\u7684\u5feb\u4e50\u8bf4\u55e8\u55e8");
        mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
        mv.visitInsn(POP);
    }
}

AdviceAdapter也是ASM提供的一个继承了MethodVisitor的类,封装了一些常用的方法,方便我们使用。我们重写其中的进入方法和退出方法的方法,并在其中加入我们要插入的字节码。

这些字节码不会写怎么办,ASM提供了个插件,可以直接将Java翻译成对应的字节码,这样就不许要太关注字节码相关的东西,只需要关注自己的业务就行了,这个插件是ASM Bytecod viewer。

asm.png

可以通过两次输入差异对比来获取自己想要的字节码。

测试

字节码插桩的内容写好后,我们就将插件发布到本地maven,在其他项目引用该插件,编译项目后,我们在build相关的目录下就能看到插入的代码了!

image.png