【Android】跟我一起用 ASM 实现编译期字节码插桩


我的博客地址:http://blog.N0tExpectErr0r.cn

本文 Demo 地址:https://github.com/N0tExpectErr0r/Elapse

起因

这两天摸鱼的时候,突然发现 Jake Wharton 大神写的 Hugo 非常有意思,通过这个库可以实现对方法调用的一些相关数据进行记录。比如它可以通过在方法前加上 DebugLog 注解使得该方法执行时在 Logcat 中打印这个方法的入参、耗时时间、返回值等等。

比如在代码中加入下面这样一个简单的注解:

就可以实现在 Logcat 中打印如下的日志:

这个库的设计思路非常有趣,通过这样一种注解的形式可以很方便地打印调试信息,相比直接修改代码实现来说极大地降低了侵入性。经过查阅资料了解到 Hugo 是基于 AspectJ 所实现的,其核心原理就是编译期对字节码的插桩。刚好笔者前两天在项目中通过 ASM 字节码插桩实现了对 View 的点击事件的无痕埋点,因此突发奇想,想通过 ASM 实现一个类似功能的库。

但 Hugo 仅仅提供了打印方法执行相关信息的功能,因此就开始思考是否能够基于它的思路进行一些扩展,实现在方法调用前后执行指定逻辑的功能呢?

如果能实现这样一个库,那对于 Hugo 的功能,我们就只需要在方法调用前记录时间,在方法调用后计算时间差即可。

同时如果还需要一个统计应用中某个方法调用次数的功能,也只需要在方法调用时执行计数的逻辑即可。

这样的实现好处就在于便于扩展,对方法调用的前后进行了监听,而具体的执行逻辑可以由使用者来自己决定。如果对这个功能的实现感兴趣,就请跟着我继续看下去吧。

基本原理

首先,我们需要了解一下什么是 ASM,ASM 是一个 Java 字节码层面的代码分析及修改工具,它有一套非常易用的 API,通过它可以实现对现有 class 文件的操纵,从而实现动态生成类,或者基于现有的类进行功能扩展。

这时候可能有读者会问了,ASM 是操纵 class 文件的,但 Apk 里面的不都是 dex 文件么?这不就没办法应用到安卓中了么?

其实在 Android 的编译过程中,首先会将 java 文件编译为 class 文件,之后会将编译后的 class 文件打包为 dex 文件,我们可以利用 class 被打包为 dex 前的间隙,插入 ASM 相关的逻辑对 class 文件进行操纵。

image-20190808150002681

前面的思路很简单,但该如何才能做到在 class 文件被打包前执行我们 ASM 相关的代码呢?

Google 在 Gradle 1.5.0 后提供了一个叫 Transform 的 API,它的出现使得第三方的 Gradle Plugin 可以在打包 dex 之前对 class 文件进行进行一些操纵。我们本次就是要利用 Transform API 来实现这样一个 Gradle Plugin。

实现思路

有了前面提到的基本原理,让我们来思考一下具体的实现思路。

思路其实非常简单,这就是一种典型的观察者模式。我们的用户对某个方法的调用事件进行订阅,当方法被调用时,就会通知用户,从而执行指定的逻辑。

我们需要一个方法调用事件的调度中心,订阅者可以向该调度中心订阅某类型的方法的调用事件,每当带有指定注解的方法有调用事件产生时,都会通知该调度中心,然后由调度中心通知对应类型的订阅者。

这样的话,我们只需要在方法的调用前后,通过 ASM 织入通知调度中心的代码即可。

image-20190808160311489

Show me your code

有了思路,我们可以开始正式码代码了,这里我建立了一个叫 Elapse 的项目。(不要问为什么,就是因为好看)

准备工作

我们先进行一些准备工作——建立 ASM 插件的 module,清空自动生成的 gradle 代码,将 gradle 按如下方式编写:

同时我们需要一个注解来标注需要被插桩的方法。我们采用了如下的一个编译期的注解,其含有一个 tag 参数用于表示该方法的 TAG,通过这个 TAG 我们可以实现针对不同方法的不同处理。

之后我们再创建一个 MethodEventManager,用于注册及分发方法调用事件:

这里代码不是很复杂,主要对外暴露了三个方法:

  • registerMethodObserver:用于向其注册某个 TAG 对应的监听
  • notifyMethodEnter:用于通知对应 TAG 的监听该方法调用
  • notifyMethodExit:用于通知对应 TAG 的监听该方法退出

有了这样一个类,我们就只需要在代码编辑的时候向包含注解的方法的开始与结束处织入对应的代码就好,就像下面这样:

Transform 的编写

之后我们建立一个继承自 Transform 的类 ElapseTransform

这里需要我们实现四个方法,我们分别介绍一下:

  • getName:当前 Transform 的名称
  • getInputTypes:Transform 要处理的数据类型,是一个 ContentType 的 Set,其中 ContentType 有下列取值:
    • DefaultContentType.CLASSES:要处理编译后的字节码文件(jar 包或目录)
    • DefaultContentType.RESOURCES:要处理标准的 Java 资源
  • getScopes:Transform 的作用范围,是一个 Scope 的 Set,其中 Scope 有以下取值:
    • PROJECT:只处理当前项目
    • SUB_PROJECTS:只处理子项目
    • PROJECT_LOCAL_DEPS:只处理当前项目的本地依赖,例如 jar, aar
    • EXTERNAL_LIBRARIES:只处理外部的依赖库
    • PROVIDED_ONLY:只处理本地或远程以 provided 形式引入的依赖库
    • TESTED_CODE:只处理测试代码
  • isIncremental:是否支持增量编译

这里我们指定的 TransformManager.CONTENT_CLASS 表示处理编译后的字节码文件,而 TransformManager.SCOPE_FULL_PROJECT 表示作用于整个项目,它们都是 TransformManager 预置好的 Set。

当调用该 Transform 时,会调用其 transform 方法,我们在里面就可以进行 class 文件的查找,然后对 class 文件进行处理:

这里我先通过 transformInvocation.getInputs 获取到了输入,这种输入是消费型输入,需要传递给下一个 Transform,其中包含了 jar 文件与 directory 文件。

然后对 inputs 进行遍历,分别获取其中的 jar 列表以及 directory 列表,再对其进行遍历,分别对 jar 文件及 directory 调用了 transformJartransformDirectory 方法。

class 文件的寻找

jar

对于 jar 文件来说,我们需要遍历其中的 JarEntry,寻找 class 文件,对 class 文件修改后写入一个新的临时 jar 文件,编辑完成后再将其复制到输出路径中。

可以看到,这里主要是以下几步:

  1. 通过 getContentLocation 方法获取到了输出路径,
  2. 构建了一个临时的输出 jar 文件
  3. 遍历原 jar 文件的 entry,将其中的 class 文件调用 modifyClass 进行修改,然后放入该临时 jar 文件
  4. 将该临时 jar 文件复制到输出路径。

这样就对 jar 文件中的所有 class 文件进行了修改。

directory

对于 directory 来说,我们对其中的文件进行了递归遍历,找到 class 文件则将其修改后放入 Map 中,最后将 Map 中的元素复制到了输出路径下。

具体逻辑不是很复杂,主要就是找出 class 文件并调用 modifyClass 文件对其进行操作。如果对具体代码感兴趣的读者可以到 GitHub 查看源码。

通过 ASM 织入代码

下面就到了我们最关键的地方,需要我们通过 ASM 来对指定类进行修改了。真正对 class 进行处理的逻辑在 modifyClass 方法中。

我们首先需要用到 ASM 中的 ClassReader,通过它来解析一些我们 class 文件中所包含的信息。

之后我们需要一个 ClassWriter 类,通过它可以实现 class 文件中字节码的写入。

之后,我们自定义了一个 ElapseClassVisitor,通过 ClassReader.accept 方法使用前面的自定义 ClassVisitor 对这个 class 文件进行『拜访』,在拜访的过程中,我们就可以插入一些逻辑从而实现对 class 文件的编辑。

其实 ClassWriter 也是 ClassVisitor 的实现类,我们只是通过 ElapseClassVisitor 代理了 ClassWriter 而已。

由于我们主要是要对方法进行织入代码,因此在该 ClassVisitor 中我们不需要做太多的事情,只需要在 visitMethod 方法调用也就是方法被调用的时候,返回我们自己实现的 ElapseMethodVisitor 从而实现对方法的织入即可:

这里实际上 ElapseMethodVisitor 并不是 MethodVisitor 的子类,而是 ASM 提供的一个继承自 MethodVisitor 的类 AdviceAdapter 的子类,通过它可以在方法的开始、结尾等地方插入自己需要的代码。

这里我们保存了 methodVisitormethodName,前者是为了后期通过它来对 class 文件进行织入代码,而后者是为了在后期将其传递给 MethodEventManager 从而进行通知。

注解处理

接下来,我们可以通过重写 visitAnnotation 方法来在访问方法的注解时进行处理,从而判断该方法是否需要织入,同时获取注解中的 tag。

这里首先判断了注解的签名是否与我们需要的注解 TrackMethod 相同(具体签名规则这里不再介绍,可以自行百度,其实就是方法签名那一套,注意里面的分号)

若该注解是我们所需要的注解,则将 needInject 置为 true,同时从该注解中获取 tag 的值,

这样我们在后续就只需要判断是否 needInject 就能知道哪些方法需要被织入了。

代码的织入

接下来我们就可以正式开始织入工作了,我们可以通过重写 onMethodEnter 以及 onMethodExit 来监听方法的进入及退出:

两段代码及其相似,只是最后调用的方法名不同,所以这里仅仅以 handleMethodEnter 举例。

在 ASM 中,通过 MethodWriter.visitMethodInsn 方法可以调用类似字节码的指令来调用方法。比如

visitMethodInsn(INVOKESTATIC, 类签名, 方法名, 方法签名);

这样的方式就可以调用一个类下的 static 方法。如果这个方法需要参数,我们可以通过 visitVarInsn 方法来调用如 ALOAD 等指令将变量入栈。整个过程其实是与字节码中的调用形式比较类似的。

如果只是调用一个 static 方法还好,但我们这里是需要调用一个单例类下的具体方法,如

MethodEventManager.getInstance().notifyMethodEnter(tag, methodName);

这样的代码恐怕除了对字节码很熟悉的人很难有人能直接想到它用字节码如何表示了。我们可以通过以下的两种方法来解决:

1. 通过 javap 查看字节码

因此我们可以写个单例的调用 Demo,之后通过 javap -v 来查看其生成的字节码,从而了解到调用的字节码大概是一个怎样的顺序:

image-20190808210642725

可以很明显的看到,这里先通过 INVOKESTATIC 调用了 getInstance 方法,然后通过 LDC 将两个字符串常量放置到了栈顶,最后通过 INVOKEVIRTUAL 调用 notify 方法进行最后的调用。

那我们可以模仿这个过程,调用 ASM 中的对应方法来完成类似的过程,于是写出了如下的代码,其中 visitLdcInsn 的效果类似于字节码中的 LDC。

这样,就可以织入我们想要的代码了。

2. 通过 ASM Bytecode 插件查看

前面这种通过字节码查看的过程确实比较麻烦,因此我们还有另外的一种方法来简化这个步骤,有大神写了一个名为 「ASM Bytecode outline」的 IDEA 插件,我们可以通过它直接查看对应的 ASM 代码。

安装该插件后,在需要查看的代码上 点击右键-Show ByteCode 即可查看对应的 ASM 代码,效果如下:

image-20190808211654952

我们从中提炼出自己需要的代码即可。

两种方法各有优劣,读者可以根据自己的需求使用不同的方式实现。

通过前面的一系列步骤,这个 ASM 织入的核心功能我们就已经实现了,如果还需要获取函数的参数等扩展,只需要知道对应的字节码实现,剩下的都很容易实现,这里由于篇幅有限就不细讲了。

打包为 Gradle 插件

接下来我们来进行最后的一步,将这个库打包为一个 Gradle Plugin,我们新建一个 ElapsePlugin 类,继承自 Plugin,并在其中注册我们的 ElapseTransform

之后我们在 build.gradle 中加入如下的 gradle 代码,描述我们 pom 的信息:

最后我们在 src/main 下新建一个 resources/META-INF/gradle-plugins 文件夹,在该文件夹下建立 <插件名>.properties 文件。

在该文件中,按如下的方式填写:

implementation-class = ,比如我这里就是 implementation-class = com.n0texpecterr0r.elapseasm.ElapsePlugin

这样,我们就能够通过运行 uploadArchives 这个 Gradle 脚本来生成对应的 jar 包了。到此为止,我们的函数调用插桩的 Gradle Plugin 就开发完成了。

效果展示

我们可以在需要使用的项目中将其添加到 classpath 中:

之后在 app module 下将其 apply 进来:

我们可以写一个 Demo 测试一下效果:

运行程序,可以发现,Logcat 中成功打印了我们需要的信息:

image-20190809110945193

也就是说,我们的代码被成功到字节码中了。让我们看看编译后生成的字节码,我们可以打开 elapse-demo/build/intermediates/transforms/ElapseTransform/debug/33/MainActivitiy.class

image-20190809111231768

看得出来,我们的代码被成功地插入了字节码中。

实现 Hugo

我们接下来通过它来尝试实现 Hugo 的打印方法耗时功能,可以新建一个 TimeObserver

这里我们以 tag + methodName + currentThread.name 来作为 key,避免了多线程下的调用导致的干扰,在方法进入时记录下开始时间,退出时计算时间差即可得到方法的耗时时间。

我们在 Application 中对其进行注册后,就可以在运行后看到效果了:

image-20190809112720691

我们开 10 个线程,来分别运行 test ,我们可以看看效果:

可以看到,仍然可以正常统计方法的调用时间:

image-20190809113050802

总结

通过 ASM + Transform API,我们可以很方便地在 class 被打包为 dex 文件之前对字节码进行编辑,从而在代码的任意位置插入我们需要的逻辑,本文只是一个小 Demo 的演示,从而让读者们能够了解到 ASM 的强大。通过 ASM 能够实现的功能其实更加丰富。目前在国内关于 ASM 的相关文章还比较匮乏,如果想要进一步了解 ASM 的功能,读者们可以到这里查看 ASM 的官方文档。

其实本文的 Demo 还有更多功能可以扩展,比如函数参数及返回值的信息的携带,对整个类的方法进行插桩等等,读者可以根据已有知识,尝试对这些功能进行扩展,由于篇幅有限这里就不再赘述了,本质上都是插入对应的字节码指令。


Android Developer in GDUT