目录
  1. 1. 一、AST 的编译理论基础
    1. 1.1. 1.1 编译器前端管线
    2. 1.2. 1.2 AST 方案的两条技术路线
  2. 2. 二、方案 A:KSP(Kotlin Symbol Processing)注解处理器
    1. 2.1. 2.1 定义埋点注解
    2. 2.2. 2.2 KSP Processor 实现
    3. 2.3. 2.3 KSP Gradle 配置
  3. 3. 三、方案 B:Kotlin Compiler Plugin(IR 级别全量注入)
    1. 3.1. 3.1 Compiler Plugin 注册
    2. 3.2. 3.2 IR Generation Extension
    3. 3.3. 3.3 IR Transformer(核心)
    4. 3.4. 3.4 构建配置
  4. 4. 四、方案 C:Android Lint 自定义规则(埋点检查)
  5. 5. 五、AST 方案架构图
  6. 6. 六、AST vs 字节码方案深度对比
  7. 7. 七、生产实践:选择策略
  8. 8. 八、ProGuard/R8 规则
  9. 9. 面试常考问题
【全埋点方案系列】AppClick全埋点之AST处理

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 方案的两条技术路线

  1. 注解处理器(APT / KAPT / KSP):识别带有特定注解的代码元素,生成新的源码文件。特点:只能生成代码,不能修改已有代码。

  2. Kotlin Compiler Plugin(IR Plugin):在 Kotlin 编译器的 IR(Intermediate Representation)阶段,直接修改程序的 IR 树。特点:可以修改任何代码,但实现复杂度极高。

  3. Lint / Detekt 自定义规则:基于 AST 的静态分析,用于在 CI 阶段检查埋点是否遗漏,但不负责埋点注入。

二、方案 A:KSP(Kotlin Symbol Processing)注解处理器

2.1 定义埋点注解

/**
* 自动埋点注解
* 标记需要自动埋点的方法
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class TrackClick(
/** 事件 ID,为空时自动从函数名生成 */
val eventId: String = "",
/** 事件属性 JSON */
val properties: String = "",
/** 页面名称(自动获取或手动指定) */
val pageName: String = "",
/** 是否采集参数信息 */
val collectParams: Boolean = true
)

/**
* 标记不需要自动埋点的 View
* 用于排除敏感控件(如密码输入框)
*/
@Target(AnnotationTarget.FIELD, AnnotationTarget.LOCAL_VARIABLE)
@Retention(AnnotationRetention.SOURCE)
annotation class IgnoreTrack

2.2 KSP Processor 实现

/**
* KSP 符号处理器
*
* 工作流程:
* 1. 扫描所有 @TrackClick 注解的函数
* 2. 对每个函数生成一个 Wrapper 类
* 3. Wrapper 类在调用原始函数前后插入埋点代码
*/
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())

// 获取所有被 @TrackClick 标记的符号
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" }

// 生成 Wrapper 类
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()
}
}

/**
* KSP Processor Provider
*/
class TrackClickProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return TrackClickProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
options = environment.options
)
}
}

2.3 KSP Gradle 配置

// build.gradle.kts (module)
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") // 传递给 Processor 的配置
arg("tracking.debug", "false")
}

三、方案 B:Kotlin Compiler Plugin(IR 级别全量注入)

这是 AST 方案中最强大也最复杂的实现方式。Kotlin Compiler Plugin 在 IR(Intermediate Representation)级别操作,可以修改任何函数的实现体。

3.1 Compiler Plugin 注册

/**
* Kotlin Compiler Plugin 注册器
*
* 需要在 src/main/resources/META-INF/services/ 下注册
*/
@AutoService(ComponentRegistrar::class)
class TrackingPluginRegistrar : ComponentRegistrar {

override fun registerProjectComponents(
project: MockProject,
configuration: CompilerConfiguration
) {
val messageCollector = configuration.get(
CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY,
MessageCollector.NONE
)

// 注册 IR 扩展
IrGenerationExtension.registerExtension(
project,
TrackingIrGenerationExtension(messageCollector)
)
}
}

3.2 IR Generation Extension

/**
* IR Generation Extension: 在 IR 生成阶段注入埋点代码
*/
class TrackingIrGenerationExtension(
private val messageCollector: MessageCollector
) : IrGenerationExtension {

override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
// 遍历所有 IR 文件
for (file in moduleFragment.files) {
file.accept(
TrackingIrTransformer(pluginContext, messageCollector),
null
)
}
}
}

3.3 IR Transformer(核心)

/**
* IR Transformer:遍历 IR 树,在 onClick 实现中插入埋点
*/
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 {
// 检查是否实现了 OnClickListener 接口
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 {
// 识别 onClick 方法
if (isOnClickMethod(declaration)) {
injectTrackingCode(declaration)
}

// 识别 setOnClickListener 调用点(Lambda 形式)
// 例如: view.setOnClickListener { ... }
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 {
// 需要检查函数体中的 IR 调用
// 这是一个启发式方法,实际需要深度遍历 body
return false // 简化为在 visitCall 中处理
}

override fun visitCall(expression: IrCall): IrExpression {
val callee = expression.symbol

// 匹配 setOnClickListener 的调用
if (callee.owner.name.asString() == "setOnClickListener" &&
callee.owner.parentAsClass.fqNameWhenAvailable?.asString() == "android.view.View"
) {
// 找到 setOnClickListener 的参数(lambda)
val listenerArg = expression.getValueArgument(0)
if (listenerArg is IrLambda) {
injectTrackingIntoLambda(listenerArg)
}
}

return super.visitCall(expression)
}

/**
* 在 onClick 方法体前插入 trackClick 调用
*/
private fun injectTrackingCode(function: IrFunction) {
val body = function.body as? IrBlockBody ?: return
val viewParam = function.valueParameters[0]

// 构造跟踪调用:TrackingHelper.trackClick(view)
val irTrackingCall = IrCallImplBuilder(
startOffset = body.startOffset,
endOffset = body.endOffset,
type = pluginContext.irBuiltIns.unitType,
symbol = trackClickSymbol,
typeArgumentsCount = 0
).apply {
// 设置参数:view
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}"
)
}

/**
* 在 lambda 中注入追踪代码
*/
private fun injectTrackingIntoLambda(lambda: IrLambda) {
val body = lambda.body as? IrBlockBody ?: return
val viewParam = lambda.valueParameters.firstOrNull() ?: return

// 类似的处理...
}
}

3.4 构建配置

// build.gradle.kts (project level)
plugins {
kotlin("jvm") version "1.9.0" apply false
}

// build.gradle.kts (app module)
plugins {
kotlin("android")
id("com.example.tracking-plugin") // 自定义 Gradle Plugin
}

// 自定义插件中注册 Kotlin Compiler Plugin
class TrackingGradlePlugin : KotlinCompilerPluginSupportPlugin {
override fun apply(target: Project) {
target.extensions.findByType(KotlinAndroidExtension::class.java)?.let { ext ->
// 添加 compiler plugin 的 artifact
}
}

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 但未被埋点覆盖。

/**
* Lint 规则:检测未标记的点击事件
*/
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

// 检查 setOnClickListener 的参数是否包含埋点调用
val parent = node.uastParent ?: return
if (!containsTrackingCall(parent)) {
context.report(
ISSUE, node, context.getLocation(node),
"此点击事件可能缺少埋点追踪,请确认已被全埋点方案覆盖"
)
}
}

private fun containsTrackingCall(node: UElement): Boolean {
// 检查 AST 子树中是否包含 TrackingSDK.track() 或类似调用
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 在字节码层自动替换调用点。

打赏
  • 微信
  • 支付宝

评论