Javassist(Java Programming Assistant)是一个在源码层面操作字节码的框架。它使用类似 Java 语法的字符串来描述要插入的代码,而无需直接操作字节码指令(如 ASM),极大降低了字节码操作的门槛。在全埋点场景中,Javassist 可以在编译期或运行时修改 onClick 方法。
一、Javassist 架构与核心 API
1.1 核心类模型
ClassPool │ └── 管理所有 CtClass 对象 │ ├── CtClass (编译时类) │ ├── CtMethod (方法) │ │ ├── CtParameter (参数) │ │ ├── insertBefore() / insertAfter() │ │ ├── addCatch() │ │ └── setBody() │ ├── CtField (字段) │ ├── CtConstructor (构造器) │ └── CtBehavior (方法/构造器的基类) │ └── ClassPath ├── ClassClassPath (通过 Class 对象) ├── ByteArrayClassPath (通过字节数组) └── LoaderClassPath (通过 ClassLoader)
|
1.2 特殊变量体系
| 变量 |
含义 |
可用范围 |
$0 |
this 引用 |
方法体内 |
$1, $2, ... |
第 1, 2, … 个参数 |
方法体内 |
$$` | 所有参数数组 (Object[]) | 方法体内 |
| `$_` | 返回值 | insertAfter |
| `$e` | 异常对象 | catch 块 |
| `$r` | 返回值类型 | insertAfter |
| `$w` | 包装器类型转换 | 方法体内 |
| `$sig` | 参数类型签名数组 | 方法体内 |
| `$type` | 返回值类型 (CtClass) | 方法体内 |
| `$class` | 当前类 (CtClass) | 方法体内 |
## 二、Android 编译时注入实现
### 2.1 Gradle Transform + Javassist
abstract class JavassistTrackingTransform : Transform() {
override fun getName() = "javassistTrackingTransform"
override fun getInputTypes() = setOf(QualifiedContent.DefaultContentType.CLASSES)
override fun getScopes() = setOf( QualifiedContent.Scope.PROJECT, QualifiedContent.Scope.SUB_PROJECTS, QualifiedContent.Scope.EXTERNAL_LIBRARIES )
override fun isIncremental() = false
override fun transform(transformInvocation: TransformInvocation) { val startTime = System.currentTimeMillis() val pool = ClassPool.getDefault()
setupClassPath(pool, transformInvocation)
transformInvocation.inputs.forEach { input -> input.directoryInputs.forEach { dirInput -> processDirectoryInput(pool, dirInput) } input.jarInputs.forEach { jarInput -> processJarInput(pool, jarInput) } }
val duration = System.currentTimeMillis() - startTime Log.i("JavassistTransform", "Transform completed in ${duration}ms") }
private fun setupClassPath(pool: ClassPool, invocation: TransformInvocation) { pool.appendClassPath( ClassClassPath(android.view.View.OnClickListener::class.java) ) pool.appendClassPath( ClassClassPath(android.view.View::class.java) ) pool.appendClassPath( ClassClassPath(com.example.tracking.TrackingHelper::class.java) )
invocation.inputs.forEach { input -> input.directoryInputs.forEach { dirInput -> pool.appendClassPath(dirInput.file.absolutePath) } } }
private fun processDirectoryInput(pool: ClassPool, dirInput: DirectoryInput) { val destDir = dirInput.file
dirInput.file.walkTopDown() .filter { it.isFile && it.extension == "class" } .forEach { classFile -> try { processClass(pool, classFile, destDir) } catch (e: Exception) { Log.e("JavassistTransform", "Failed to process ${classFile.name}: ${e.message}") } } }
private fun processJarInput(pool: ClassPool, jarInput: JarInput) { }
private fun processClass(pool: ClassPool, classFile: File, destDir: File) { val relativePath = classFile.relativeTo(destDir).path val className = relativePath .removeSuffix(".class") .replace(File.separatorChar, '.')
val ctClass: CtClass try { ctClass = pool.getCtClass(className) } catch (e: NotFoundException) { ctClass = pool.makeClass(classFile.inputStream()) }
if (ctClass.isFrozen) return if (ctClass.isInterface || ctClass.isAnnotation || ctClass.isEnum) return
if (!implementsOnClickListener(ctClass)) { ctClass.detach() return }
injectTrackingCode(pool, ctClass)
val outputFile = File(destDir, relativePath) outputFile.parentFile.mkdirs() ctClass.writeFile()
ctClass.detach() }
private fun implementsOnClickListener(ctClass: CtClass): Boolean { val interfaces = ctClass.interfaces if (interfaces != null) { for (iface in interfaces) { if (iface.name == "android.view.View\$OnClickListener") { return true } } } val superClass = ctClass.superclass if (superClass != null && superClass.name != "java.lang.Object") { return implementsOnClickListener(superClass) } return false }
private fun injectTrackingCode(pool: ClassPool, ctClass: CtClass) { try { val onClickMethod = ctClass.getDeclaredMethod( "onClick", arrayOf(pool.get("android.view.View")) )
onClickMethod.insertBefore( """ { com.example.tracking.TrackingHelper.trackClick($1); } """.trimIndent() )
onClickMethod.addCatch( """ { com.example.tracking.TrackingHelper.trackClickError($1, $e); throw $e; } """.trimIndent(), pool.get("java.lang.Exception") )
Log.i("JavassistTransform", "Injected tracking code into ${ctClass.name}.onClick(View)")
} catch (e: NotFoundException) { } catch (e: CannotCompileException) { Log.e("JavassistTransform", "Failed to inject code: ${e.message}") } } }
|
### 2.2 TrackingHelper 辅助类
object TrackingHelper {
private val executor = Executors.newSingleThreadExecutor()
@JvmStatic fun trackClick(view: View) { val context = view.context val info = buildMap { put("view_class", view.javaClass.name) put("view_id", view.id) put("view_text", getViewText(view)) put("page_name", getPageName(context)) put("timestamp", System.currentTimeMillis()) put("inject_method", "javassist") }
executor.execute { AnalyticsSDK.track("app_click", info) } }
@JvmStatic fun trackClickError(view: View, error: Throwable) { executor.execute { AnalyticsSDK.track("click_error", mapOf( "view_class" to view.javaClass.name, "error_type" to error.javaClass.name, "error_message" to (error.message ?: ""), "timestamp" to System.currentTimeMillis() )) } }
private fun getViewText(view: View): String { return ((view as? TextView)?.text?.toString() ?: "").take(100) }
private fun getPageName(context: Context): String { var ctx = context while (ctx is ContextWrapper) { if (ctx is Activity) return ctx.javaClass.simpleName ctx = ctx.baseContext } return context.javaClass.simpleName } }
|
### 2.3 更高级的注入策略
class AdvancedJavassistInjector(private val pool: ClassPool) {
fun injectTimingTracking(ctClass: CtClass) { try { val onClickMethod = ctClass.getDeclaredMethod( "onClick", arrayOf(pool.get("android.view.View")) )
onClickMethod.insertBefore( """ { long __trackStartTime = System.currentTimeMillis(); // 将开始时间存入 View 的 tag 中(非侵入式) $1.setTag(com.example.tracking.R.id.tracking_click_start_time, Long.valueOf(__trackStartTime)); } """.trimIndent() )
onClickMethod.insertAfter( """ { Long __startTime = (Long) $1.getTag( com.example.tracking.R.id.tracking_click_start_time); if (__startTime != null) { long __duration = System.currentTimeMillis() - __startTime; com.example.tracking.TrackingHelper.trackClickDuration($1, __duration); } } """.trimIndent() )
} catch (e: Exception) { Log.e("Javassist", "Failed to inject timing: ${e.message}") } }
fun injectSuspendOnClickTracking(ctClass: CtClass) { try { val methods = ctClass.declaredMethods for (method in methods) { if (method.name == "onClick" && method.parameterTypes.size == 2) { val firstParam = method.parameterTypes[0] if (firstParam.name == "android.view.View") { } } } } catch (e: Exception) {} }
fun shouldInject(className: String, includePackages: List<String>): Boolean { return includePackages.any { className.startsWith(it) } && !className.contains("BuildConfig") && !className.contains("\$") }
fun replaceOnClickBody(ctClass: CtClass) { try { val onClickMethod = ctClass.getDeclaredMethod( "onClick", arrayOf(pool.get("android.view.View")) )
onClickMethod.setBody( """ { com.example.tracking.TrackingHelper.trackClick($1); // 这里无法执行原始逻辑(已丢失) // 仅适用于特定的代码生成场景 } """.trimIndent() ) } catch (e: Exception) {} } }
|
## 三、构建性能分析
### 3.1 Javassist 的编译时开销
操作 | 耗时 (每class) ---------------------------|--------------- ClassPool.getCtClass() | ~0.5ms 查找接口实现 | ~0.2ms insertBefore() (简单插入) | ~1-3ms writeFile() (生成字节码) | ~2-5ms Total per class | ~5-10ms
1000 个 class 的项目 | ~5-10s 额外构建时间 5000 个 class 的项目 | ~25-50s 额外构建时间
|
### 3.2 Javassist vs ASM 性能对比
| 操作 | Javassist | ASM |
|------|-----------|-----|
| 加载 class | 快(字符串解析) | 快(二进制读取) |
| 代码注入 | 慢(需编译源码字符串) | 快(直接操作字节码) |
| 生成 class | 慢(需要生成+验证字节码) | 快(直接写入字节码) |
| 内存占用 | 高(保留源码 AST) | 低(Stream 处理) |
| 学习成本 | 低 | 高 |
## 四、运行时动态注入
object RuntimeJavassistInjector {
fun inject(context: Context) { val pool = ClassPool.getDefault() pool.appendClassPath(LoaderClassPath(context.classLoader))
try { val ctClass = pool.get("com.example.MainActivity\$onCreate\$1") val onClickMethod = ctClass.getDeclaredMethod("onClick")
onClickMethod.insertBefore( """ { com.example.tracking.TrackingHelper.trackClick($1); } """.trimIndent() )
val modifiedBytes = ctClass.toBytecode()
ctClass.detach() } catch (e: Exception) { Log.e("RuntimeJavassist", "Failed to inject", e) } } }
|
## 五、Javassist vs ASM vs AspectJ 深度对比
| 特性 | Javassist | ASM | AspectJ |
|------|-----------|-----|---------|
| **API 风格** | 源码字符串 | 字节码指令 (Visitor) | 声明式注解 |
| **操作单位** | CtClass / CtMethod | ClassNode / MethodNode | Aspect / Pointcut |
| **代码注入方式** | `insertBefore("Track.track($1);")` | `mv.visitMethodInsn(INVOKESTATIC, ...)` | `@Around("pointcut()")` |
| **类型检查** | 运行时(字符串编译时) | 无(直接操作字节码) | 编译期(ajc 编译时) |
| **Kotlin 支持** | 良好(基于 Java 字节码) | 良好 | 部分(suspend 限制) |
| **学习成本** | 低 | 高 | 中 |
| **灵活度** | 中 | 极高 | 中(受 AOP 模型约束) |
| **Android AGP 8.0** | 需适配新 API | 需适配新 API | 插件待适配 |
| **维护建议** | 小型项目快速实现 | 大型项目长期维护 | 中等项目,团队有 AOP 经验 |
## 六、常见错误与调试
### 6.1 ClassPool 找不到类
fun buildCompleteClassPath(pool: ClassPool, project: Project) { val androidJar = "${android.sdkDirectory}/platforms/${compileSdkVersion}/android.jar" pool.appendClassPath(androidJar)
pool.appendClassPath("${project.buildDir}/intermediates/javac/debug/classes")
configurations.getByName("implementation").forEach { dep -> pool.appendClassPath(dep.absolutePath) } }
|
### 6.2 注入代码编译错误
fun debugInjection(pool: ClassPool, ctClass: CtClass) { try { val code = """ { com.example.tracking.TrackingHelper.trackClick(${1}); // 错误:大括号冲突 } """.trimIndent()
val testClass = pool.makeClass("Debug_Injection_${System.currentTimeMillis()}") testClass.addMethod(CtMethod.make("public void test() $code", testClass))
} catch (e: CannotCompileException) { Log.e("JavassistDebug", "Compile error: ${e.message}") Log.e("JavassistDebug", "Reason: ${e.reason}") } }
|
## 七、ProGuard/R8 规则
# 保持 Javassist 注入调用的辅助类 -keep class com.example.tracking.TrackingHelper { public static void trackClick(android.view.View); public static void trackClickDuration(android.view.View, long); public static void trackClickError(android.view.View, java.lang.Throwable); }
# 保持 View.OnClickListener 匿名内部类的 onClick 不被移除 -keepclassmembers class * implements android.view.View$OnClickListener { public void onClick(android.view.View); }
# Javassist 运行时依赖(如果使用运行时动态注入) -dontwarn javassist.** -keep class javassist.** { *; }
|
---
## 面试常考问题
**Q1:Javassist 的 insertBefore 中 $1 代表什么?**
Javassist 的 `$` 变量体系:`$0` = this(当前对象引用),`$1, $2, ...` = 方法的第 1, 2, ... 个参数。在 `onClick(View v)` 中,`$1` 即被点击的 View 对象。其他常用变量:`$$ 表示所有参数的 Object[] 数组,$_ 表示返回值(仅在 insertAfter() 中可用,$r 可获取返回值的类型),$e 在 catch 块中表示捕获的异常对象,$w 用于包装类型自动装箱/拆箱(如 $w($1.getId()) 将 int 转为 Integer)。$sig 和 $type 分别在源码级提供参数类型签名数组和返回值类型。 |
|
|
Q2:Javassist 在 Android 中的类加载限制?
Android 的 ART 虚拟机使用 .dex 格式,而非 JVM 的 .class 格式。Javassist 生成的字节码须确保在 D8/R8 转换为 dex 前是合法的 JVM 字节码。主要限制:(1)Android 不支持运行时动态生成并加载 class(除非使用 DexClassLoader 多级加载,会造成内存和类加载器泄漏);(2)Android 对 lambda 和方法引用的处理与 JVM 不同(Android 使用脱糖,JVM 使用 invokedynamic);(3)编译时通过 Transform 在 dx 之前修改 class 文件是正确的做法,因为此时 class 文件还是标准的 JVM 格式,尚未转换为 dex。
Q3:Javassist 修改 class 后如何确保不被 R8 移除?
Transform 阶段在 R8 的 code shrinking 之前执行,所以插入的代码会被 R8 视为「已使用」。但如果 TrackingHelper.trackClick() 是一个只有 void 返回且无可见副作用的方法,R8 可能将其判定为 no-op 并移除整个调用。防范措施:(1)在 ProGuard 规则中添加 -keep 规则维持 TrackingHelper 的所有公共方法;(2)在 TrackingHelper 中添加一个被 System.currentTimeMillis() 等有副作用的调用,阻止 R8 的纯函数优化;(3)在 trackClick 方法上标记 @Keep 注解;(4)使用 assumenosideeffects 规则排除 TrackingHelper 类。
Q4:Javassist 的 insertBefore 和 insertAfter 的执行顺序与原始方法体的关系?
当同时使用 insertBefore、insertAfter 和 addCatch 时,生成的字节码执行顺序为:insertBefore 代码 → 原始方法体 → insertAfter 代码。具体来说:(1)insertBefore 在最前面执行,可以修改方法参数或提前返回;(2)原始方法体在中间执行;(3)如果设置了 asFinally = true,insertAfter 代码类似于 finally 块,无论方法正常返回还是抛出异常都会执行(但如果 catch 块中 throw 了新异常,asFinally 的代码不会执行,除非也设置了 addCatch);(4)addCatch 插入的 catch 块包裹了 insertBefore + 原始方法体 + insertAfter 的整个执行过程。
Q5:Javassist 如何处理泛型和注解?
Javassist 对泛型的支持有限:(1)CtClass.getGenericSignature() 获取泛型签名字符串,但 Javassist 不会解析它;(2)CtMethod.getParameterTypes() 返回的是擦除后的类型,不能直接获取泛型参数;(3)可以通过 method.getAttribute(AnnotationsAttribute.invisibleTag) 获取方法注解,field.getAttribute(AnnotationsAttribute.visibleTag) 获取字段注解。对于复杂泛型场景(如 Kotlin 的 reified 泛型或协变),建议使用 ASM 而非 Javassist。