Android Gradle 中的字节码插桩之ASM
Posted 好人静
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android Gradle 中的字节码插桩之ASM相关的知识,希望对你有一定的参考价值。
前言
逐步整理的一系列的总结:
Android 自定义Gradle插件的Extension类(五)
android Gradle 中的字节码插桩之ASM(八)
Android Gradle 中的使用ASMified插件生成.class的技巧(九)
Android Gradle 中的实例之动态修改AndroidManifest文件(十)
在 Android Gradle 中的Transform(六)中只是简单的了解了怎么能够怎么在apk打包流程中进行对.class文件进行修改处理,那么具体实现一个像在 Android Gradle 中的Transform(六)前言说的那些功能实现,还需要用到一些字节码框架。
在JVM平台上,常见的处理字节码的框架最常见的就三个:ASM,Javasist,AspectJ(这个不就是在小白新手web开发简单总结(十二)-数据库连接的相关优化(事务管理)中的三 声明式事务管理里面的一个概念吗?有点开心)。
一 ASM
1.AOP
先认识一个概念AOP。
AOP(Aspect Oriented Programming):面向切面编程。相比较于OOP的将问题化为单个模块,AOP就是将这些模块的某一类问题进行统一管理。把横跨并嵌入到众多模块的功能集中起来,放到一个统一的地方来管理和控制。
通常有三种实现方式:
- (1)生成子类的字节码
- (2)生成代理类的字节码
- (3)直接修改源类的字节码
在Android中通常在.class文件转换成.dex文件的时候,通过一些字节码框架实现对应的功能,即在 Android Gradle 中的Transform(六) 的三 Android Transform高级应用中对提到.对jar文件和.class文件的增加修改逻辑地方中通过字节码框架实现对应的功能。
2.ASM
ASM 是一个字节码操作框架,可以动态生成类或者为增加既有类的功能。该框架可以直接产生二进制的class文件,也可以在类被加载Java虚拟机之前改变类的行为。ASM相比较于Javasist,AspectJ或者反射,可以更底层的处理字节码的每条命令、处理速度更快,占用内存更小。
有两种API类型:Tree API和Visitor API。
- (1)Tree API:也称为对象模型。将class的内容都读取到内存,构建成一个树形结构,在处理Method、Field等元素的时候,定位到树形结构中的元素,最后写入新的class文件;
- (2)Visitor API:也称为事件模型。每次扫描到类文件的相应内容的时候,就会回调API中对应的方法,然后处理完之后可覆盖原来的.class文件实现代码注入。其中几个核心类如下:
- 1)ClassReader:解析编译过的.class文件,可以获取该文件中的类名、接口、成员名、方法参数等;
- 2)ClassWriter:重构编译之后的类,用来修改类名、属性、方法等以及生成新的.class文件;
- 3)ClassVisitor:访问类信息。包括标记在类上的注解、类构造方法、类字段、类方法、静态代码块;
- 4)AdviceAdapter:实现了MethodVisitor接口,主要访问方法的信息。用来对具体方法进行字节码操作;
- 5)FieldVisitor:访问具体的类成员;
- 6)AnnotationVisitor:访问具体的注解信息
在使用ASM框架的时候,需要在引入其对应的依赖,如下:
dependencies
implementation 'org.ow2.asm:asm:9.2'
对应maven库地址为https://mvnrepository.com/artifact/org.ow2.asm/asm,可在里面选择合适的版本引入到项目的dependencies 中。
二 ASM中几个重要的类
1.ASM之ClassVisitor
抽象类,主要用来访问类信息。其中几个重要的方法如下:
- (1)构造函数
当继承ClassVisitor来实现一个具体的访问类的类时,必须增加一个构造函数来通过super()调用到该抽象类的构造函数,例如
public AutoLogClassVisitor(ClassVisitor visitor)
super(Opcodes.ASM9, visitor);
其中 ClassVisitor的构造函数的api这个参数,如下:
/**
* Constructs a new @link ClassVisitor.
*/
public ClassVisitor(final int api, final ClassVisitor classVisitor)
if (api != Opcodes.ASM9
&& api != Opcodes.ASM8
&& api != Opcodes.ASM7
&& api != Opcodes.ASM6
&& api != Opcodes.ASM5
&& api != Opcodes.ASM4
&& api != Opcodes.ASM10_EXPERIMENTAL)
throw new IllegalArgumentException("Unsupported api " + api);
// ...........
从源码中可以看到目前只支持AMS4、AMS5、AMS6、AMS7、AMS8、AMS9、ASM10_EXPERIMENTAL。这个参数指的是 ASM API 版本号,差别在于高版本中有些方法在低版本中没有。
- (2)visit(version, access, name, signature, superName, interfaces)
该方法是扫描类的时候回调的第一个方法。其中里面的具体实现采用的访问者设计模式(遗留问题:后面去学习下这个设计模式),具体的实现通过传入的ClassVisitor来实现。里面的参数含义如下:
/**
* 当开始扫描类的时候回调的第一个方法
*
* @param version jdk版本.如52则为jdk1.8,对应@link Opcodes#V18; 51则为jdk1.7,对应Opcodes的V17.具体参数对应值在 @link Opcodes中查看.
* @param access 类修饰符.在ASM中以“ACC_”开头的常量:如 @link Opcodes#ACC_PUBLIC对应的public.具体参数对应值在 @link Opcodes中查看.
* @param name 类名:包名+类名
* @param signature 泛型信息,若未定义任何类型,则为空
* @param superName 父类
* @param interfaces 实现的接口的数组
*/
- (3)MethodVisitor visitMethod(access, name, descriptor, signature, exceptions)
该方法是扫描类的方法的时候进行回调的方法。返回的是一个MethodVisitor,在实现该方法的时候,通常返回的是一个自定义的MethodVisitor,用于对方法的进行处理。里面的参数含义如下:
/**
* 扫描到类的方法回调该方法
*
* @param access 方法修饰符,同visit()中的access
* @param name 方法名
* @param descriptor 方法签名:(参数列表)返回类型,如void onCreate(Bundle savedInstanceState),返回的为(Landroid/os/Bundle;)V
* I代表int;B代表byte;C代表char;D代表double;F代表float;J代表long;S代表short;Z代表boolean;V代表void;
* [...;代表一维数组;[[...;代表二维数组;[[[...;代表三维数组
* 例如输入参数为:
* 1.String[]则返回[Ljava/lang/String;
* 2.int,String,int[]则返回(ILjava/lang/String;[I)
* @param signature 泛型相关信息
* @param exceptions 会抛出异常
* @return MethodVisitor
*/
- (4)FieldVisitor visitField(access, name, descriptor, signature, value)
该方法是扫描类的成员变量的时候回调该方法,返回的是一个FieldVisitor,在实现该方法的时候,通常返回一个自定义的FieldVisitor,用于对成员变量进行逻辑处理。里面的参数含义同visitMethod()
- (5)AnnotationVisitor visitAnnotation(descriptor, visible)
该方法是扫描类的注解的时候调用该方法,返回的是一个AnnotationVisitor,在实现该方法的时候,通常返回一个自定义的AnnotationVisitor,用于对注解进行逻辑处理。里面的参数含义如下:
/**
* 扫描到类注解回调该方法
*
* @param descriptor 注解类型
* @param visible 在JVM是是否可见
* @return AnnotationVisitor
*/
还有像visitSource()访问的源码文件、visitOuterClass()等,后面如果有用到的时候再去详细总结。
2.ASM之AdviceAdapter
在前面介绍visitMethod()的时候,会返回一个MethodVisitor ,用来对类中的方法进行处理。该 MethodVisitor为一个抽象类,通常使用其子类AdviceAdapter,可以更方便的修改方法的字节码。其中几个比较重要的方法如下:
- (1)构造函数
当继承该抽象类的时候,必须要增加构造函数来通过super()调用到父类的构造函数,例如:
/**
* Constructs a new @link AdviceAdapter.
*
* @param api the ASM API version implemented by this visitor. Must be one of @link
* Opcodes#ASM4, @link Opcodes#ASM5, @link Opcodes#ASM6 or @link Opcodes#ASM7.
* @param methodVisitor the method visitor to which this adapter delegates calls.
* @param access the method's access flags (see @link Opcodes).
* @param name the method's name.
* @param descriptor the method's descriptor (see @link Type Type).
*/
protected AutoLogAdviceAdapter(int api, MethodVisitor methodVisitor, int access, String name, String descriptor)
super(api, methodVisitor, access, name, descriptor);
从注释中可以看到改处的api参数支持 AMS4、AMS5、AMS6、AMS7,并且从源码中也可以看到如果不设置这几个值,会抛出异常,代码如下:
public MethodVisitor(final int api, final MethodVisitor methodVisitor)
if (api != Opcodes.ASM6 && api != Opcodes.ASM5 && api != Opcodes.ASM4 && api != Opcodes.ASM7)
throw new IllegalArgumentException();
//.......
遗留问题:在实际使用过程中,将ASM9赋值api,并没有该异常抛出。有时候会出现"com.android.tools.r8.errors.b: Absent Code attribute in method that is not native or abstract"异常,改成ASM7即没有,暂时还没有找到原因
在 Android Gradle之Java字节码(七)中的(2)方法引用中,已经对比了一个Java源码经过javac编译之后生成的.class字节码对应的结构对比关系,那么ASM字节码框架中同样提供了对应的回调方法来对应这些.class字节码。部分回调方法与字节码文件的对应关系图如下:
简单的汇总下这些方法:
- (2)方法调用生命周期相关的方法
- onMethodEnter():进入到该方法的时候回调该方法。
- visitCode():开始执行字节码的Code部分的时候回调该方法,会在onMethodEnter()之后调用。
- onMethodExit():即将退出该方法的时候回调该方法,该方法调用结束之后,才会执行返回的字节码指令,如ireturn。
- visitEnd():该方法调用结束之后该回调方法。
- (3)方法的执行的指令集
根据不同作用的指令集会回调到不同的方法,具体指令集对应的代码位于org.objectweb.asm.Opcodes下定义的相关变量:
- 1)visitVarInsn(opcode, var):加载和存储局部变量表中的变量的指令。
第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:
int ILOAD = 21; // visitVarInsn
int LLOAD = 22; // -
int FLOAD = 23; // -
int DLOAD = 24; // -
int ALOAD = 25; // -
int ISTORE = 54; // visitVarInsn
int LSTORE = 55; // -
int FSTORE = 56; // -
int DSTORE = 57; // -
int ASTORE = 58; // -
int RET = 169; // visitVarInsn
第二个参数var:就是字节码指令的操作数
当执行的指令集中有如上指令的时候,就会回调到该方法,如上例中的iload_1,那么在回调该方法的时候,两个opcode, var对应的值为
visitVarInsn opcode = 21 , var = 1
- 2)visitIntInsn(opcode, operand):加载常量的bipush和sipush以及创建数组的newarray指令
第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:
int BIPUSH = 16; // visitIntInsn
int SIPUSH = 17; // -
int NEWARRAY = 188; // visitIntInsn
第二个参数var:就是字节码指令的操作数
- 3)visitInsn(opcode):算术指令操作指令、数组相关的操作指令等
第一个参数opcode对应的字节码指令位于Opcodes类中,因为内容比较多,不单独罗列,用的时候参照Opcodes类
- 4)visitFieldInsn(opcode, owner, name, descriptor):访问类的成员变量的操作指令
第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:
int GETSTATIC = 178; // visitFieldInsn
int PUTSTATIC = 179; // -
int GETFIELD = 180; // -
int PUTFIELD = 181; // -
第二个参数name:方法的所有者类的内部名称
后面的name和descripor不在多于罗列,同其他出现过的作用一样。
- 5)visitMethodInsn(opcode, owner, name, descriptor, isInterface):方法调用相关的指令
第一个参数opcode:就是对应的字节码指令位于Opcodes类中,如下:
int INVOKEVIRTUAL = 182; // visitMethodInsn
int INVOKESPECIAL = 183; // -
int INVOKESTATIC = 184; // -
int INVOKEINTERFACE = 185; // -
后面的三个参数同上。
最后一个参数 isInterface:就是该方法是否为接口方法的一个标示
当然跟指令集相关的回调方法还有像visitTypeInsn()等,不在一一罗列,等用到的时候在去查看Opcodes类。
- (4)局部变量表中的所有变量输出
visitLocalVariable(name, descriptor, signature, start, end, index)
- (5)方法传入参数的输出
visitParameter(name, access)
- (6)最大操作数栈和局部变量个数的输出
visitMaxs(maxStack, maxLocals)
那么还是刚才的这个sum()方法,最后在访问该方法的时候,该AdviceAdapter返回的内容如下:
//ClassVisitor#visitMethod回调
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* visitMethod access = 2 , name = sum , descriptor = (II)I
//进入到AdviceAdapter#visitParameter回调
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitParameter name = aa
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitParameter name = bb
//进入到该方法
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = onMethodEnter
//进入到Code部分
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitCode
//指令集合
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitVarInsn opcode = 21 , var = 1
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitVarInsn opcode = 21 , var = 2
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitInsn opcode = 96
//退出方法
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = onMethodExit
//执行ireturn
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitInsn opcode = 172
//输出visitLocalVariable内容
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitLocalVariable name = this
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitLocalVariable name = aa
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitLocalVariable name = bb
//输出 stack=2, locals=3的内容
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitMaxs maxStack = 2 , maxLocals = 3
//该方法执行完毕
~*~*~*~*~*~* DebugPlugin ~*~*~*~*~*~* = visitEnd
遗留问题:visitLabel()和visitLineNumber()还没有看明白是怎么回事
ASM之FieldVisitor和AnnotationVisitor等有需要的时候再去总结,用法应该和MethodVisitor类似
5.ASM之ClassReader和ClassWriter
ClassReader就是将.class文件以InputSteam、byte[]等形式读到ClassReader中,然后通过accept(classVisitor)方法,将对应的需要处理具体逻辑ClassVisitor传入到ClassReader中,按照顺序调用ClassVisitor中的方法。其中里面几个比较重要的方法如下:
- (1)构造函数
支持.class文件对应的InputStream、byte[]、className(即具体的类.class.getName())
- (2) accept( classVisitor, parsingOptions)
用来接收ClassVisitor解析和处理.class的信息。
第一个参数classVisitor:给定具体处理逻辑的ClassVisitor,通常需要自定义类来继承抽象类ClassVisitor.
第二个参数parsingOptions:解析.class文件的选项.其中有几个取值:
- 1)ClassReader.SKIP_CODE:跳过方法体的code属性,即Code属性下的内容不会被转换或访问;
- 2)ClassReader.SKIP_DEBUG:跳过文件中的调试信息,即源文件、源码调试扩展、局部变量表、行号表属性、局部变量表类型表这些属性,即下面的这些方法不会被调用到 @link ClassVisitor#visitSource, @link MethodVisitor#visitLocalVariable, @link MethodVisitor#visitLineNumber and @link MethodVisitor#visitParameter;
- 3)ClassReader.SKIP_FRAMES:跳过文件StackMapTable和StackMap属性,即@link MethodVisitor#visitFrame不会被方法;该属性只有在ClassWriter设置 @link ClassWriter#COMPUTE_FRAMES才会起作用;
- 4) ClassReader.EXPAND_FRAMES:跳过文件的StackMapTable属性。默认的栈图是以原始格式被访问,设置此标识栈图始终以扩展格式进行访问,大幅度降低性能。
ClassWriter将新生成的字节码写入到文件中,通过toByteArray()形式返回byte[]。
- (1)构造函数
里面的参数含义如下:
第一个参数reader :就是要处理的.class文件的ClassReader;
第二个参数 flags:就是标记符。有三种值:
1)0:不自动计算操作数栈和局部变量表的大小,需要手动指定
2)COMPUTE_MAXS和COMPUTE_FRAMES:下图是官方API文档:
从描述中可以看出:
- COMPUTE_MAXS:会自动计算操作栈数(maximum stack size)和局部变量表中的个数(maximum number of local variables)
- COMPUTE_FRAMES:不仅会自动计算操作数栈和局部变量表个数,并且还会自动计算StackMapTable
但是这些标识也会让性能损失:COMPUTE_MAXS慢10% COMPUTE_FRAMES慢2倍。
对于字节码中的代码注入,不仅注入相关的执行指令,比如方法注入,还需要堆栈帧图(StackMapTable)进行计算、栈帧中的局部变量表和操作数栈的大小。当然ClassWriter也提供了对应的flag,可以自行计算这些内容,见上面的两个flag的解释。
前面在Android Gradle之Java字节码(七)的4.运行时数据区域Runtime data area中也介绍过,Java的源码是运行在线程中,每个线程都有一个JVM栈,而栈又有多个栈帧(stack frame)组成,每个栈帧中包含着执行该方法的局部变量表、操作数栈、返回方法、动态链接。每运行一个方法都会创建一个栈帧压入到JVM栈中,当方法执行完返回的时候,就会将该栈帧出栈。
栈帧中的局部变量表和操作数栈的大小取决于方法代码。
从Java1.6以后引入的栈帧图(StackMapTable)概念:在Code属性中用来存储局部变量和操作数的类型验证以及字节码的偏移量。一个方法仅对应一个栈帧图。
遗留问题:栈帧图的概念需要在理解一下
- (2)toByteArray()
最终通过该方法将新的字节码文件转换成byte[],供写入文件。
三 实例
一般在使用ASM字节码框架往.class文件注入字节码的时候,一般需要将注入的Java代码先写出来,然后编译成.class文件,在通过前面的Android Gradle之Java字节码(七)中提到的ASMByte插件中的一些ASMified反编译.class文件,得到所需要的ASM注入代码。
1.为所有的方法添加调用日志实例
接 Android Gradle 中的Transform(六)的三 Android Transform高级应用的实例,循环input.getDirectoryInputs()中的所有.class文件,然后得到的.class文件添加ASM来处理逻辑,主要逻辑代码如下:
private void addLogForClass(String input, String output)
if (input == null || output == null)
return;
try
//1.创建ClassReader
FileInputStream is = new FileInputStream(input);
ClassReader reader = new ClassReader(is);
//2.创建ClassWriter
ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
//3.增加自定义的ClassVisitor实现为文件进行增加log
AutoLogClassVisitor classVisitor = new AutoLogClassVisitor(writer);
//4.调用reader.accept
reader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
//5.最后将修改后的.class文件重新写入该文件中
FileOutputStream fos = new FileOutputStream(output);
fos.write(writer.toByteArray());
fos.close();
catch (FileNotFoundException e)
e.printStackTrace();
catch (IOException e)
e.printStackTrace();
其实主要解析逻辑在 AutoLogClassVisitor中,然后进入到AutoLogClassVisitor中看下里面的内容:
2.AutoLogClassVisitor
访问的是所有.class文件。因为本次打印所有的方法的执行时间,那么就要循环到.class文件的所有方法,在方法上添加相应的逻辑,那么对于AutoLogClassVisitor这个类中主要就是在visitMethod()的时候,将承载着方法添加调用时间逻辑的MethodVisitor返回即可。
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions)
SystemOutPrintln.println("visitMethod access = " + access + " , name = " + name + " , descriptor = " + descriptor);
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
//TODO 这里传入ASM9为什么没有报错?
AdviceAdapter logAdviceAdapter = new AutoLogAdviceAdapter(this.api, methodVisitor, access, name, descriptor);
return logAdviceAdapter;
// return super.visitMethod(access, name, descriptor, signature, exceptions);
那么现在所有的逻辑都在 AutoLogAdviceAdapter这个类中。
3. AutoLogAdviceAdapter
该类就是当执行到.class文件的每个方法的时候,都会回调到该AutoLogAdviceAdapter中的每个回调周期方法中。具体怎么将Java源代码转换成对应的ASM框架所需要的代码可参见Android Gradle 中的使用ASMified插件生成.class的技巧(九)总结,这里仅提一点自己的逻辑处理,因为有些方法可能在执行return的时候,有一些计算公式还在占用方法执行时间,所以将方法的调用完的执行时间放到visitInsn()中执行return相关的字节码指令的时候,添加方法执行完的时间计算,而不是onMethodExit()。
相关代码已经上传到具体的代码已经上传到至https://github.com/wenjing-bonnie/AndroidPlugin.git的firstplugin目录的相关内容。因为逐渐在这基础上进行迭代,可回退到Gradle_8.0该tag下可以查看相关内容。
运行代码之后,经过编译之后的.class文件中已经添加了计算方法执行的相关代码,其中一个方法如下:
private int sum(int aa, int bb)
long beginTime = System.currentTimeMillis();
System.out.println("Other running code");
long callTime = System.currentTimeMillis() - beginTime;
Log.d("AUTO", String.format("cost time is [%d]ms", callTime));
return aa + bb;
4.Transform输出的路径
在Android Gradle 中的Transform(六)中提到了,通过Transform添加的字节码相关代码,该Transform会自动添加到build的任务队列中,那么前面在AutoLogTransform的时候,获取Transform的输出路径的时候,通过下面的代码获取:
File dest = outputProvider.getContentLocation(directory.getName(), directory.getContentTypes(), directory.getScopes(), Format.DIRECTORY);
经过编译执行该Transform对应的Task之后,
> Task :app:transformClassesWithAutoLogTransformTaskForHuaweiDebug
会在项目的app/build/intermediates/transforms按照"Transform名字/productFlavor/buildType/根据传入的是directory对应的Format.DIRECTORY或jar对应Format.JAR)"创建出下面的一系列文件或文件夹。
另外从app/build下面的文件夹几乎都是不同的Task在执行阶段创建的文件夹来生成对应的task的输出。
四 总结
稍后在总结。
以上是关于Android Gradle 中的字节码插桩之ASM的主要内容,如果未能解决你的问题,请参考以下文章