简介
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的打包流程有了更深的理解
