从喝水到学会 Android ASM 插桩

2,550 阅读34分钟

首先,老手请绕道。

header

很久前点开了一篇 ASM 的文章,很长,不少陌生的术语,对彼时的我来说有点复杂,大概翻了翻后:噢,一个可以修改字节码的东西。厉害!但反正我用不上,便关掉了页面,往后我对 ASM 的认识一直停留于此。

后来,工作学习中常听到

  • Transform 接口废弃(尤其是 AGP 升级 7.0 那会)
  • 无痕埋点
  • 插桩

一直听说这些词,但却不明所以。好吧,是时候学习一下了。

什么是"插桩"与 "AOP"?

当我大概知道 ASM 是一个修改字节码的工具后,最让我好奇的不是怎么去使用它,反倒是大量出现在相关文章里的两个词——“插桩”、“AOP 切面编程”。

我一直都有个疑问,为什么修改字节码叫作“插桩”而不是“插码”?AOP 切面编程又是什么意思?

插桩 Instrumentation

"插桩" 对应的英文是 "Instrumentation",这个词是 Instrument 的变体/衍生词。

"Instrument" 是“仪器”、“仪表”的意思(比如飞机驾驶舱里的各种仪表盘)

instrumentation.png

至于 "Instrumentation",其本意是“给……装上仪器”这个动作。比如给工厂的管道装上压力计和温度计,以便于监控它的运行状态,这个过程就叫做 "Instrumentation"。

这个词被软件行业借用过来,意思是:给“程序”装上“监控仪器”(也就是额外的代码),以便监控它的运行状态。

那“插桩”又该怎么理解呢?

  • “插”:插入,这个很好理解,修改字节码就是把代码“插”进入;
  • “桩”:桩子,木桩,其实就是上面“仪器”的隐喻,在编程里指的是我们插入的代码。

想象一下,你现在是一个土木工程师,要测量一条河流的几个关键点的水位变化,或者要标记一块地的重要坐标。你会在这些关键点“插入一个个“桩子”(测量桩 / 标记桩)。

在软件工程里,我们的“河流”就是程序的执行流。我们想要监控的“关键点”一般是:

  • 方法出/入口
  • 一个循环的内部
  • 异常被抛出的地方

我们插入的代码,就像一个个“桩子”,被安插在这些关键点上,起到“测量”或“标记”的作用(比如:记录方法耗时、打印日志、上报分析数据)。


那如果我疯了,我非要用书写字节码的方式来实现业务逻辑,比如判断用户输入的字符串是否为手机号,这个过程算不算“插桩”?从广义上说,算。但 "插桩" 这个词,它侧重强调的是插入代码的“目的”。"插桩" 插入的代码(那个“桩”),其目的不是为了实现业务逻辑,而是为了:

  • 监控 (Monitoring): 比如 APM 里的性能监控;
  • 调试 (Debugging): 比如打印日志;
  • 分析 (Analytics): 比如埋点上报;
  • 控制 (Control): 比如在方法执行前进行权限检查(AOP 的一种体现);

所以,"插桩" 特指那些为了“监控和测量”而插入的非业务代码。

AOP 切面编程

AOP 即“面向切面编程”,它是一种编程思想,就像 OOP 面向对象编程一样。

我们先不纠结于“切面”这个词,它听起来和“插桩”一样抽象。我们先来看一个面向对象编程中很常见的“痛点”。

我们都知道,面向对象的核心思想是“封装、继承、多态”。它允许我们将数据(属性)和操作这些数据的逻辑(方法)“封装”到一个“类”(Class)里。

比如,下面定义一个 Person 类,它封装了 name 属性和 eat() 方法。然后再定义一个 Student 类,它继承自 Person 类,并封装了 study() 方法。

open class Person(val name: String) {
  fun eat() {
    println("$name 正在吃饭...")
    Thread.sleep(100) // 模拟吃饭耗时
  }
}

class Student(name: String, val studentId: String) : Person(name) {
  fun study() {
    println("$name 正在学习...")
    Thread.sleep(200) // 模拟学习耗时
  }
}

PersonStudent 之间形成了一条清晰的垂直继承链。Student 是一个 Person,它在 Person 的基础上“垂直”地构建了更具体的功能。

简单来说,OOP 帮我们把系统拆分成了很多个“垂直”的模块(User 模块、Order 模块...)。每个模块各司其职,管理好自己的数据和业务逻辑即可。

这在绝大多数情况下都是 OK 的,但总有一些“不合群”的逻辑,它们很难被归类到某一个具体的业务模块里。假设产品经理要求我们为这两个(将来可能还有几百个)方法添加耗时监控。

不借助 AOP 我们很可能会这么写:

open class Person(val name: String) {
  fun eat() {
+   val startTime = System.currentTimeMillis()
    println("$name 正在吃饭...")
    Thread.sleep(100) // 模拟吃饭耗时
+   val duration = System.currentTimeMillis() - startTime
+   Log.d("PERF", "eat() 耗时: ${duration}ms")
  }
}

class Student(name: String, val studentId: String) : Person(name) {
  fun study() {
+   val startTime = System.currentTimeMillis()
    println("$name 正在学习...")
    Thread.sleep(200) // 模拟学习耗时
+   val duration = System.currentTimeMillis() - startTime
+   Log.d("PERF", "study() 耗时: ${duration}ms")
  }
}

“耗时监控”这个功能,它并不属于 Person / Student 的核心业务,但它却像牛皮癣一样,侵入并散落在了各个业务模块中。这导致了:

  1. 代码重复:每个方法里都有一套几乎一样的模板代码。
  2. 维护困难:如果现在想把日志从 Log.d 改成上报到服务器,得去修改所有地方。
  3. 逻辑污染:eat 方法的核心职责本应只是“吃”,现在却被迫“关心”自己是如何被监控的。

如果把面向对象 OOP 想象成盖大楼,Person 是 1 楼,Student 是 2 楼...(同一片土地上还有各种其他大楼) 它们都是“垂直”的功能单元。那么“性能监控”、“日志打印”、“权限校验”这些功能,就像是大楼的“水电”或“消防”系统。它们需要“横跨”所有楼层,贯穿到每一个房间。这种“横跨”多个“垂直”模块的通用功能,就被称为 “横切关注点”(Cross-Cutting Concerns)。

AOP_2.png

而 AOP 的核心思想,就是把这些“横切关注点”从业务逻辑中“抽离”出去,实现关注点分离。

AOP 允许我们将这些“横切逻辑”定义在一个独立的地方,这个地方就叫“切面”(Aspect),它就像是整栋大楼的“集中式配电箱”或“日志中心”。然后我们还需要再定义一个“规则”(比如“所有以 load 开头的方法”或“所有被 @LogTime 注解的方法”),AOP 框架就会自动把这个“切面”里的逻辑“织入”到符合规则的那些“关键点”(比如方法执行前 / 后)。


如果你写过 Python,里面的装饰器(Decorator)就是 AOP 思想的一个直观体现。

假设我们要实现“打印方法耗时”这个切面逻辑,用Python 装饰器来实现很简单:

# 这是一个“装饰器”,它本质上是一个函数 (log_time)
# 它接收另一个函数 (func) 作为参数,并返回一个新的“包装后”的函数 (wrapper)
def log_time(func):  # 参数 func 为被装饰的函数  
  def wrapper(*args, **kwargs):      
    start = time.perf_counter()  # 记录开始时间
    
    result = func(*args, **kwargs)  # 调用被装饰的函数, 即原始的业务逻辑函数
    
    end = time.perf_counter()  # 记录结束时间
    cost = (end - start) * 1000  # 计算耗时,单位毫秒
    print(f'execute {func.__name__} took {cost:.4f} ms') # 打印耗时
    return result  # 返回结果
  
  return wrapper  # 返回包装函数
  
  
@log_time # 语法糖,这等价于: add = log_time(add), 切面逻辑在这里被"织入"
def add(a, b):
  return a + b
  
  
if __name__ == '__main__':  
  print(add(1, 2)) # 调用时,执行的已经是被 "wrapper" 包装过的函数了

# 输出:
# execute add took 0.0008 ms
# 3

通过装饰器,我们的业务方法 add() 内部保留了纯粹的业务逻辑。它对自己被“监控”这件事完全无感知,是 AOP 框架(这里指 Python 的装饰器语法)在外部帮我们完成了“织入”工作。


AOP 与“插桩”的关系

  • AOP (面向切面): 一种编程思想,目的是解耦“核心业务”和“横切关注点”。

  • 插桩 (Instrumentation): 实现 AOP 思想的一种主要技术手段(即在特定点插入探针代码)。

  • ASM: 一个(在 Java/Kotlin 领域)用来执行“插桩”的具体工具,能直接操作字节码。

在 Java/Kotlin 领域,我们没有 Python 装饰器那么灵活的语法。为了在不修改业务代码的前提下,实现 AOP 切面编程,把“切面”逻辑(比如权限检查、耗时统计)“织入”到业务方法中,我们最好的办法就是在编译期去修改 .class 文件的字节码。而 ASM 就是一个能方便修改字节码的工具,它能找到目标方法(比如 loadUserData),在它的字节码开头和结尾,“插入”用于计时的“桩”(也就是那些 startTimeLog.d 对应的字节码指令)。

什么是 ASM

ASM 是一个通用的 Java 字节码操作和分析框架。它允许你动态地读取、修改和生成 Java 字节码(.class 文件)。

ASM_can_do.png

前置知识

基础不牢,地动山摇。

既然 ASM 是用来修改字节码(.class 文件)的,那么我们肯定得先了解 .class 文件是在哪个阶段生成的,以及字节码的格式。

如果不知道 .class 文件何时生成,我们就不知道去哪里“拦截”它;如果不知道字节码格式,那么即使文件摆在面前,我们也无从下手修改。

Java 编译过程

How Compilation Works in Java.png

我们写的 java 代码是放在 .java 文件里,这些文件会经过 Javac 被编译成字节码(Bytecode)也就是 .class 文件,执行时 .class 文件经由 JVM 虚拟机被翻译成机器码。

Android 打包流程

再来看看 apk 的打包流程:

Android 打包流程.png

  1. aapt2 编译资源生成 R.java 和 resources.arsc;

  2. kotlinc/javac 编译源码 .kt/.java 生成 .class 文件;

  3. R8 (或 d8) 对 .class 文件进行混淆、优化,并转换为 .dex 文件;

  4. apkbuilder 将 .dex、.arsc、AndroidManifest.xml 等所有文件打包成一个未签名的 .apk (ZIP 包);

  5. jarsigner 使用私钥对 APK 进行签名,生成可安装的 APK 文件;

  6. zipalign 对 APK 包进行内存对齐优化;

我在网上搜上面的流程图,找到最早的一张是 11 年前,彼时 2014 年还是 Android 4.4w 和 Android 5.0 Lollipop 的时代,而我还在读初中。

时过境迁,Android 的打包流程也在变化,dex 编译器从 dx 到 d8 再到 r8。其次,在 Android 7.0+ 后,必须改用 V2+ 签名,打包时必须先 Zipalign 对齐再 Apksigner 签名,也就是上面最后两个步骤互相调换。

话扯远了,本文主要是讨论 ASM 修改字节码,我们只需要知道,在 apk 打包过程中,javac / kotlinc 编译器工作后、DEX 编译器工作前,就是我们修改字节码进行插桩的时机。

初识字节码

为了认识字节码,我们首先写一个简单的 add() 函数:

// Utils.kt
fun add(a: Int, b: Int) {
  return a + b
}

我们可以在 IDE 里直接通过 Tools -> Kotlin -> Show Kotlin Bytecode 来方便地查看文件对应的字节码,不过为了能让大家对整个过程有更好的认识,所以下面会一步一步手敲命令。

我们先用 kotlinc 将其编译为 .class 文件,因为 Kotlinc 是与 IDE 捆绑的,在我的机器上,路径是: C:\Program Files\Android\Android Studio\plugins\Kotlin\kotlinc\bin\kotlinc

我们先切换(cd)到 Kotlin 编译器的 bin 目录:

cd 'C:\Program Files\Android\Android Studio\plugins\Kotlin\kotlinc\bin'

然后使用 kotlinc xxx.kt 命令编译 .kt 文件:

.\kotlinc Utils.kt -d KotlinBytecode

这里建议加上 -d 指定输出目录

│
└─KotlinBytecode
    ├─com
    │  └─example
    │      └─helloworld (对应的包路径)
    │              UtilsKt.class
    │
    └─META-INF (包含模块信息)
            main.kotlin_module

因为 add() 函数直接写在 Utils.kt 文件里,Kotlin 默认会在文件名后面加上 Kt 来生成类,我们最终得到的就是 UtilsKt.class 文件。

hex.png

.class 文件是二进制文件,里面是遵循《Java虚拟机规范》特定格式编码的机器指令(称为字节码,Bytecode)和其他元数据。需要使用专门的工具(如十六进制编辑器或 javap 反汇编器)才能查看其内容。 class file overview.png

ClassFile 结构.png 比如,.class 文件格式规范规定,每个 .class 文件的开头都必须是被称为 “魔数”(Magic Number)的固定四个字节。这个魔数是 0xCAFEBABE(16 进制表示)。

即使用 16 进制编辑器打开 .class 文件,也非常难以阅读和理解,想要以更直观的方式阅读 .class 文件,可以利用 JDK 自带 javap(Java Class File Disassembler)工具,它用于反汇编 .class 文件,是查看字节码的标准工具。

我们先 cd 到 .class 文件的所在目录,然后使用 javap 命令:

javap -c .\UtilsKt.class

-c(disassemble code)参数表示查看字节码指令

执行命令会我们就会得到以下内容:

Compiled from "Utils.kt"
public final class com.example.helloworld.UtilsKt {
  public static final int add(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: iadd
       3: ireturn
}

要理解上面的字节码指令,我们首先得理解栈帧,每个线程都有一个独立的栈,每调用一个方法,就会往这个栈放入一个栈帧(栈是一个后进先出的结构,所以调用最深方法的栈帧处在栈顶):

栈帧.png

每个栈帧都存储着:

  • 局部变量表
  • 操作数栈
  • 动态链接;
  • 方法返回地址;

我们再把 javap 的输出贴一遍:

public final class com.example.helloworld.UtilsKt {
  public static final int add(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: iadd
       3: ireturn
}

局部变量表像一个数组,用于存储方法参数和方法内部定义的局部变量,对于我们的 add() 方法,局部变量表为:[ 0: a, 1: b ] (索引 0 存的是参数 a,索引 1 存的是参数 b)

如果是非静态方法,局部变量表的索引 0 位置是 class 实例,也就是 this

操作数栈 (Operand Stack) 是一个后进先出 (LIFO) 的栈,用于执行计算。字节码指令(比如 iadd)会从这个栈中弹出 (pop) 数据,计算后,再把结果压入 (push) 栈顶。初始时操作数栈为空:[ ]

接下来,我们来当一次 JVM,一步一步执行 add(int, int) 方法的字节码:

  1. iload_0

    • 含义: i 代表 integer (整数),load 代表加载。0 是一个简写,代表索引 0。
    • 动作: 从局部变量表的索引 0 处加载一个整数(也就是参数 a),并将其压入操作数栈。
    • 栈帧状态:
      • 局部变量表: [ a, b ]
      • 操作数栈: [ a ]
  2. iload_1:与上一条字节码指令类似,不再赘述。

    • 栈帧状态:
      • 局部变量表: [ a, b ]
      • 操作数栈: [ a, b ] (b 在栈顶)
  3. iadd

    • 含义:i (integer) + add (相加)
    • 动作: 从操作数栈顶部弹出两个整数(先弹出 b,再弹出 a),将它们相加,然后把结果(假设为 c) 压回操作数栈。
    • 栈帧状态:
      • 局部变量表:[ a, b ]
      • 操作数栈:[ c ]
  4. ireturn

    • 含义:i (integer) + return (返回)
    • 动作:从操作数栈顶部弹出一个整数(也就是 a + b 的结果),并将其作为方法的返回值。同时,当前栈帧被销毁。
    • 栈帧状态:被销毁

以上就是字节码的执行过程,就好像一套基于栈的底层汇编语言。iloadiaddireturn 这些被称为“助记符”,它们每一个都对应一个 16 进制的“操作码”(比如 iload_0 对应 0x1a)。.class 文件里存的就是这些二进制的操作码,而 javap 帮我们反编译成了更易读的助记符。


好了,截至目前为止,我们现在知道了:

  • 为什么插桩:为了实现 AOP,分离“横切关注点”;
  • 插桩时机:在 .class 文件生成后,.dex 文件生成前;
  • 插桩原理:修改 .class 文件的字节码指令。

下一个问题是:怎么改?

我们总不能用 16 进制编辑器去手动修改 .class 文件吧?(较真起来可以是可以,但真没这个必要)我们需要一个更高级、更抽象的工具来帮我们解析和修改字节码。

市面上主流的字节码操作库有:

  • Javassist: 提供了更高级别的 API。支持用类似 Java 源码的字符串(如 System.out.println("hello");)来插入代码,它会帮你编译成字节码。上手简单,但灵活性和性能稍差。
  • ASM: 提供了更底层的 API。它不认识 Java 源码,它只认识 iloadiadd 这样的字节码指令。需手动去“拼”出这些指令。更难,但性能高、灵活性强。

ASM_and_Javassist.png

在 Android 领域,ASM 几乎是唯一的选择。因为在 Android 打包过程中会遍历成百上千个 .class 文件。这个过程对性能的要求是极致的。ASM 几乎就是字节码的“搬运工”,非常的轻量,没有多余的抽象和转换。

ASM 是如何工作的?

那么,相比于手动编辑 16 进制文件,ASM 是如何让我们优雅地修改 .class 文件的呢?

ASM 提供了两套 API 来操作字节码,它们各有优劣:

  1. Tree API:面向对象的方式,先把整个 .class 文件的内容读入内存,构建成一个“树”状结构,我们通过操作这棵“树”的节点来修改字节码。

  2. Core API:基于事件流的方式,像 SAX 解析 XML 一样,逐行扫描 .class 文件,在扫描到特定结构(如“找到一个方法”、“找到一条指令”)时触发回调,我们在回调里进行修改。

Tree API

我们先从更容易理解的 Tree API 讲起。

相信在座的每个 Android 开发者都用过 Gson 解析 .json 文件:

{
  "name": "UtilsKt",
  "superName": "java/lang/Object",
  "methods": [ { "name": "add", "desc": "(II)I" } ]
}

使用 Gson 时,它会把整个 JSON 字符串读入内存,转换成一个 JsonObject 或对应的数据类。随后我们就可以像操作一个普通的 Kotlin/Java 对象一样,随意地读取、修改、添加或删除它的任何属性,比如 jsonObject.remove("superName")

ASM 的 Tree API 也是完全一样的思路。由于.class 文件是一个遵循严格规范的二进制结构。Tree API 会把整个 .class 文件的字节流完整地读入内存,然后构建出一个完整的“对象树”来表示这个类。

还记得我们上面那个 UtilsKt.class 吗?如果用 Tree API 把它加载到内存里,结构大概是这个样子:

ClassNode (类: UtilsKt)
 |
 +-- name: "UtilsKt" (由文件名 Utils.kt 自动生成)
 |
 +-- superName: "java/lang/Object"
 |
 +-- access: public final (公共且不可继承)
 |
 +-- fields (字段列表): (无)
 |
 +-- methods (方法列表):
     |
     +-- MethodNode (方法: <init>)
     |   |
     |   +-- access: private (私有构造函数,防止外部实例化)
     |
     +-- MethodNode (方法: add)
         |
         +-- access: public static final (公共、静态、最终)
             |
             +-- descriptor: "(II)I" (描述符:接收两个Int,返回一个Int)
             |
             +-- instructions (指令列表):
                 |
                 +-- InsnNode (指令: [ILOAD_0]) (加载第 0 个参数 'a' 到栈顶)
                 +-- InsnNode (指令: [ILOAD_1]) (加载第 1 个参数 'b' 到栈顶)
                 +-- InsnNode (指令: [IADD])    (将栈顶两个 Int 相加,结果放回栈顶)
                 +-- InsnNode (指令: [IRETURN]) (返回栈顶的 Int 结果)

现在要对 add() 方法插入耗时统计的切面逻辑,我们要做的事情,就是去修改对应的 MethodNode 里的 instructions 列表。具体来说就是:

  1. methodNode.instructions 的最前面(insert)插入“获取开始时间并存入局部变量”的指令。
  2. 遍历 methodNode.instructions,找到所有“返回”指令(比如 IRETURN),在这些指令之前(insertBefore)插入“计算耗时并打印日志”的指令。

在这里先给各位新手打个预防针,“插入字节码”说起来容易,写起来做起来难。我们必须手动去“拼”出代码对应的所有字节码指令。 以 val start = System.currentTimeMillis() 为例,对应的字节码指令有两个:

  1. INVOKESTATIC java/lang/System.currentTimeMillis ()J:调用静态方法,并将 long 类型的返回值压入操作数栈;
  2. LSTORE_2:将栈顶的 long 存入局部变量表的第 2 个槽位(第 0、1 个分别是 ab)。

Log.d(...) 这行代码则更为复杂,涉及 String 的拼接,对应的字节码指令有十几条(比如 NEWDUPLDCINVOKEVIRTUAL...)除了要确保这些指令准确无误,还得手动管理好局部变量表和操作数栈的最大深度,因为没有修改前,这个方法的局部变量表只有 2 个槽位,操作数栈的最大深度是 2,但是插入了新的字节码后就不一样了,如果不修正局部变量表和操作数栈的最大深度,JVM 在校验 .class 文件时就会直接崩溃。

前面我们学会了用 javap -c xxx.classs 命令来查看 class 文件中的 Java 字节码指令,它会显示类中的所有方法以及每个方法对应的字节码指令列表。如果我们想查看 .class 文件的更多信息,可以使用 -v 参数(verbose),它会输出显示:类元数据、常量池、方法元数据(如堆栈大小、局部变量表大小、参数数量)等等所有详细信息。

javap-v.png

这套 Tree API 的优点是直观,符合面向对象的思维。增删改查就像操作 List 一样简单,适合需要进行复杂、全局性修改的场景。缺点也很明显:内存黑洞,需要把 .class 文件的所有信息都加载到内存中。

一般情况下,我们是不会采用这这套 API 的,在 Android 的编译流程中,AGP 需要遍历成百上千个 .class 文件。如果每处理一个文件都要先把它们全部读入内存,构建成一个巨大的 ClassNode 对象树,然后再序列化回磁盘,内存占用和 I/O 开销将不容乐观,对本就漫长的编译过程无疑是雪上加霜。

Core API

如果说 Tree API 是把整本书背下来再修改,那么 Core API 就是边读边改。

Core API 基于 Visitor Pattern(访问者模式),它本质上是一个事件驱动的模型(就像 XML 解析中的 SAX)。它不需要把整个类加载到内存里,而是通过“流”的形式,遍历 .class 文件,每遇到一个节点(类头、字段、方法、注解),就触发一个事件回调。

graph LR
    A[ClassReader] -->|解析字节码| B(ClassVisitor 我们写的插桩逻辑)
    B -->|修改/透传事件| C[ClassWriter]
    C -->|生成字节码| D[新的.class文件]

它们的协作关系就像一条流水线,也就是责任链模式

怎么去理解这里面的“访问者模式”呢?你可以把这个过程想象成有一个导游(ClassReader)带领我们(ClassVisitor)参观房子(.class),除了之外,还有一个装修公司的负责人(ClassWriter)在旁边。

  1. ClassReader(导游)带我们走到主卧门口,开始念:“现在有一个方法,叫 add,参数是 2 个 int...”

  2. ClassVisitor(访问者 / 游客)听到后,可以原封不动地传给 ClassWriter(装修公司的负责人),也可以夹带私货:“现在有一个方法,叫 add,参数是 3 个 int”

  3. ClassWriter(装修公司的负责人)听到什么就记下来,最后拼成新的 .class 文件。

假设我们要修改一个类,我们通常会继承 ClassVisitor

class MyClassVisitor(nextVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM9, nextVisitor) {

  // 访问到"类"
  override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>?) {
    // 可以在这里修改类名、父类等
    super.visit(version, access, name, signature, superName, interfaces)

  }

  // 当扫描到方法时,会调用这个回调
  override fun visitMethod(access: Int, name: String?, descriptor: String?, signature: String?, exceptions: Array<out String>?): MethodVisitor {
    val originalMethodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
    if (name == "add") {
      // 如果是我们要修改的 add 方法,我们不能直接在这里写指令
      // 而是要返回一个新的 MethodVisitor 对象,由它去处理方法内部的细节
      return MyMethodVisitor(originalMethodVisitor)
    }

    return originalMethodVisitor
  }
}

请注意 ClassVisitor 只负责“类”层面的事(比如类名、字段列表、方法列表)。当它发现一个方法时(visitMethod()),它不会直接处理方法体内的代码,而是要求你返回一个 MethodVisitor。也就是:如果你想改方法里的代码,得再派一个小弟(MethodVisitor)进去。这个 MethodVisitor 里的回调更加细致:

  • visitAnnotation(descriptor: String, visible: Boolean):访问方法注解;
  • visitParameter(name: String, access: Int):访问方法的参数信息;
  • visitCode():方法体的开始;
  • visitVarInsn(opcode: Int, varIndex: Int):访问局部变量;
  • ...
class MyMethodVisitor(nextMv: MethodVisitor) : MethodVisitor(Opcodes.ASM9, nextMv) {
  // 方法开始执行时
  override fun visitCode() {
    super.visitCode()
    // 插入:System.currentTimeMillis()...
  }

  // 遇到每条指令时
  override fun visitInsn(opcode: Int) { 
    if (opcode == Opcodes.IRETURN) {
      // 在 return 之前插入耗时计算代码...
    }
    super.visitInsn(opcode)
  }
}

Core_API_Details.png

相比于 Tree API,Core API 的优点是内存占用低、速度快。缺点就是实现复杂,有点反人类,作为开发者要时刻清楚当前“流”到了哪里。如果想获取上下文(比如在方法结尾想知道方法开头定义的某个变量值),得自己想办法用变量存起来,不像 Tree API 那样可以随意回头看。

总结一下

Tree_VS_Core.png

Tree API 就像是用 Word 打开文档,你可以随意翻页、查找、替换,适合做复杂的全类分析,但费内存; Core API 就像是听录音带,听一句改一句,过了就没了,适合做高效的机械化修改(比如统一加日志、埋点)。

实践

我们从"插桩"和"AOP"这两个词开始讲起,然后简单了解了 Java 编译过程和 apk 打包流程,还学了点 JVM 字节码的知识,最后探讨了 ASM 是如何修改字节码的。

所有的理论知识都学了,接下来就是真刀真枪的实操环节了。我们来写一个 AOP 切面编程的 Hello World:函数耗时打印。

我们的目标很简单:给被注解的方法自动加上计时代码,并在 Logcat 中打印出来。


1. 定义标记:@LogTime

我们先在 :app 模块新建一个注解类 LogTime

// app/src/main/java/com/example/aop/LogTime.kt

@Retention(AnnotationRetention.BINARY) // 重要:必须保留到字节码阶段,否则 ASM 读不到
@Target(AnnotationTarget.FUNCTION) // 作用在函数上
annotation class LogTime()

然后,找个“受害者”方法。我们在 MainActivity 里写一个模拟耗时操作的方法,并打上标记:

// app/src/main/java/com/example/aop/MainActivity.kt

class MainActivity : ComponentActivity() {  
  override fun onCreate(savedInstanceState: Bundle?) {  
    super.onCreate(savedInstanceState)  
    doSomeHeavyWork()  
  }  
  
  @LogTime // 打上标记
  private fun doSomeHeavyWork() {  
    Thread.sleep(100) // 模拟耗时 100ms
  }
}

如果不插桩,这段代码运行起来静悄悄的。我们的目标是让它自动吐出类似 MainActivity.doSomeHeavyWork execution time: 100 ms 的日志。

2. 搭建工程:Composite Build

既然要对字节码下手,我们首先得搞清楚“战场”在哪。

普通的业务代码(比如 MainActivity)是运行在用户的 Android 手机上的;而我们现在要写的插桩代码,是用来修改字节码的,它需要运行在编译阶段(也就是你的开发电脑上)。

这意味着,我们不能把这部分代码直接写在 :app 模块里。我们需要一个能介入 Android 构建流程、在 .class 生成后由 Gradle 自动调用的东西——没错,就是 Gradle 插件。


Gradle 插件的代码必须独立于 :app 模块存在。在 Android 工程里,要存放这种“构建逻辑(Build Logic)”,通常有两个选择:

  • :buildSrc: 这是以前最常用的方案。只要在根目录新建一个叫 buildSrc 的文件夹,Gradle 就会自动把它识别为插件工程。
    • 优点:零配置
    • 缺点::buildSrc 被视为构建脚本的一部分。只要改动了 :buildSrc 里的任何一行代码,Gradle 就会认为整个项目的构建逻辑变了,从而强制让所有模块(:app, :lib...)执行全量重新编译。
  • Composite Build(复合构建): 这是 Gradle 官方目前推荐的现代化方案。 它允许你把插件作为一个完全独立的项目(Project)来开发,然后通过 includeBuild 的方式“挂载”到主项目上。
    • 优点:完全解耦。

drop_buildSrc.png

作为 2025 年的 Android 开发者,我们当然选择 Composite Build 来编写 Gradle 插件。

我们在项目根目录下新建一个文件夹 build-logic(名字可以随意),这就相当于我们创建了一个独立的“外包工程”。

就像我们创建 Android 项目一样,一个根项目里面包含一个 :app 模块,我们的外包工程项目(build-login)里面也应该有一个插件模块,所以我们再继续创建两级子目录 /plugin/timing,这个 timing 目录就是我们插件模块。

.
  ├── app/                   <-- app 模块目录
  ├── gradle/
+ ├── build-logic/           <-- 复合构建的根目录
+ │   └── plugin/
+ │       └── timing/        <-- 插件模块目录
  ├── build.gradle.kts       <-- 根项目的构建脚本
  └── settings.gradle.kts    <-- 根项目的设置脚本

OK,我们创建了一个独立的"外包"工程项目,到目前为止,这个项目和主项目没有任何关联,我们需要在主项目 settings.gradle.kts 里使用 includeBuild() 告诉 Gradle:嘿,这个 build-logic 是一个独立的 Project,里面包含一些构建逻辑,在编译主项目里的任何模块之前,请你先编译 build-logic 这个项目。

// settings.gradle.kts

pluginManagement {  
+  includeBuild("build-logic")
   repositories { ... }  
}  
  
dependencyResolutionManagement { ... }  
  
rootProject.name = "AopExample"  
  
include(":app") // 包含 app 子模块

虽然复合构建(build-logic)从文件结构上看,存在于主项目内部,但是复合构建是可以被视为一个独立的 Project 的,也就是说,即使我把 build-logic/ 文件夹单独拿出来,也可以 build 起来,不需要依赖主项目。既然它是一个独立的项目,那么应该有自己的 settings.gradle.kts 文件:

.
  ├── app/                     <-- app 模块目录
  ├── gradle/
  ├── build-logic/             <-- 复合构建的根目录
+ │   ├── settings.gradle.kts  <-- 管理 build-logic 内部的模块
  │   └── plugin/
  │       └── timing/          <-- 插件模块目录
  ├── build.gradle.kts         <-- 根项目的构建脚本
  └── settings.gradle.kts      <-- 根项目的设置脚本

内容和主项目的 settings.gradle.kts 是类似的:

// build-logic/settings.gradle.kts

pluginManagement {  
    repositories {  
        gradlePluginPortal()  
        google()  
    }  
}  
  
dependencyResolutionManagement {  
    repositories {  
        google {  
            content {  
                includeGroupByRegex("com\\.android.*")  
                includeGroupByRegex("com\\.google.*")  
                includeGroupByRegex("androidx.*")  
            }  
        }  
        mavenCentral()  
    }
}  
  
rootProject.name = "build-logic" // 这个"外包工程"的名字
include(":plugin:timing") // 工程里包含的模块

每个模块都有自己的 build.gradle.kts 文件,:app 如此,插件模块 :plugin:timing 也不例外:

.
  ├── app/                           <-- app 模块目录
  ├── gradle/
  ├── build-logic/                   <-- 复合构建的根目录
  │   ├── settings.gradle.kts        <-- 管理 build-logic 内部的模块
  │   └── plugin/
  │       └── timing/                <-- 插件模块目录
+ │           └─── build.gradle.kts  <-- 插件的构建脚本
  ├── build.gradle.kts               <-- 根项目的构建脚本
  └── settings.gradle.kts            <-- 根项目的设置脚本
// build-logic/plugin/timing/build.gradle.kts

plugins { `kotlin-dsl` }  
  
java {  
  sourceCompatibility = JavaVersion.VERSION_17  
  targetCompatibility = JavaVersion.VERSION_17  
}  
  
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } }  
  
dependencies {  
  // AGP API,用于访问 androidComponents 扩展
  // 强烈建议与 AGP gradle 版本保持一致
  implementation("com.android.tools.build:gradle-api:8.13.1")  
  // ASM 核心库: 操作字节码的基础
  implementation("org.ow2.asm:asm:9.9")  
  // ASM 工具库,提供了一些方便的适配器类  
  implementation("org.ow2.asm:asm-commons:9.9")  
}  

// 注册 Gradle 插件
// TimingPlugin 我们在后面会实现
gradlePlugin {  
  plugins {  
    register("TimingPlugin") {  
      id = "timing-plugin" // 插件 ID
      implementationClass = "com.example.asm.timing.TimingPlugin" // 插件的入口类
    }  
  }  
}

3. 核心手术:编写 MethodVisitor

好,架子搭好了,现在进入最核心的部分——写字节码。

我们要修改的是方法(Method),所以核心逻辑肯定在 MethodVisitor 里。

.
  ├── app/                           <-- app 模块目录
  ├── gradle/
  ├── build-logic/                   <-- 复合构建的根目录
  │   ├── settings.gradle.kts        <-- 管理 build-logic 内部的模块
  │   └── plugin/
  │       └── timing/                <-- 插件模块目录
  │           ├─── build.gradle.kts  <-- 插件的构建脚本
+ │           └── src/ 
+ │               └── main/kotlin/com/example/asm/timing/ 
+ │                   └── TimingMethodVisitor.kt
  ├── build.gradle.kts               <-- 根项目的构建脚本
  └── settings.gradle.kts            <-- 根项目的设置脚本

直接用 MethodVisitor 会比较痛苦,还得自己判断哪里是方法结束。幸运的是,asm-commons 库提供了一个神器:AdviceAdapter,它是 MethodVisitor 的子类,贴心地提供了 onMethodEnter()(方法进入时)和 onMethodExit()(方法退出时)这两个回调。我们只需要在这两个地方“填空”就行了。

这部分代码稍微有点长,不过每一行都有注释,我就不啰嗦太多了,有了前面的前置知识,相信大家都能看懂,其实就是把 Java 代码“翻译”成等价的字节码指令。

// build-logic/plugin/timing/src/main/kotlin/com/example/asm/timing/TimingMethodVisitor.kt

class TimingMethodVisitor(  
    api: Int,  
    methodVisitor: MethodVisitor,  
    access: Int,  
    private val methodName: String?,  
    descriptor: String?,  
    private val className: String,  
    private val logTag: String  
) : AdviceAdapter(api, methodVisitor, access, methodName, descriptor) {  
  
    private var hasLogTimeAnnotation = false  
    private var startTimeLocalVarIndex: Int = 0  
  
    // 1. 访问方法的注解  
    override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor? {  
        if (descriptor == "Lcom/example/aop/LogTime;") { // 通过描述符判断注解  
            hasLogTimeAnnotation = true  
        }  
        return super.visitAnnotation(descriptor, visible)  
    }  
  
    // 2. 在方法进入时被调用  
    override fun onMethodEnter() {  
        super.onMethodEnter()  
        if (hasLogTimeAnnotation) {  
            // newLocal() 是一个便捷方法, 会在当前方法的局部变量表保留一个新的槽位, 返回局部变量索引  
            startTimeLocalVarIndex = newLocal(Type.LONG_TYPE /* 指定新局部变量的数据类型 */)  
  
            // 调用静态方法 System.currentTimeMillis()            // mv 是 MethodVisitor, 在父类中定义的  
            mv.visitMethodInsn( // visitMethodInsn 方法用于添加一个方法调用指令  
                /* opcode = */ INVOKESTATIC,      // Invoke Static: JVM 字节码指令,它告诉 JVM 去调用一个 static(静态)方法  
                /* owner = */ "java/lang/System", // 拥有该方法的类的内部名称  
                /* name = */ "currentTimeMillis", // 想要调用的方法的名称  
                /* descriptor = */ "()J",         // 方法描述符, () 表示该方法没有参数, J 表示方法的返回值类型是 long (J 是 long 的 JVM 类型签名)  
                /* isInterface = */ false         // 指示 owner(即 java/lang/System)是否是一个接口  
            )  
  
            // 将上一步的结果(时间戳)存入我们之前创建的局部变量中  
            mv.visitVarInsn( // 此方法用于添加一个与局部变量交互的指令(如加载或存储)  
                /* opcode = */ LSTORE,        // LSTORE 是一个复合指令: STORE 表示“存储”, 会从操作数栈的栈顶弹出一个值, L 表示 long                /* varIndex = */ startTimeLocalVarIndex // 局部变量索引, 指定要存储到哪一个局部变量槽位  
            )  
        }  
    }  
  
    // 3. 在方法退出时被调用 (无论是正常返回还是异常抛出)  
    override fun onMethodExit(opcode: Int) {  
        if (hasLogTimeAnnotation) {  
            /**  
             * 以下部分的等效代码是:  
             * long endTime = System.currentTimeMillis();  
             * long duration = endTime - startTime;             * String msg = new StringBuilder()  
             *         .append("MyClass.myMethod execution time: ")  
             *         .append(duration)             *         .append(" ms")             *         .toString();             * Log.d(logTag, msg);  
             */  
            // 再次调用 System.currentTimeMillis() 获取结束时间, 不再赘述  
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)  
            // 加载方法开始时存储的时间戳  
            mv.visitVarInsn(LLOAD, startTimeLocalVarIndex)  
            // 计算差值 (结束时间 - 开始时间)  
            mv.visitInsn(LSUB)  
            // 将差值(long类型)存入一个新的局部变量  
            val durationLocalVarIndex = newLocal(Type.LONG_TYPE)  
            mv.visitVarInsn(LSTORE, durationLocalVarIndex)  
  
  
            // 准备打印日志  
            mv.visitLdcInsn(logTag) // LDC (Load Constant) 加载一个常量到操作数栈的栈顶  
            // 此时的操作数栈: [日志Tag(String)]  
  
            mv.visitTypeInsn( // 访问与“类型”相关的指令  
                /* opcode = */ NEW,                    // NEW  
                /* type = */ "java/lang/StringBuilder" // 要创建的对象的类型  
            ) // 在堆上分配一个 StringBuilder 对象,并将其引用(地址)推入栈顶  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit)]  
  
            mv.visitInsn(DUP) // DUP (Duplicate) 复制栈顶元素  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit), sb_ref(uninit)]  
  
            mv.visitMethodInsn(  
                /* opcode = */ INVOKESPECIAL,            // invoke special 特殊调用,用于构造函数、super 调用和 private 方法  
                /* owner = */ "java/lang/StringBuilder", // 拥有该方法的类的内部名称  
                /* name = */ "<init>",                   // 构造函数的固定名称  
                /* descriptor = */ "()V",                // 描述符。() 表示无参数,V 表示 void 返回  
                /* isInterface = */ false                // // 指示 owner(即 java/lang/StringBuilder)是否是一个接口  
            )  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit)]  
  
            mv.visitLdcInsn("$className.$methodName execution time: ") // 加载常量, 将日志消息的前缀(一个 String)推入栈顶  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit), 日志消息前缀(String)]  
  
            mv.visitMethodInsn(  
                /* opcode = */ INVOKEVIRTUAL,                                       // invoke virtual: 虚拟调用,用于所有实例方法  
                /* owner = */ "java/lang/StringBuilder",                            // 拥有该方法的类的内部名称  
                /* name = */ "append",                                              // 方法名  
                /* descriptor = */ "(Ljava/lang/String;)Ljava/lang/StringBuilder;", // 描述符, 参数类型为 String, 返回类型为 StringBuilder                /* isInterface = */ false                                           // 指示 owner(即 java/lang/StringBuilder)是否是一个接口  
            ) // 调用 append(String)。它会消耗栈顶的 日志消息前缀(String) 和 sb_ref(init), 不过它会返回一个 StringBuilder, 然后将结果推入栈顶  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit)]  
  
            mv.visitVarInsn(LLOAD, durationLocalVarIndex) // 加载耗时  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit), 耗时(long)]  
  
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false)  
            // 调用 append(long)。它会消耗栈顶的 耗时(long) 和 sb_ref(init), 不过它仍然返回一个 StringBuilder, 然后将结果推入栈顶  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit)]  
  
            mv.visitLdcInsn(" ms") // 加载常量, 将日志消息的后缀(一个 String)推入栈顶  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit), 日志消息后缀(String)]  
  
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false)  
            // 调用 append(String)。它会消耗栈顶的 日志消息后缀(String) 和 sb_ref(init), 不过它仍然返回一个 StringBuilder, 然后将结果推入栈顶  
            // 此时的操作数栈: [日志Tag(String), sb_ref(uninit)]  
  
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false)  
            // 调用 toString()。它会消耗栈顶的 sb_ref(init), 然后将结果推入栈顶  
            // 此时的操作数栈: [日志Tag(String), 日志消息(String)]  
  
            mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false)  
            // 调用 Log.d(tag, msg)。它会消耗栈顶的 日志消息(String) 和 日志Tag(String), 然后将结果(写入的字节数)推入栈顶  
            // 此时的操作数栈: [result(int)]  
  
            // Log.d 返回了一个 int,我们并不关心它。如果不弹出它,这个 int 值会留在栈上,当原始的 RETURN 指令执行时,  
            // 会导致栈帧错误 (StackMapFrameError)。POP 用来清理栈,确保栈在我们的代码执行完毕后是干净的  
            mv.visitInsn(POP) // Log.d 返回 int,需要弹出  
        }  
        super.onMethodExit(opcode) // 这行代码的作用是将原始的退出指令(如 RETURN)写回方法中, 不要忘记!!!  
    }  
}

写这部分代码的时候,你可能会觉得自己就像一个无情堆栈机:“把这个压入栈,把那个弹出来……”。没错,这就是堆栈机的魅力(噩梦)。

4. 组装流水线:ClassVisitor 与 Factory

有了处理方法的 MethodVisitor,我们需要一个“导游” ClassVisitor 来把各个方法领给它:

// build-logic/plugin/timing/src/main/kotlin/com/example/asm/timing/TimingClassVisitor.kt

class TimingClassVisitor(  
    private val apiVersion: Int,  
    nextClassVisitor: ClassVisitor,  
    private val className: String,  
    private val logTag: String,  
) : ClassVisitor(apiVersion, nextClassVisitor) {  
  
  override fun visitMethod(  
      access: Int,  
      name: String?,  
      descriptor: String?,  
      signature: String?,  
      exceptions: Array<out String>?,  
  ): MethodVisitor {  
    // 先获取原始的 MethodVisitor    
    val originalMethodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
    // 用我们的 TimingMethodVisitor 包裹原始的 MethodVisitor
    // 这样,当 ASM 遍历指令时,会先经过我们的 MethodVisitor,再传给原始的 MethodVisitor
    return TimingMethodVisitor(  
        apiVersion,  
        originalMethodVisitor,  
        access,  
        name,  
        descriptor,  
        className,  
        logTag,  
    )  
  }  
}

就好比我们不能直接实例化 ViewModel,同样的,我们也不能直接创建 ClassVisitor,必须通过一个 Factory 工厂类来创建 ClassVisitor。

// build-logic/plugin/timing/src/main/kotlin/com/example/asm/timing/TimingClassVisitorFactory.kt

abstract class TimingClassVisitorFactory :  
    AsmClassVisitorFactory<TimingClassVisitorFactory.Parameters> {
    // 泛型用于指定这个工厂类可以接受的参数类型
    // 如果不需要接受参数,可以设置为 InstrumentationParameters.None
  
  interface Parameters : InstrumentationParameters {  
    @get:Input 
    val logTag: Property<String>  
  }  
  
  override fun createClassVisitor(  
      classContext: ClassContext,  
      nextClassVisitor: ClassVisitor,  
  ): ClassVisitor {
    // 获取外部传进来的 tag
    val tag: String = parameters.get().logTag.getOrElse("Timing")
  
    return TimingClassVisitor(  
        apiVersion = instrumentationContext.apiVersion.get(),  
        nextClassVisitor = nextClassVisitor,  
        className = classContext.currentClassData.className,  
        logTag = tag, 
    )  
  }  
  
  // 过滤逻辑:在这个方法里决定哪些类需要被“插桩” 
  // 为了性能,这里只扫描 com.example.aop 包下的类
  override fun isInstrumentable(classData: ClassData): Boolean {  
    return classData.className.startsWith("com.example.aop")  
  }  
}
.
  ├── app/                           <-- app 模块目录
  ├── gradle/
  ├── build-logic/                   <-- 复合构建的根目录
  │   ├── settings.gradle.kts        <-- 管理 build-logic 内部的模块
  │   └── plugin/
  │       └── timing/                <-- 插件模块目录
  │           ├─── build.gradle.kts  <-- 插件的构建脚本
  │           └── src/ 
  │               └── main/kotlin/com/example/asm/timing/ 
+ │                   ├── TimingClassVisitorFactory.kt
+ │                   ├── TimingClassVisitor.kt
  │                   └── TimingMethodVisitor.kt
  ├── build.gradle.kts               <-- 根项目的构建脚本
  └── settings.gradle.kts            <-- 根项目的设置脚本

5. 插件入口

最后一步,编写 Gradle 插件入口类 TimingPlugin,把所有东西串起来,并挂载到 Android 的构建流程中:

.
  ├── app/                           <-- app 模块目录
  ├── gradle/
  ├── build-logic/                   <-- 复合构建的根目录
  │   ├── settings.gradle.kts        <-- 管理 build-logic 内部的模块
  │   └── plugin/
  │       └── timing/                <-- 插件模块目录
  │           ├─── build.gradle.kts  <-- 插件的构建脚本
  │           └── src/ 
  │               └── main/kotlin/com/example/asm/timing/ 
+ │                   ├── TimingPlugin.kt
  │                   ├── TimingClassVisitorFactory.kt
  │                   ├── TimingClassVisitor.kt
  │                   └── TimingMethodVisitor.kt
  ├── build.gradle.kts               <-- 根项目的构建脚本
  └── settings.gradle.kts            <-- 根项目的设置脚本
// build-logic/plugin/timing/src/main/kotlin/com/example/asm/timing/TimingPlugin.kt

class TimingPlugin : Plugin<Project> {  
  
  abstract class Extension {  
    abstract val logTag: Property<String>
    
    init {  
      logTag.convention("Timing") // 默认的 LogTag
    }
  }  
  
  override fun apply(project: Project) {  
    // // 定义 DSL 扩展:允许用户在 build.gradle 中配置 
    // timing { 
    //   logTag = "xxx" 
    // }
    val extension: TimingPlugin.Extension = 
        project.extensions.create("timing", TimingPlugin.Extension::class.java)
  
    // 获取 Android 组件扩展
    val androidComponents: AndroidComponentsExtension<*, *, *> = 
        project.extensions.getByType(AndroidComponentsExtension::class.java)
    // 对所有的 Variant (Debug/Release...) 进行操作
    androidComponents.onVariants { variant ->  
      // 注册 ASM 转换
      variant.instrumentation.transformClassesWith(
          TimingClassVisitorFactory::class.java, // 我们的 ClassVisitor 工厂类
          InstrumentationScope.PROJECT, // 作用范围:整个项目(不包括第三方库)
      ) { parameters: TimingClassVisitorFactory.Parameters ->
          // 将 DSL 中的配置传给 Factory
          parameters.logTag.set(extension.logTag)
      }
      
      // 重要:因为我们修改了字节码(增加了局部变量和堆栈操作), 
      // 需要让 AGP 帮我们重新计算栈帧(StackMapFrames),否则校验 class 文件会失败  
      variant.instrumentation.setAsmFramesComputationMode(
          // 仅重新计算被"插桩"了的方法的栈帧
          FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS 
      )
    }  
  }  
}

6. 见证奇迹

现在,让我们回到 :app 模块,应用我们刚刚写好的插件。

// app/build.gradle.kts

plugins {  
   alias(libs.plugins.android.application)  
   alias(libs.plugins.kotlin.android)  
   alias(libs.plugins.kotlin.compose)  
+  id("timing-plugin")  
}

+ timing {
+   logTag = "LogTime" 
+ }

Sync 一下 Gradle,然后点击 Run 运行 App。

当 App 启动时,可以把 Logcat 过滤器设为你设置的 tag。你会惊喜地发现:

D/...: com.example.aop.MainActivity.doSomeHeavyWork execution time: 103 ms

它工作了!我们在没有修改 doSomeHeavyWork() 源码的情况下,凭空让它拥有了自我计时的能力。这就是 ASM 插桩的魔力。

虽然我们在 MethodVisitor 里手写指令的过程有点像是在用镊子绣花,但一旦你掌握了这套逻辑,你就可以做很多更高级的事情。

ASM 是一把手术刀,用得好,它是优化和治理代码的神器。希望这篇文章能成为你字节码探索之旅的起点,而不是终点。

参考链接