首先,老手请绕道。

很久前点开了一篇 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) // 模拟学习耗时
}
}
Person 和 Student 之间形成了一条清晰的垂直继承链。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 的核心业务,但它却像牛皮癣一样,侵入并散落在了各个业务模块中。这导致了:
- 代码重复:每个方法里都有一套几乎一样的模板代码。
- 维护困难:如果现在想把日志从
Log.d改成上报到服务器,得去修改所有地方。 - 逻辑污染:
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),在它的字节码开头和结尾,"插入"用于计时的"桩"(也就是那些 startTime 和 Log.d 对应的字节码指令)。
什么是 ASM
ASM 是一个通用的 Java 字节码操作和分析框架。它允许你动态地读取、修改和生成 Java 字节码(.class 文件)。

前置知识
基础不牢,地动山摇。
既然 ASM 是用来修改字节码(.class 文件)的,那么我们肯定得先了解 .class 文件是在哪个阶段生成的,以及字节码的格式。
如果不知道 .class 文件何时生成,我们就不知道去哪里"拦截"它;如果不知道字节码格式,那么即使文件摆在面前,我们也无从下手修改。
Java 编译过程

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

-
aapt2 编译资源生成
R.java和 resources.arsc; -
kotlinc/javac 编译源码
.kt/.java生成.class文件; -
R8 (或 d8) 对
.class文件进行混淆、优化,并转换为.dex文件; -
apkbuilder 将 .dex、.arsc、AndroidManifest.xml 等所有文件打包成一个未签名的
.apk(ZIP 包); -
jarsigner 使用私钥对 APK 进行签名,生成可安装的 APK 文件;
-
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) 方法的字节码:
-
iload_0- 含义: i 代表 integer (整数),load 代表加载。0 是一个简写,代表索引 0。
- 动作: 从局部变量表的索引 0 处加载一个整数(也就是参数 a),并将其压入操作数栈。
- 栈帧状态:
- 局部变量表:
[ a, b ] - 操作数栈:
[ a ]
- 局部变量表:
-
iload_1:与上一条字节码指令类似,不再赘述。- 栈帧状态:
- 局部变量表:
[ a, b ] - 操作数栈:
[ a, b ](b 在栈顶)
- 局部变量表:
- 栈帧状态:
-
iadd- 含义:
i(integer) +add(相加) - 动作: 从操作数栈顶部弹出两个整数(先弹出
b,再弹出a),将它们相加,然后把结果(假设为c) 压回操作数栈。 - 栈帧状态:
- 局部变量表:
[ a, b ] - 操作数栈:
[ c ]
- 局部变量表:
- 含义:
-
ireturn- 含义:
i(integer) +return(返回) - 动作:从操作数栈顶部弹出一个整数(也就是 a + b 的结果),并将其作为方法的返回值。同时,当前栈帧被销毁。
- 栈帧状态:被销毁
- 含义:
以上就是字节码的执行过程,就好像一套基于栈的底层汇编语言。iload、iadd、ireturn 这些被称为"助记符",它们每一个都对应一个 16 进制的"操作码"(比如 iload_0 对应 0x1a)。.class 文件里存的就是这些二进制的操作码,而 javap 帮我们反编译成了更易读的助记符。
好了,截至目前为止,我们现在知道了:
- 为什么插桩:为了实现 AOP,分离"横切关注点";
- 插桩时机:在
.class文件生成后,.dex文件生成前; - 插桩原理:修改
.class文件的字节码指令。
下一个问题是:怎么改?
我们总不能用 16 进制编辑器去手动修改 .class 文件吧?(较真起来可以是可以,但真没这个必要)我们需要一个更高级、更抽象的工具来帮我们解析和修改字节码。
市面上主流的字节码操作库有:
- Javassist : 提供了更高级别的 API。支持用类似 Java 源码的字符串(如
System.out.println("hello");)来插入代码,它会帮你编译成字节码。上手简单,但灵活性和性能稍差。 - ASM : 提供了更底层的 API。它不认识 Java 源码,它只认识
iload、iadd这样的字节码指令。需手动去"拼"出这些指令。更难,但性能高、灵活性强。

在 Android 领域,ASM 几乎是唯一的选择。因为在 Android 打包过程中会遍历成百上千个 .class 文件。这个过程对性能的要求是极致的。ASM 几乎就是字节码的"搬运工",非常的轻量,没有多余的抽象和转换。
ASM 是如何工作的?
那么,相比于手动编辑 16 进制文件,ASM 是如何让我们优雅地修改 .class 文件的呢?
ASM 提供了两套 API 来操作字节码,它们各有优劣:
-
Tree API :面向对象的方式,先把整个
.class文件的内容读入内存,构建成一个"树"状结构,我们通过操作这棵"树"的节点来修改字节码。 -
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 列表。具体来说就是:
- 在
methodNode.instructions的最前面(insert)插入"获取开始时间并存入局部变量"的指令。 - 遍历
methodNode.instructions,找到所有"返回"指令(比如IRETURN),在这些指令之前(insertBefore)插入"计算耗时并打印日志"的指令。
在这里先给各位新手打个预防针,"插入字节码"说起来容易,写起来做起来难。我们必须手动去"拼"出代码对应的所有字节码指令。 以
val start = System.currentTimeMillis()为例,对应的字节码指令有两个:
INVOKESTATIC java/lang/System.currentTimeMillis ()J:调用静态方法,并将long类型的返回值压入操作数栈;LSTORE_2:将栈顶的long存入局部变量表的第 2 个槽位(第 0、1 个分别是a和b)。而
Log.d(...)这行代码则更为复杂,涉及 String 的拼接,对应的字节码指令有十几条(比如NEW、DUP、LDC、INVOKEVIRTUAL...)除了要确保这些指令准确无误,还得手动管理好局部变量表和操作数栈的最大深度,因为没有修改前,这个方法的局部变量表只有 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 文件,每遇到一个节点(类头、字段、方法、注解),就触发一个事件回调。
它们的协作关系就像一条流水线,也就是责任链模式
怎么去理解这里面的"访问者模式"呢?你可以把这个过程想象成有一个导游(ClassReader)带领我们(ClassVisitor)参观房子(.class),除了之外,还有一个装修公司的负责人(ClassWriter)在旁边。
-
ClassReader(导游)带我们走到主卧门口,开始念:"现在有一个方法,叫 add,参数是 2 个 int..."
-
ClassVisitor(访问者 / 游客)听到后,可以原封不动地传给 ClassWriter(装修公司的负责人),也可以夹带私货:"现在有一个方法,叫 add,参数是 3 个 int"
-
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 是一把手术刀,用得好,它是优化和治理代码的神器。希望这篇文章能成为你字节码探索之旅的起点,而不是终点。

比如,