从喝水到学会 Android ASM 插桩

首先,老手请绕道。

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

后来,工作学习中常听到

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

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

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

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

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

插桩 Instrumentation

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

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

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

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

那"插桩"又该怎么理解呢?

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

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

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

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

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


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

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

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

AOP 切面编程

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

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

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

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

kotlin 复制代码
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 我们很可能会这么写:

diff 复制代码
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 的核心思想,就是把这些"横切关注点"从业务逻辑中"抽离"出去,实现关注点分离。

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


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

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

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 是用来修改字节码(.class 文件)的,那么我们肯定得先了解 .class 文件是在哪个阶段生成的,以及字节码的格式。

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

Java 编译过程

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

Android 打包流程

再来看看 apk 的打包流程:

  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() 函数:

kotlin 复制代码
// 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 目录:

bash 复制代码
cd 'C:\Program Files\Android\Android Studio\plugins\Kotlin\kotlinc\bin'

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

bash 复制代码
.\kotlinc Utils.kt -d KotlinBytecode

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

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

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

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

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

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

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

bash 复制代码
javap -c .\UtilsKt.class

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

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

arduino 复制代码
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
}

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

每个栈帧都存储着:

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

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

arduino 复制代码
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 这样的字节码指令。需手动去"拼"出这些指令。更难,但性能高、灵活性强。

在 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 文件:

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 把它加载到内存里,结构大概是这个样子:

lua 复制代码
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),它会输出显示:类元数据、常量池、方法元数据(如堆栈大小、局部变量表大小、参数数量)等等所有详细信息。

这套 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

kotlin 复制代码
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):访问局部变量;
  • ...
kotlin 复制代码
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)
  }
}

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

总结一下

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

实践

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

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

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


1. 定义标记:@LogTime

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

kotlin 复制代码
// app/src/main/java/com/example/aop/LogTime.kt

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

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

kotlin 复制代码
// 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 的方式"挂载"到主项目上。
    • 优点:完全解耦。

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

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

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

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

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

diff 复制代码
// settings.gradle.kts

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

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

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

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

kotlin 复制代码
// 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 也不例外:

diff 复制代码
.
  ├── app/                           <-- app 模块目录
  ├── gradle/
  ├── build-logic/                   <-- 复合构建的根目录
  │   ├── settings.gradle.kts        <-- 管理 build-logic 内部的模块
  │   └── plugin/
  │       └── timing/                <-- 插件模块目录
+ │           └─── build.gradle.kts  <-- 插件的构建脚本
  ├── build.gradle.kts               <-- 根项目的构建脚本
  └── settings.gradle.kts            <-- 根项目的设置脚本
kotlin 复制代码
// 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 里。

diff 复制代码
.
  ├── 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 代码"翻译"成等价的字节码指令。

kotlin 复制代码
// 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 来把各个方法领给它:

kotlin 复制代码
// 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。

kotlin 复制代码
// 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")  
  }  
}
diff 复制代码
.
  ├── 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 的构建流程中:

diff 复制代码
.
  ├── 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            <-- 根项目的设置脚本
kotlin 复制代码
// 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 模块,应用我们刚刚写好的插件。

diff 复制代码
// 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。你会惊喜地发现:

text 复制代码
D/...: com.example.aop.MainActivity.doSomeHeavyWork execution time: 103 ms

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

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

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

参考链接

相关推荐
吴Wu涛涛涛涛涛Tao22 分钟前
用 Flutter + BLoC 写一个顺手的涂鸦画板(支持撤销 / 重做 / 橡皮擦 / 保存相册)
android·flutter·ios
HAPPY酷1 小时前
Flutter 开发环境搭建全流程
android·python·flutter·adb·pip
圆肖1 小时前
File Inclusion
android·ide·android studio
青旬1 小时前
我的AI搭档:从“年久失修”的AGP 3.4到平稳着陆AGP 8.0时代
android·ai编程
FrameNotWork2 小时前
#RK3588 Android 14 虚拟相机 HAL 开发踩坑实录:从 Mali Gralloc 报错到成功显示画面
android·车载系统
恋猫de小郭3 小时前
回顾 Flutter Flight Plans ,关于 Flutter 的现状和官方热门问题解答
android·前端·flutter
张风捷特烈4 小时前
FlutterUnit3.4.1 | 来场三方库的收录狂欢吧~
android·前端·flutter
e***58234 小时前
Spring Cloud GateWay搭建
android·前端·后端
x***13398 小时前
【MyBatisPlus】MyBatisPlus介绍与使用
android·前端·后端