AST(Abstract Syntax Tree,抽象语法树)方案是在源码编译阶段进行埋点注入。与 ASM/Javassist 操作字节码不同,AST 操作的是源代码的语法结构树。它通常结合自定义注解 + 注解处理器(APT/KAPT/KSP),在编译期根据注解生成或修改代码,实现埋点。
一、AST 的编译理论基础
1.1 编译器前端管线
Java/Kotlin 编译器的前端管线:
源码 (.java/.kt) │ ▼ ┌────────────────┐ │ Lexical Analysis │ → Token 序列 │ 词法分析 │ └───────┬────────┘ │ ▼ ┌────────────────┐ │ Syntax Analysis │ → 抽象语法树 (AST) │ 语法分析 │ └───────┬────────┘ │ ▼ ┌────────────────┐ │ Semantic Analysis│ → 带类型信息的 AST (类型检查、符号解析) │ 语义分析 │ └───────┬────────┘ │ ▼ ┌────────────────┐ │ IR Generation │ → 中间表示 (IR) │ 中间代码生成 │ └────────────────┘
|
关键点:AST 是编译器前端阶段的产物,操作的是结构化的语法树而非字节码。 这意味着可以在源码层面理解和操作代码结构。
1.2 AST 方案的两条技术路线
注解处理器(APT / KAPT / KSP):识别带有特定注解的代码元素,生成新的源码文件。特点:只能生成代码,不能修改已有代码。
Kotlin Compiler Plugin(IR Plugin):在 Kotlin 编译器的 IR(Intermediate Representation)阶段,直接修改程序的 IR 树。特点:可以修改任何代码,但实现复杂度极高。
Lint / Detekt 自定义规则:基于 AST 的静态分析,用于在 CI 阶段检查埋点是否遗漏,但不负责埋点注入。
二、方案 A:KSP(Kotlin Symbol Processing)注解处理器
2.1 定义埋点注解
@Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.SOURCE) annotation class TrackClick( val eventId: String = "", val properties: String = "", val pageName: String = "", val collectParams: Boolean = true )
@Target(AnnotationTarget.FIELD, AnnotationTarget.LOCAL_VARIABLE) @Retention(AnnotationRetention.SOURCE) annotation class IgnoreTrack
|
2.2 KSP Processor 实现
class TrackClickProcessor( private val codeGenerator: CodeGenerator, private val logger: KSPLogger, private val options: Map<String, String> ) : SymbolProcessor {
private val processedFunctions = mutableSetOf<String>()
override fun process(resolver: Resolver): List<KSAnnotated> { val trackClickType = requireNotNull( resolver.getClassDeclarationByName( resolver.getKSNameFromString("com.example.annotation.TrackClick") ) ).asType(emptyList())
val symbols = resolver.getSymbolsWithAnnotation(trackClickType.toClassName().canonicalName)
val deferred = mutableListOf<KSAnnotated>()
symbols.filterIsInstance<KSFunctionDeclaration>().forEach { function -> try { processFunction(function, resolver) } catch (e: Exception) { logger.error("Failed to process ${function.qualifiedName?.asString()}: ${e.message}") deferred.add(function) } }
return deferred }
private fun processFunction(function: KSFunctionDeclaration, resolver: Resolver) { val funcName = function.simpleName.asString() val packageName = function.packageName.asString() val qualifiedName = "${packageName}.${funcName}"
if (!processedFunctions.add(qualifiedName)) return
val annotation = function.annotations .first { it.shortName.asString() == "TrackClick" } val eventId = extractAnnotationValue(annotation, "eventId") ?: "click_${funcName.toLowerCase()}" val properties = extractAnnotationValue(annotation, "properties") ?: "{}" val pageName = extractAnnotationValue(annotation, "pageName") ?: ""
val params = function.parameters val viewParam = params.find { it.type.resolve().declaration.qualifiedName?.asString() == "android.view.View" }
val wrapperName = "${funcName}_TrackingWrapper" val fileSpec = FileSpec.builder(packageName, wrapperName) .addType( TypeSpec.objectBuilder(wrapperName) .addFunction( FunSpec.builder(funcName) .apply { params.forEach { param -> val paramName = param.name?.asString() ?: "param" val paramType = param.type.resolve().declaration .qualifiedName?.asString() ?: "Any" addParameter(paramName, ClassName.bestGuess(paramType)) } function.modifiers.forEach { modifier -> when (modifier) { Modifier.SUSPEND -> addModifiers(KModifier.SUSPEND) Modifier.PUBLIC -> addModifiers(KModifier.PUBLIC) else -> {} } } } .addStatement("// === 自动生成的埋点代码 ===") .addStatement( "com.example.tracking.TrackingSDK.track(%S, %L)", eventId, properties ) .addStatement("// === 原始业务逻辑 ===") .addStatement( "${funcName}(${params.joinToString(", ") { it.name?.asString() ?: "" }})" ) .build() ) .build() ) .build()
fileSpec.writeTo(codeGenerator, Dependencies(false, function.containingFile!!)) }
private fun extractAnnotationValue(annotation: KSAnnotation, key: String): String? { return annotation.arguments .find { it.name?.asString() == key } ?.value?.toString() } }
class TrackClickProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { return TrackClickProcessor( codeGenerator = environment.codeGenerator, logger = environment.logger, options = environment.options ) } }
|
2.3 KSP Gradle 配置
plugins { id("com.google.devtools.ksp") version "1.9.0-1.0.13" }
dependencies { ksp(project(":tracking-ksp-processor")) implementation(project(":tracking-annotation")) }
ksp { arg("tracking.sdk", "sensors") arg("tracking.debug", "false") }
|
三、方案 B:Kotlin Compiler Plugin(IR 级别全量注入)
这是 AST 方案中最强大也最复杂的实现方式。Kotlin Compiler Plugin 在 IR(Intermediate Representation)级别操作,可以修改任何函数的实现体。
3.1 Compiler Plugin 注册
@AutoService(ComponentRegistrar::class) class TrackingPluginRegistrar : ComponentRegistrar {
override fun registerProjectComponents( project: MockProject, configuration: CompilerConfiguration ) { val messageCollector = configuration.get( CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE )
IrGenerationExtension.registerExtension( project, TrackingIrGenerationExtension(messageCollector) ) } }
|
3.2 IR Generation Extension
class TrackingIrGenerationExtension( private val messageCollector: MessageCollector ) : IrGenerationExtension {
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { for (file in moduleFragment.files) { file.accept( TrackingIrTransformer(pluginContext, messageCollector), null ) } } }
|
class TrackingIrTransformer( private val pluginContext: IrPluginContext, private val messageCollector: MessageCollector ) : IrElementTransformerVoid() {
private val trackingHelperSymbol by lazy { pluginContext.referenceClass( FqName("com.example.tracking.TrackingHelper") ) ?: error("TrackingHelper not found") }
private val trackClickSymbol by lazy { trackingHelperSymbol.simpleFunctions() .first { it.name.asString() == "trackClick" } }
private val viewClassSymbol by lazy { pluginContext.referenceClass(FqName("android.view.View")) }
override fun visitClassNew(declaration: IrClass): IrStatement { val isOnClickListenerClass = declaration.superTypes.any { superType -> superType.classFqName?.asString() == "android.view.View.OnClickListener" }
if (isOnClickListenerClass) { messageCollector.report( CompilerMessageSeverity.INFO, "Found OnClickListener implementation: ${declaration.name}" ) }
return super.visitClassNew(declaration) }
override fun visitFunctionNew(declaration: IrFunction): IrStatement { if (isOnClickMethod(declaration)) { injectTrackingCode(declaration) }
if (isSetOnClickListenerCall(declaration)) { injectTrackingCodeIntoLambda(declaration) }
return super.visitFunctionNew(declaration) }
private fun isOnClickMethod(function: IrFunction): Boolean { return function.name.asString() == "onClick" && function.valueParameters.size == 1 && function.valueParameters[0].type.classFqName?.asString() == "android.view.View" }
private fun isSetOnClickListenerCall(function: IrFunction): Boolean { return false }
override fun visitCall(expression: IrCall): IrExpression { val callee = expression.symbol
if (callee.owner.name.asString() == "setOnClickListener" && callee.owner.parentAsClass.fqNameWhenAvailable?.asString() == "android.view.View" ) { val listenerArg = expression.getValueArgument(0) if (listenerArg is IrLambda) { injectTrackingIntoLambda(listenerArg) } }
return super.visitCall(expression) }
private fun injectTrackingCode(function: IrFunction) { val body = function.body as? IrBlockBody ?: return val viewParam = function.valueParameters[0]
val irTrackingCall = IrCallImplBuilder( startOffset = body.startOffset, endOffset = body.endOffset, type = pluginContext.irBuiltIns.unitType, symbol = trackClickSymbol, typeArgumentsCount = 0 ).apply { putValueArgument( 0, IrGetValueImpl( startOffset = viewParam.startOffset, endOffset = viewParam.endOffset, type = viewParam.type, symbol = viewParam.symbol ) ) }.build()
body.statements.add(0, irTrackingCall)
messageCollector.report( CompilerMessageSeverity.INFO, "Injected tracking code into ${function.name}" ) }
private fun injectTrackingIntoLambda(lambda: IrLambda) { val body = lambda.body as? IrBlockBody ?: return val viewParam = lambda.valueParameters.firstOrNull() ?: return
} }
|
3.4 构建配置
plugins { kotlin("jvm") version "1.9.0" apply false }
plugins { kotlin("android") id("com.example.tracking-plugin") }
class TrackingGradlePlugin : KotlinCompilerPluginSupportPlugin { override fun apply(target: Project) { target.extensions.findByType(KotlinAndroidExtension::class.java)?.let { ext -> } }
override fun getCompilerPluginId() = "com.example.tracking"
override fun getPluginArtifact(): SubpluginArtifact = SubpluginArtifact( groupId = "com.example", artifactId = "tracking-compiler-plugin", version = "1.0.0" )
override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean = true }
|
四、方案 C:Android Lint 自定义规则(埋点检查)
AST 不仅可以用于生成代码,还可以用于检查埋点是否完整。通过在 CI 阶段运行 Lint 规则,可以检测哪些 View 设置了 OnClickListener 但未被埋点覆盖。
class MissingTrackingDetector : Detector(), SourceCodeScanner {
companion object { val ISSUE = Issue.create( id = "MissingClickTracking", briefDescription = "View 点击事件未添加埋点", explanation = "所有 View 的点击事件应该被埋点 SDK 覆盖", category = Category.CORRECTNESS, priority = 5, severity = Severity.WARNING, implementation = Implementation( MissingTrackingDetector::class.java, Scope.JAVA_FILE_SCOPE ) ) }
override fun getApplicableMethodNames(): List<String> = listOf("setOnClickListener")
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) { val containingClass = method.containingClass ?: return if (containingClass.qualifiedName != "android.view.View") return
val parent = node.uastParent ?: return if (!containsTrackingCall(parent)) { context.report( ISSUE, node, context.getLocation(node), "此点击事件可能缺少埋点追踪,请确认已被全埋点方案覆盖" ) } }
private fun containsTrackingCall(node: UElement): Boolean { return node.asRecursiveLogString().contains("track") } }
|
五、AST 方案架构图
┌──────────────────────────────┐ │ 源码 (.kt / .java) │ └──────────────┬───────────────┘ │ ┌───────────────────────────┼───────────────────────────┐ │ │ │ ▼ ▼ ▼ ┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │ 注解处理器 │ │ Kotlin Compiler │ │ Lint 规则 │ │ (KSP / KAPT) │ │ Plugin (IR Plugin) │ │ (检测埋点遗漏) │ │ │ │ │ │ │ │ ● 扫描 @TrackClick│ │ ● 遍历 IR 树 │ │ ● 扫描 setOnClick-│ │ ● 识别标记方法 │ │ ● 在 onClick 方法 │ │ Listener 调用 │ │ ● 生成 Wrapper 类 │ │ 体前插入 track() 调用│ │ ● 检查是否含埋点 │ │ ● 源码 -> 新源码 │ │ ● 修改 IR → 字节码 │ │ ● CI 阶段报 Warning│ └─────────┬────────┘ └──────────┬───────────┘ └──────────────────┘ │ │ ▼ ▼ ┌──────────────────┐ ┌──────────────────────┐ │ 新生成的源码文件 │ │ 修改后的 IR │ │ (.kt / .java) │ │ → .class 字节码 │ └─────────┬────────┘ └──────────┬───────────┘ │ │ └───────────┬────────────┘ │ ▼ ┌──────────────────────┐ │ Kotlin/Java 编译器 │ │ → .class → .dex │ └──────────────────────┘
|
六、AST vs 字节码方案深度对比
| 维度 |
AST(KSP/KCP) |
字节码(ASM/Javassist) |
| 操作层级 |
源码 AST 或 IR |
.class 字节码 |
| 实现方式 |
生成新源码 / 修改 IR 树 |
直接修改字节码指令 |
| 代码安全性 |
编译期类型检查 |
无类型检查,错误在运行时暴露 |
| 覆盖范围 |
仅注解标记的方法(KSP)或全量(KCP) |
全量遍历所有 class |
| Kotlin 特有语法 |
KSP 原生支持,KCP 支持 suspend/coroutine |
通过 Java 字节码兼容(降级处理) |
| 第三方库 |
不处理(KSP)/ 可处理(KCP) |
可处理(jar 中的 class) |
| 学习成本 |
中(需要理解 KSP API 或 IR 树结构) |
高(需要理解 JVM 字节码指令) |
| 构建速度影响 |
KSP 增量编译快(~2x KAPT) |
Transform 阶段显著增加构建时间 |
| AGP 兼容性 |
KSP 稳定,KCP 需要适配 Kotlin 版本 |
AGP 8.0 废弃 Transform API |
| 维护成本 |
KSP 低(注解 API 稳定),KCP 高(随 Kotlin 版本变化) |
中(字节码指令相对稳定) |
七、生产实践:选择策略
是否要求零业务代码侵入? ├── 是 → 字节码方案或 Kotlin Compiler Plugin └── 否 → KSP 注解方案
需要覆盖第三方库吗? ├── 是 → 字节码方案(ASM/Javassist) └── 否 → KSP 注解方案即可
项目使用 Kotlin 吗? ├── 是,使用了 coroutines/compose │ └── Kotlin Compiler Plugin(IR)最有优势 └── Java 项目 └── APT + ASM 组合
团队有字节码经验吗? ├── 是 → ASM 方案 └── 否 → KSP + 简单注解(成本最低)
|
八、ProGuard/R8 规则
# 保持注解类 -keep @interface com.example.annotation.TrackClick -keep @interface com.example.annotation.IgnoreTrack
# KSP 生成的代码保持 -keep class **._TrackingWrapper { *; } -keep class **.*_TrackingWrapper { *; }
# 保持 TrackingHelper 不被内联 -keep class com.example.tracking.TrackingHelper { public static void trackClick(android.view.View); public static void trackClick(java.lang.String, java.util.Map); }
|
面试常考问题
Q1:KSP 与 KAPT 的关系和区别?
KSP(Kotlin Symbol Processing)是 Google 专为 Kotlin 设计的符号处理器,直接解析 Kotlin 源码的 AST,不需要通过 Java stub 生成中间产物。KAPT(Kotlin Annotation Processing Tool)则先将 Kotlin 编译为 Java stub,再用 Java 注解处理器(javac 的 APT)处理。关键差异:(1)KSP 比 KAPT 快约 2 倍,因为它跳过了 stub 生成阶段;(2)KSP 原生支持 Kotlin 特有语法(如 suspend 函数、internal 可见性、declaration-site variance),KAPT 在 Java stub 中会丢失这些信息;(3)KSP 的 API 是 first-class Kotlin API,使用起来更自然;(4)KSP 目前只支持生成新文件,不支持修改已有文件(与 KAPT 相同),要修改已有代码必须使用 Kotlin Compiler Plugin。
Q2:AST 方案能否实现全局无注解全埋点?
不能直接通过 APT/KSP 实现全局无注解埋点,因为它们只能处理带注解的符号。要实现无注解全局埋点,需要使用 Kotlin Compiler Plugin 在 IR 级别对所有方法进行遍历和注入。Kotlin Compiler Plugin 的能力等同于 ASM 字节码操作,它可以在 IR 树中识别所有 setOnClickListener 调用点和 onClick 方法实现,并自动注入 track() 调用。Google 的 Jetpack Compose Compiler Plugin 也是在 IR 层工作的典型例子。但 KCP 的开发成本极高:需要理解 Kotlin IR 的树结构、处理各种语法糖的脱糖(如 lambda 到匿名内部类的转换)、跟随 Kotlin 版本更新 IR 结构的变化。
Q3:为什么大多数全埋点框架选择字节码方案而非 AST?
三个原因:(1)全量覆盖:字节码方案(ASM/Javassist/AspectJ)天然支持全局遍历,无需开发者添加注解。AST 方案(KSP)只处理带注解的代码,违背全埋点「零业务侵入」的核心价值;(2)第三方库覆盖:字节码方案可以处理 aar/jar 中的第三方 UI 组件(如第三方图片选择器、分享弹窗),AST 方案只能处理项目自身的源码;(3)生态成熟度:ASM 已有 20 年历史,社区积累了大量的 Android 字节码操作经验和库(如 Booster、ByteX),而 Kotlin Compiler Plugin 的生态仍在快速发展中。不过,随着 AGP 8.0 废弃 Transform API 和 KSP 2.0 的成熟,AST 方案的长期前景更好。
Q4:Kotlin Compiler Plugin 如何处理增量编译?
Kotlin Compiler Plugin 的 IR 扩展在增量编译时会收到变化的 IR 文件(而非全量)。Plugin 需要正确处理增量编译的文件粒度。关键策略:(1)只处理当前编译单元(即发生变化的文件),不重复处理未变化的文件;(2)在 IR 层面使用 IrDeclarationOrigin 标记 Plugin 生成的代码,避免在后续编译轮次中重复注入;(3)注意 Plugin 之间可能存在交互——如果项目中同时使用了多个 Kotlin Compiler Plugin(如 Compose Compiler + 埋点 Plugin),需要确保它们的 IR 修改不冲突。
Q5:KSP 生成的代码如何与项目现有代码交互?
KSP 生成的代码位于 build/generated/ksp/ 目录,和手写代码在同一个编译单元中编译。生成的代码可以直接:(1)调用项目中的任何 public/internal 类和方法;(2)访问项目中的资源 ID(R.id.xxx);(3)使用项目的依赖库。但注意:(1)KSP 生成的代码不能引用同一次编译中生成的其他 KSP 代码(无跨生成文件依赖);(2)生成的代码不能修改已有类的实现(这是 KSP vs ASM 的根本区别);(3)生成的代码需要在业务代码中手动调用(如调用 Wrapper 类替代原始函数),这实际上构成了一定的代码侵入性。要消除这种侵入性,可以结合 Gradle Transform 在字节码层自动替换调用点。