使用ASM完成编译时插桩

使用ASM完成编译时插桩
ASM,是一个跟AspectJ功能类似比AspectJ更强大的编译时插桩框架。功能虽强大,不过用起来比AspectJ麻烦不少。
其实这个框架在Java中用的很多,对于Android开发者来说如果之前没有开发过Java就有点陌生了

官网 https://asm.ow2.io/

  • ASM是一个通用的Java字节码操作和分析框架,可以用它来动态的生成类后者增强现有类的功能。
  • ASM可以直接产生二进制的class文件,也可以在类被加载到Java虚拟机之前动态改变类的行为。
  • Java Class的类文件的信息存储在.class文件中,ASM可以读取.class文件中的类信息,改变类行为,分析类信息,甚至生成新的类。

Andorid java文件打包流程:
.java文件->.class文件->.dex文件。想要编译时插桩一般有两种方式

  • 更改java文件:APT,AndroidAnnotation 都是这个层面的dagger,butterknife等框架就是这个层面的应用。
  • 更改class文件:AspectJ,ASM,javassisit等,功能更加强大

下面练习一个小例子,使用ASM来统计Application中onCreate执行的时间。
我们需要两大步来完成:
第一步拿到所有的.class文件,第二步交给ASM动态插入代码。

第一步找到class文件

如何能拿到呢?Google官方在Adnroid Gradle1.5.0版本提供了Transform API,允许第三方Plugin在打包dex文件之前的编译过程中操作.class文件。所以我们就可以使用Transform,拿到所有的.class文件。
想要使用Transform API,这时候就得自定义一个Gradle的插件了

implementation-class=com.hsm.asmplugin.AsmPlugin

最后的目录结构是这样的


下面去到当前module下面的build.gradle添加相关的依赖

apply plugin: 'groovy'
apply plugin: 'maven'

repositories {
mavenCentral()
jcenter()
}
dependencies {
//gradle sdk
implementation gradleApi()
//groovy sdk
implementation localGroovy()

implementation 'com.android.tools.build:gradle:3.5.0'
//ASM相关依赖
implementation 'org.ow2.asm:asm:7.1'
implementation 'org.ow2.asm:asm-commons:7.1'
}
//uploadArchives是将已经自定义好了插件打包到本地Maven库里面去了,
// 你也可以选择打包到远程服务器中。其中,
// group和version是我们之后配置插件地址时要用到的。
group='com.chs.asm.plugin'
version='1.0'
uploadArchives {
repositories {
mavenDeployer {
//本地的Maven地址:当前工程下
repository(url: uri('./my-plugin'))

//提交到远程服务器:
// repository(url: "http://www.xxx.com/repos") {
// authentication(userName: "admin", password: "admin")
// }
}
}
}

因为打包的时候需要用到maven,所以添加maven相关的依赖,uploadArchives是将已经自定义好了插件打包到本地Maven库里面去,也可以打包到远程服务器。group和version是使用的时候需要的组名和版本信息。
配置完成了,下面开始写代码,在groovy文件夹下写我们自己的插件继承Plugin

package com.hsm.asmplugin

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.annotations.NotNull
public class AsmPlugin implements Plugin<Project> {

@Override
public void apply(@NotNull Project project) {
def android = project.extensions.getByType(AppExtension)
println '----------- 开始注册 >>>>> -----------'
AsmTransform transform = new AsmTransform()
android.registerTransform(transform)
}
}

获取project中的AppExtension类型extension,然后注册我们自己定义的Transform。

啥是AppExtension,我们app的gradle中最上面都有这个插件 apply plugin: 'com.android.application
如果依赖了这个插件,AppExtension就存在。
下面来看AsmTransform

package com.hsm.asmplugin

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import com.chs.asm.LogVisitor
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.ClassWriter

import java.util.jar.JarEntry
import java.util.jar.JarFile
import java.util.jar.JarOutputStream
import java.util.zip.ZipEntry

public class AsmTransform extends Transform {

// 设置我们自定义的Transform对应的Task名称
// 编译的时候可以在控制台看到 比如:Task :app:transformClassesWithAsmTransformForDebug
@Override
public String getName() {
return "AsmTransform"
}
// 指定输入的类型,通过这里的设定,可以指定我们要处理的文件类型
// 这样确保其他类型的文件不会传入
@Override
public Set getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
// 指定Transform的作用范围
@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}

@Override
public boolean isIncremental() {
return false
}

@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
long startTime = System.currentTimeMillis()
println '----------- startTime <' + startTime + '> -----------'
//拿到所有的class文件
Collection inputs = transformInvocation.inputs;
TransformOutputProvider outputProvider = transformInvocation.outputProvider;
if (outputProvider != null) {
outputProvider.deleteAll()
}
//遍历inputs Transform的inputs有两种类型,一种是目录,一种是jar包,要分开遍历
inputs.each { TransformInput input ->
//遍历directoryInputs(文件夹中的class文件) directoryInputs代表着以源码方式参与项目编译的所有目录结构及其目录下的源码文件
// 比如我们手写的类以及R.class、BuildConfig.class以及R$XXX.class等
input.directoryInputs.each { DirectoryInput directoryInput ->
//文件夹中的class文件
handDirectoryInput(directoryInput, outputProvider)
}
//遍历jar包中的class文件 jarInputs代表以jar包方式参与项目编译的所有本地jar包或远程jar包
input.jarInputs.each { JarInput jarInput ->
//处理jar包中的class文件
handJarInput(jarInput, outputProvider)
}
}
}

//遍历directoryInputs 得到对应的class 交给ASM处理
private static void handDirectoryInput(DirectoryInput input, TransformOutputProvider outputProvider) {
//是否是文件夹
if (input.file.isDirectory()) {
//列出目录所有文件(包含子文件夹,子文件夹内文件)
input.file.eachFileRecurse { File file ->
String name = file.name
//需要插桩class 根据自己的需求来------------- 这里判断是否是我们自己写的Application
if ("MyApp.class".equals(name)) {
ClassReader classReader = new ClassReader(file.bytes)
//传入COMPUTE_MAXS ASM会自动计算本地变量表和操作数栈
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
//创建类访问器 并交给它去处理
ClassVisitor classVisitor = new LogVisitor(classWriter)
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
FileOutputStream fos = new FileOutputStream(file.parentFile.absolutePath + File.separator + name)
fos.write(code)
fos.close()
}
}
}
//处理完输入文件后把输出传给下一个文件
def dest = outputProvider.getContentLocation(input.name, input.contentTypes, input.scopes, Format.DIRECTORY)
FileUtils.copyDirectory(input.file, dest)
}
//遍历jarInputs 得到对应的class 交给ASM处理
private static void handJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {
if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
//重名名输出文件,因为可能同名,会覆盖
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
JarFile jarFile = new JarFile(jarInput.file)
Enumeration enumeration = jarFile.entries()
File tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_temp.jar")
//避免上次的缓存被重复插入
if (tmpFile.exists()) {
tmpFile.delete()
}
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
//用于保存
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement()
String entryName = jarEntry.getName()
ZipEntry zipEntry = new ZipEntry(entryName)
InputStream inputStream = jarFile.getInputStream(jarEntry)
//需要插桩class 根据自己的需求来-------------
if ("androidx/fragment/app/FragmentActivity.class".equals(entryName)) {
//class文件处理
println '----------- jar class <' + entryName + '> -----------'
jarOutputStream.putNextEntry(zipEntry)
ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
//创建类访问器 并交给它去处理
ClassVisitor cv = new LogVisitor(classWriter)
classReader.accept(cv, ClassReader.EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
jarOutputStream.write(code)
} else {
jarOutputStream.putNextEntry(zipEntry)
jarOutputStream.write(IOUtils.toByteArray(inputStream))
}
jarOutputStream.closeEntry()
}
//结束
jarOutputStream.close()
jarFile.close()
//获取output目录
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
FileUtils.copyFile(tmpFile, dest)
tmpFile.delete()
}
}
}

上面类上的注释很清楚啦,Transform的inputs有两种类型,一种是源码目录,一种是jar包,分别遍历这两个,找到我们需要处理的class类型。比如上面的代码中源码部遍历筛选的是我们自己的Application->MyApp.class。jar包部分处理所有的FragmentActivity。这些都可以根据自己的需求来筛选。
然后通过ClassReader读取,通过ClassWriter交给我们自定义的类访问器LogVisitor来处理
ASM核心类

  • ClassReader 用来解析编译过的字节码文件
  • ClassWriter 用来重新构建编译后的类,比如修改类名,属性,方法或者生成新类的字节码文件
  • ClassVisitor 用来访问类成员信息,包括标记在类上的注解,类的构造方法,类的字段,方法,静态代码块
  • MethodVisitor 用来访问方法的信息,用来进行具体的方法字节码操作。
  • AdviceAdapter 用来访问方法的信息,用来进行具体的方法字节码操作。是MethodVisitor的增强实现

第二步动态插入代码

第一步中通过Transform,遍历所有的class文件,筛选出我们想要处理的class,然后交给了类访问器来处理,下面就来看怎么处理

package com.chs.asm;

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

public class LogVisitor extends ClassVisitor {
private String mClassName;
public LogVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}

@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
System.out.println("LogVisitor : visit -----> started:" + name);
this.mClassName = name;
super.visit(version, access, name, signature, superName, interfaces);
}
//定义一个方法, 返回的MethodVisitor用于生成方法相关的信息
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
if ("com/hsm/asmtext/MyApp".equals(this.mClassName)) {
if ("onCreate".equals(name)) {
//处理onCreate
System.out.println("LogVisitor : visitMethod method ----> " + name);
return new OnCreateVisitor(mv);
}
}
return mv;
}
//访问结束
@Override
public void visitEnd() {
System.out.println("LogVisitor : visit -----> end");
super.visitEnd();
}
}

在visitMethod方法中筛选出我们想要操作的方法。比如这里操作onCreate方法。筛选出来之后交给自定义的方法访问者OnCreateVisitor来处理
怎么处理呢?假如我们想要在Application的onCreate方法执行前插入一行记录时间的代码,在onCreate之后在插入一行代码如下

public class MyApp extends Application {

@Override
public void onCreate() {
long startTime = System.currentTimeMillis();
super.onCreate();
...一堆操作..
long interval = System.currentTimeMillis()-startTime;
}
}

那么使用ASM插入的方式如下

package com.chs.asm;

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


public class OnCreateVisitor extends MethodVisitor {

public OnCreateVisitor(MethodVisitor methodVisitor) {
super(Opcodes.ASM5, methodVisitor);
}
//开始访问方法
@Override
public void visitCode() {
super.visitCode();

mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitVarInsn(Opcodes.LSTORE, 1);
}

@Override
public void visitInsn(int opcode) {
//判断内部操作指令
//当前指令是RETURN,表示方法内部的代码已经执行完
if (opcode == Opcodes.RETURN) {
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitVarInsn(Opcodes.LLOAD, 1);
mv.visitInsn(Opcodes.LSUB);
mv.visitVarInsn(Opcodes.LSTORE, 3);
Label l3 = new Label();
mv.visitLabel(l3);
mv.visitLineNumber(20, l3);
mv.visitLdcInsn("TAG");
mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
mv.visitInsn(Opcodes.DUP);
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "", "()V", false);
mv.visitLdcInsn("interval:");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
mv.visitVarInsn(Opcodes.LLOAD, 3);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);
}
super.visitInsn(opcode);
}

@Override
public void visitEnd() {
super.visitEnd();
//访问结束
}
}

其实到这里就完事了,之后就是打包发布到maven然后供我们的主工程使用了,不过上面的代码是个什么鬼,啥意思啊,我们该怎么写出来啊。
想要弄懂上面的代码,需要对java字节码和JVM的指令有一定的了解,上面就是组装一个方法的代码,需要用到包名啊,方法签名等。
如果我们不了解JVM指令可以写出上面的代码吗?当然可以,有牛逼的前辈早已经写出了插件来生成这样的代码,上面的代码就是生成出来的。
打开AndroidStudio的安装插件的界面,搜索ASM Bytecode Outline这个插件安装。
怎么使用呢
先写一个空的Application如下

public class MyApp extends Application {

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

然后鼠标右击,选择Show Bytecode Outline,就能看到这几行代码的字节码了。


然后在里面加上我们要插入的代码,在执行同样的操作

public class MyApp extends Application {

@Override
public void onCreate() {
long startTime = System.currentTimeMillis();
super.onCreate();
long interval = System.currentTimeMillis()-startTime;
Log.i("TAG","interval:"+interval);
}
}

又能看到当前几行代码的字节码。然后牛逼的功能又来了,点击ASMified这个tab,里面有个show differences


就能看到前后两次操作生成的指令的区别在哪里了。这样就能很清晰的知道该怎么写了如下。


OK,下面开始发布上传,之前build.gradle中已经配置好了maven的本地仓库地址了。下面直接使用AndroidSrudio的快捷键上传
打开最右边的Gradle面板,然后点击uploadArchives上传,


OK之后就可以在对应目录看到我们上传的jar包了。本项目配置的仓库在当前目录下的my-plugin文件夹,最后生成目录如下


在当前工程目录引入本地maven仓库地址和我们自己写的插件,插件的包名和版本号就是之前插件build.gradle中配置的

buildscript {
repositories {
google()
jcenter()
maven {
//本地仓库地址
url uri('D:/androiddemo/5/ASMText/asmplugin/my-plugin')
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0'
//自己写的插件
classpath 'com.chs.asm.plugin:asmplugin:1.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()
maven {
//本地仓库地址
url uri('D:/androiddemo/5/ASMText/asmplugin/my-plugin')
}
}
}

然后去app中build.gradle中引入插件

apply plugin: 'com.chs.asm-plugin'

OK大工完成,在onCreate中添加个耗时代码用来测试,运行app

public class MyApp extends Application {

@Override
public void onCreate() {
super.onCreate();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}

运行结果如下

2019-09-26 11:09:28.838 28745-28745/com.hsm.asmtext I/TAG: interval:1002

到这里ASM的简单用法就算入门了,想要自如的操控我们的代码,还需要继续系统的学习一下gradle和ASM的知识。多多练习多熟悉。
参考博文

在AndroidStudio中自定义Gradle插件

【Android】函数插桩(Gradle + ASM)

Android ASM自动埋点方案实践