简介
ASM插桩在网上其实已经有很多资料了,我之所以再写这篇文章呢,一是因为好久前学习的ASM,现在已经忘的差不多了,需要再回顾一下,二来是记录一下学习过程,以后如果再有细节记不清楚可以很方便的就能查到,三来再学习的过程中也踩了一些坑,收获了一些心得,这些也需要一个地方记录一下。
好了,废话就说到这里,接下来开始正文。
插桩技术指在保证原有程序逻辑完整性的基础上,在程序中插入探针,通过探针采集代码中的信息(方法本身、方法参数值、返回值等)在特定的位置插入代码段,从而收集程序运行时的动态上下文信息。
插桩技术大体可以分为两类:
APT
(Annotation Process Tools),在编译的时候,动态生成Java
文件,之后编译器将生成的Java
文件编译成class
文件,像ButterKnife
、Dagger
就是通过APT
的方式生成代码的。- 代表工具:
ButterKnife
- 代表工具:
AOP
(Aspect Oriented Programming),生成class
文件后,修改class
文件的字节码,达到修改代码的目的。- 代表工具:听云
工具
我们这次选用AOP
技术,我们看看有哪些工具可以帮助我们完成插桩工作:
AspectJ
,成熟稳定,使用者不需要对字节码文件有深入的理解,使用简单。但是其切入点相对固定,对于字节码文件的操作自由度以及开发的掌控度就大打折扣。并且,他会额外生成一些包装代码,对性能以及包大小有一定影响。ASM
,可以修改现有的字节码文件,也可以动态生成字节码文件,完全从字节码去操作字节码的框架,更加灵活,功能更加强大,可以根据需求自定义修改、插入、删除,性能也十分出色,但是要对字节码文件有比较深入的了解,上手也更难。
我们使用ASM
来完成插桩,在介绍Android
字节码插桩之前,需要先了解一下Java
字节码的概念和Android
程序打包过程。
字节码
我们知道,Java
程序是运行在JVM
(Java
虚拟机)上的,Java
源代码首先会由编译器(Java Compiler
)编译成包含了Bytecode
(字节码)的.class
文件,程序执行时,由类加载器(class loader
)将该类的字节码加载到JVM
中,JVM
会解释执行相应的Bytecode
。如下图所示:
为什么不直接彻底编译成机器码,而需要字节码这个中间产物呢?Java
是一门跨平台的语言,为了实现一份源码,处处运行的效果,每个平台都有对应不同的JVM
,它会将源码对应的指令翻译成对应平台能够理解的机器指令。那为什么不从源码直接解释执行呢,我个人认为这是因为直接从源码开始的编译,速度非常慢,出于性能的考虑,先将源码做一些预处理,处理为字节码,来减轻运行前的编译的性能开销。
在做插桩之前,我们先要记住一点:Java
字节码指令是基于堆栈操作的,因为大部分的Java
虚拟机对字节码的执行是基于堆栈的(Android
的Dalvik
虚拟机是基于寄存器的,不过不影响我们的插桩,因为在我们对java
字节码插完桩后,才会执行从java
字节码转换到dex
文件的过程)
Android打包过程
Android插桩过程
实战
这次,我们模仿听云,做一个Activity
生命周期执行时间检测的插件。
我们先梳理一下功能点:
- 针对
Activity
类 - 针对生命周期方法
- 支持插件自定义配置
我们用Java
代码把我们想要插入的逻辑写一遍:
1 | public class Test { |
接下来正式开始编写插件
新建插件工程
由于Android Studio
没有新建gradle
脚本的选项,我们先新建一个Empty Activity Project
,在此基础上进行改造。
- 新建
module
- 更改
module
的build.gradle
文件 - 新建
groovy
源代码目录 - 新建
groovy
类实现Plugin<Project>
接口 - 新建
resource/META_INF/xxx.properites
文件(xxx为插件的id名) - 在
properites
文件中声明插件的实现类
为插件提供可配置的功能
- 新建一个实体类用来保存配置信息
1 | public class AsmConfigModel { |
- 在插件
apply
的时候创建这个配置类,以提供给使用者配置
1 |
|
- 在使用该插件的
module
下的build.gradle
文件中配置
1 | asmConfig { |
- 新建asm-excludes.txt文件,配置exclude信息
1 | com/xxx/xxx/BaseActivity |
这里是举个例子,在工程中很有可能有的Activity
继承自一些基类Activity
,对这些类插桩就重复了
使用Transform Api
根据官网介绍,Transform Api
允许第三方 Plugin
在打包 dex
文件之前的编译过程中操作.class
文件,下图是Transform Api
的工作流程
可以看到,一次App
的编译打包可能会经历多次Transform
,Transform
将输入进行处理,然后写入到指定的目录下作为下一个 Transform
的输入源。
使用插桩工具,我们需要借助于Transform Api
实现
- 首先,我们需要让我们的插件继承自
Transform
- 然后,我们要在插件
apply
时注册Transform
1 |
|
- 最后,需要实现
Transform
类中的抽象方法
getName
这个方法是指定这个Transform
的名称
1 |
|
getInputTypes
这个方法是指定输入类型
这里,我们选用TransformManager.CONTENT_CLASS
就可以了
getScopes
这个方法是指定插桩的作用域
这里我们选择TransformManager.SCOPE_FULL_PROJECT
,代表插桩范围包括此工程和它依赖的所有包
isIncremental
这个方法代表是否开启增量编译
如果开启的话可以减少编译时间,但需要增加额外的判断条件,所以这里就先不开启了
transform
这个方法是核心方法,我们要对输入内容进行处理然后输出
transform()
方法的参数 TransformInvocation
是一个接口,提供了一些关于输入输出的一些基本信息。下图是transform
中我们需要走的流程
这里以directoryInputs
举例,directoryInputs
就是本地源码编译后产生的class
文件
1 | private void handleDirectory(DirectoryInput input, TransformOutputProvider outputProvider) { |
可以用以下流程图大概描述一下一个class
文件的修改过程
自定义ClassVisitor
我们开始继承ClassVisitor
来实现我们对类的修改
读取配置
访问类
通过这个方法我们可以获得这个类的访问控制,全限定类名,父类名,实现的接口名等信息
这里,我们通过全限定类名和读取出的配置做比对,进一步验证是否需要对此类进行插桩
访问类内方法
通过这个方法我们可以获得这个类的所有方法的名称和描述符,我们通过它们来判断该方法是否需要插桩
如果有需要插桩的方法,就将mNeedStubClass
标志位置为true,这个标识是为了我们后续判断是否要在该类中插入成员变量,然后使用我们自定义的MethodVisitor
替换原始的MethodVisitor
。
插入成员变量
在最后,如果有需要插桩的方法,我们需要将private long _$_timeRecorder
这个成员变量插入到类中去
自定义MethodVisitor
之前说了,Java
字节码指令是基于栈操作的,基本上任何操作都会改变栈状态
在方法执行之前插入代码
1 | /** |
在方法return之前插入代码
1 | /** |
这样我们的方法插桩工作就完成了,接下来我们运行一下看看
运行
先clean build
,再build
,查看控制台信息,build
完成后查看class
文件
运行App
,查看Logcat
信息,可以看到打印出来了我们想要的信息。
结语
这样我们就通过插桩的方式,实现了一个简单的无任何代码侵入的性能检测工具
通过这一次实践,我对java
的编译运行字节码,Android
的打包流程有了更深的理解