ASM介绍
ASM是一个字节码操作库,它可以直接修改已经存在的class
文件或者生成class
文件。ASM提供了一些便捷的功能来操作字节码内容。
与其它字节码操作框架(比如:AspectJ
等)相比,ASM更偏向于底层,它是直接操作字节码的,在设计上相对更小、更快,所以在性能上更好,而且几乎可以任意修改字节码。
为什么要用ASM?
我们都知道任何的字节码操作库最终都是要修改.class
文件的,在Android平台上通常是.class
转.dex
阶段。下面一起来看看.class
文件是什么样子:
左边这一堆符号就是字节码,可以看出字节码是由16进制数据组成的;而右边的部分是将16进制数转换成相对容易阅读的指令。
如何查看字节码?
由于class
文件本质是16进制
数据,所以任意的16进制编辑器都可以查看。这里提供2种查看方法:
1、可以通过16进制编辑器查看
010 Editor
2、终端命令行
#打开class文件:
vim xx.class
#然后输入,就可以显示16进制的class文件了
:%!xxd
字节码数据对应的指令可以通过javap
指令查看
javap -v xx.class
注意:网上很多文章说这是汇编指令,这种说法是严重错误的,这绝对不是汇编指令,它只不过把16进制的字节码文件代表的意思形象的表达出来,但本质上还是字节码啊;而JVM底层操作的才是汇编指令,反汇编需要专门的软件,比如:Hopper。16进制的字节码和字节码指令的关系就类似于汇编指令和2进制的机器码一样。
字节码文件是由一个很复杂的文件格式组成,如果你感兴趣,可以参考:
认识 .class 文件的字节码结构 、 Java字节码指令
如果你对class
文件格式很了解的话,你可以直接通过编辑器修改字节码文件,但是这样的要求比较高,而且操作起来极度不变,于是ASM出现了,它对字节码指令再做一层封装,对外提供了一系列ASM API。
ASM核心API讲解
ASM Core API提供了3个类来操作字节码,分别是:
ClassReader
:对具体的class
文件进行读取与解析;ClassWriter
:将修改后的class
文件通过文件流的方式覆盖掉原来的class
文件,从而实现class
修改;ClassVisitor
:可以访问class
文件的各个部分,比如方法
、变量
、注解
等,这也是修改原代码的地方。
注意:
ClassReader
解析class
文件过程中,解析到某个结构就会通知到ClassVisitor
内部的相应方法(比如:解析到方法时,就会回调ClassVisitor.visitMethod
方法);
ClassVisitor
是有一定调用顺序的:
visit
visitSource?
visitOuterClass?
( visitAnnotation | visitAttribute )*
( visitInnerClass | visitField | visitMethod )*
visitEnd
下面通过一个小案例来讲解ASM的使用流程:
打印Activity生命周期
在
start code
之前,先思考几个问题:
1、如何获取所有编译后的class文件,如何拦截,并替换他们?
2、如何修改某个class文件?
3、修改完成之后,如何替换原有的class,达到hook的目的?
下面,我们就一起来解决这几个问题。
Q1:在Android开发中,字节码插桩的时机是在apk
打包过程的class
转dex
阶段;而Gradle
已经给我们提供了一个工具在这个阶段做一些事情,比如修改class
内容,它就是Transform API
。通过Transform就可以获取到所有的.class
文件,包括jar包中的,然后你可以根据自己的需求过滤出需要的.class
文件。这一步非常关键,需要你掌握Gradle插件和Transform的知识,如果你还不太熟悉它们,可以参考:
Android 自定义Gradle插件的3种方式
Android Gradle Transform 详解
Q2:在上一步的Transform API
中可以拿到需要处理的class文件,但是并不能知道class
的具体内容和格式,所以需要借助一个工具来解析class
文件结构,这个工具就是ASM
,它不仅能解析class
,还提供了大量的API来修改class
内容,几乎所有的CRUD
操作都可以完成。
Q3:在修改完class之后,只需要按照原来文件的路径,通过FileOutputStream
文件流的形式去覆盖原文件即可。
下面开始代码详解,由于本文着重讲解ASM Core API相关的内容,所以Gradle插件和Transform的内容不会讲的特别细致,但是关键部分和流程还是会讲的。
1、这里用buildSrc
的模式来定义插件,首先定义插件实现类
就是注册了Transform实现类而已
class CustomPlugin implements Plugin<Project>{
@Override
void apply(Project project) {
AppExtension appExtension= project.extensions.getByType(AppExtension)
//注册Transform
appExtension.registerTransform(new MyTransform())
}
}
2、Transform实现类
遍历class文件,查找目标class,便于后面的修改
class MyTransform extends Transform {
@Override
String getName() {
return "MyTransform"
}
//输入文件类型,有CLASSES和RESOURCES
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
// 指Transform要操作内容的范围,官方文档Scope有7种类型:
// EXTERNAL_LIBRARIES 只有外部库
// PROJECT 只有项目内容
// PROJECT_LOCAL_DEPS 只有项目的本地依赖(本地jar)
// PROVIDED_ONLY 只提供本地或远程依赖项
// SUB_PROJECTS 只有子项目。
// SUB_PROJECTS_LOCAL_DEPS 只有子项目的本地依赖项(本地jar)。
// TESTED_CODE 由当前变量(包括依赖项)测试的代码
// SCOPE_FULL_PROJECT 整个项目
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
//指明当前Transform是否支持增量编译
@Override
boolean isIncremental() {
return false
}
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation)
//inputs中是传过来的输入流,其中有两种格式,一种是jar包格式一种是目录格式。
def inputs = transformInvocation.getInputs()
//获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做不然编译会报错
def outputProvider = transformInvocation.getOutputProvider()
for (TransformInput input : inputs) {
//处理Jar中的class文件
for (JarInput jarInput : input.getJarInputs()) {
File dest = outputProvider.getContentLocation(
jarInput.getName(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyFile(jarInput.getFile(), dest);
}
//处理文件目录下的class文件
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
...省略ASM相关操作
File dest = outputProvider.getContentLocation(
directoryInput.getName(),
directoryInput.getContentTypes(),
directoryInput.getScopes(),
Format.DIRECTORY);
//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyDirectory(directoryInput.getFile(), dest)
}
}
}
}
这里最核心的就是transform()
方法了,通过它可以获取要处理的源文件。这里只是把目标源文件拷贝到目标路径。
注意:
1、由于getScopes()
方法设置的是对整个项目处理,所以需要同时处理jar包和文件目录。实际上如果你设置的是PROJECT_ONLY
,那可以只处理文件目录而不用管jar包;
2、文件拷贝这一步必须要做,不然会出现找不到文件的异常
到这里都还只是Transform
的内容,下面把class
修改的部分加上去,也就是ASM Core API
相关的部分。
在transform
方法中,针对文件目录做如下修改,其余地方不变
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
handleDirectoryInput(directoryInput, outputProvider)
}
/**
* 处理文件目录下的class文件
*/
void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
//列出目录所有文件(包含子文件夹,子文件夹内文件)
directoryInput.file.eachFileRecurse { File file ->
def fileName = file.name
if (checkClassFile(fileName)) {
System.out.println('filename----' + fileName)
//对class文件进行读取与解析
ClassReader classReader = new ClassReader(file.bytes)
//对class文件的写入
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
//访问class文件相应的内容,解析到某一个结构就会通知到ClassVisitor的相应方法
ClassVisitor classVisitor = new LifecycleClassVisitor(classWriter)
//依次调用 ClassVisitor接口的各个方法
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
//toByteArray方法会将最终修改的字节码以 byte 数组形式返回。
byte[] bytes = classWriter.toByteArray()
//通过文件流写入方式覆盖掉原先的内容,实现class文件的改写。
// FileOutputStream outputStream = new FileOutputStream( file.parentFile.absolutePath + File.separator + fileName)
//这个地址在javac目录下
FileOutputStream outputStream = new FileOutputStream(file.path)
outputStream.write(bytes)
outputStream.close()
}
}
//Transform 拷贝文件到transforms目录
File dest = outputProvider.getContentLocation(
directoryInput.getName(),
directoryInput.getContentTypes(),
directoryInput.getScopes(),
Format.DIRECTORY);
//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyDirectory(directoryInput.getFile(), dest)
}
/**
* 检查class文件是否符合条件
* @param name
* @return
*/
boolean checkClassFile(String name) {
return name.endsWith("Activity.class")
}
代码详解:
1、通过DirectoryInput
遍历所有的文件,如果有子文件夹,也会找到其包含的class文件,然后只处理其中的Activity
文件(我这里是通过文件名来过滤的,可能不是很严谨,不过不影响大局,后面会说更好的方式)。
2、在找到目标Activity
文件之后,就可以做字节码操作相关的事情了。先来说一下大体的流程:
- 2.1、
ClassReader
:对class
文件进行读取与解析; - 2.2、
ClassWriter
:对class
文件写入; - 2.3、
ClassVisitor
:可以访问class
文件的各个部分,在ClassReader
解析到某一个结构就会通知到ClassVisitor
的相应方法,比如解析到方法会回调ClassVisitor#visitMethod
,其它属性、注解等也有对应的方法; - 2.4、
classReader.accept
:依次调用ClassVisitor
的各个方法 - 2.5、
classWriter.toByteArray()
:将最终修改的字节码以 byte 数组形式返回。 - 2.6、
FileOutputStream
:通过文件流写入方式覆盖掉原先的内容,实现class文件的改写。
ClassVisitor
public class LifecycleClassVisitor extends ClassVisitor {
private String className;
public LifecycleClassVisitor(ClassVisitor cv) {
/**
* 参数1:ASM API版本,源码规定只能为4,5,6
* 参数2:ClassVisitor不能为 null
*/
super(Opcodes.ASM6, cv);
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
this.className = name;
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
System.out.println("ClassVisitor visitMethod name-------" + name);
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
if (name.startsWith("on")) {
//处理onXX()方法
return new LifecycleMethodVisitor(mv, className, name);
}
return mv;
}
@Override
public void visitEnd() {
super.visitEnd();
}
}
这个类很好理解,就是访问类的时候会调用它,在解析到class的各部分的时候会调用visitXX()方法,而且这些方法的调用是有顺序的,更多详细的解释可以参考:ASM框架学习(二)-ClassVisitor
在这里,我只想在Activity
生命周期的方法里插入一句代码,所以我只关注visitMethod()
方法即可这里通过方法名找到对应的方法,然后就可以完成代码的插入了。这个方法返回了一个MethodVisitor
对象,如果你要修改方法的内容就会用到它,
MethodVisitor
public class LifecycleMethodVisitor extends MethodVisitor {
private String className;
private String methodName;
public LifecycleMethodVisitor(MethodVisitor methodVisitor, String className, String methodName) {
super(Opcodes.ASM6, methodVisitor);
this.className = className;
this.methodName = methodName;
}
// @Override
// public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
// System.out.println("MethodVisitor visitAnnotation desc------"+desc);
// System.out.println("MethodVisitor visitAnnotation visible------"+visible);
// AnnotationVisitor annotationVisitor = mv.visitAnnotation(desc, visible);
// if (desc.contains("CheckLogin")){
// return new TestAnnotationVistor(annotationVisitor);
// }
// return annotationVisitor;
// }
//方法执行前插入
@Override
public void visitCode() {
super.visitCode();
System.out.println("MethodVisitor visitCode------");
mv.visitLdcInsn("TAG");
mv.visitLdcInsn(className + "------->" + methodName);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);
}
//方法执行后插入
@Override
public void visitInsn(int opcode) {
// if (opcode==Opcodes.RETURN){
// mv.visitLdcInsn("TAG");
// mv.visitLdcInsn(className + "------->" + methodName);
// mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
// mv.visitInsn(Opcodes.POP);
// }
super.visitInsn(opcode);
System.out.println("MethodVisitor visitInsn------");
}
@Override
public void visitEnd() {
super.visitEnd();
System.out.println("MethodVisitor visitEnd------");
}
}
MethodVisitor代码详解:
这是真正修改字节码内容的地方,这个类也有一系列visitXX()
方法,并且是按顺序执行的,这里着重讲3个方法,更多详情参考:
ASM框架学习(三)-FieldVisitor和MethodVisitor
visitCode
:
表示开始访问方法,表示方法执行前插入。
这段代码实际上大概是这样
Log.e("TAG", "MainActivity------->onCreate()");
visitCode()中的这段代码,大家应该有点印象,在文章开头讲解class文件结构的时候展示过字节码指令的格式。之前有讲过,class文件本质上是16进制数据,为了更好的理解16进制数的意义,出现了字节码指令;而ASM框架就是对字节码指令再做了一次封装,于是乎你在这里看到了一些指令相关的内容
说了这么多,你可能还是会说,这个ASM API也太复杂了,简直难以理解,更不用说自己手写了。的确是这样的,如果你写过汇编代码,你会更加绝望,本来几行java代码,用汇编指令写出来可能需要几十上百行;而字节码指令虽然难度有所降低,但对于很多应用层的开发者而言,仍然是很有难度的,不过还好有这么一款工具,可以把我们写的Java代码转换成对应的ASM代码,待会儿会说。
visitInsn
:
访问零操作指令,比如访问return
指令。
如果想在方法最后织入代码,写在visitEnd
方法内是无效的,因为回调它的时候方法已经访问结束了。但是有一个迂回的解决办法,方法执行结束前都会有一个return指令,即使你的方法返回值为void
,那编译成字节码时会默认补上一个return
指令。
所以在方法末尾织入代码可以这样写:
//方法执行后插入
@Override
public void visitInsn(int opcode) {
if (opcode==Opcodes.RETURN){
mv.visitLdcInsn("TAG");
mv.visitLdcInsn(className + "------->" + methodName);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);
}
super.visitInsn(opcode);
System.out.println("MethodVisitor visitInsn------");
}
关于指令
return指令根据返回对象的类型不同,会有不同的指令,比如:
-
ireturn
返回值类型为int -
lreturn
返回值类型为long -
areturn
返回值类型为对象类型
这里的指令范围非常广,这些指令常量被封装到Opcodes
类中,你可以自行查看。
ASM Bytecode Outline插件的使用
刚才说过写ASM API是一件非常麻烦的事情,但是可以借助ASM Bytecode Outline插件来完成。
1、在Android Studio中搜索安装ASM Bytecode Outline
2、使用的时候先写出对应的Java代码,然后右键,选择Show Bytecode outLine,把相应的asm代码拷贝出来即可;
到这里,ASM的完整流程就算结束了。
总结
1、ASM框架入门并不难,但是也不简单,对基础要求比较高,至少你要掌握APK打包流程、自定义Gradle插件、Transform API以及AOP思想
2、使用感受
缺点:如果你用过其它AOP框架,比如AspectJ
,再来用ASM,你应该会感觉到很难受、不好用,因为它太复杂了,编写一个ASM工程对代码量怕是其它aop框架的几倍。原因也很简单,它是直接操作字节码指令的,这可是直接和JVM虚拟机打交道的底层内容,能不难吗?
优点:足够强大,几乎所有的CRUD操作都可以完成。由于是直接操作字节码,所以在效率上会比其它框架更高,注意:性能上没什么影响,因为是在编译期完成的。很多上层框架是用ASM作为底层技术的,比如Groovy、cglib等