讲两句
本文旨在通过一个极其简洁的例子带大家一窥字节码插桩技术,不会对框架进行深入解读,因为涉及到的东西比较多,如果全盘托出,那么对于初学者来说极不友好,会因没有全局感也迷失方向,浪费更多的时间。就好比你学开车前得先学会汽修,那完犊子了。这里写一个及其好理解且简单的例子,先让代码跑起来,看到输出结果。让学者先有个整体思维,再逐步根据需求深入学习。
简单说一下原理
所谓字节码插桩其实就是在编译打包时对字节码进行操作,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默认输出目录的一个文件夹。
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。
可以通过两次输入差异对比来获取自己想要的字节码。
测试
字节码插桩的内容写好后,我们就将插件发布到本地maven,在其他项目引用该插件,编译项目后,我们在build相关的目录下就能看到插入的代码了!