AspectJ 是面向切面编程(AOP)在 Java 领域的标准实现。在全埋点场景中,AspectJ 可以在编译期 将埋点代码织入指定的方法(如 View.OnClickListener.onClick()),实现零业务代码侵入的全自动埋点。
一、AspectJ AOP 理论基础 1.1 AOP 核心概念 ┌─────────────────────────────────────────────────────┐ │ AspectJ 核心概念 │ ├─────────────────────────────────────────────────────┤ │ │ │ Join Point (连接点) │ │ ├── method execution: 方法执行时 │ │ ├── method call: 方法调用时 │ │ ├── constructor execution: 构造器执行 │ │ ├── field get/set: 字段读写 │ │ ├── exception handler: 异常处理 │ │ ├── class initialization: 类初始化 │ │ └── advice execution: 通知执行 │ │ │ │ Pointcut (切入点) │ │ ├── execution(* View.OnClickListener.onClick(..)) │ │ ├── call(* *.setOnClickListener(..)) │ │ ├── within(com.example..*) │ │ ├── @annotation(TrackClick) │ │ └── 组合: &&, ||, ! │ │ │ │ Advice (通知) │ │ ├── @Before: 方法执行前 │ │ ├── @After: 方法返回后 (正常/异常都执行) │ │ ├── @AfterReturning: 方法正常返回后 │ │ ├── @AfterThrowing: 方法抛出异常后 │ │ └── @Around: 环绕 (可控制是否执行原方法) │ │ │ │ Weaving (织入) │ │ ├── Compile-time (编译时织入) │ │ ├── Post-compile (编译后织入) ← Android 使用此类型 │ │ └── Load-time (类加载时织入) │ │ │ └─────────────────────────────────────────────────────┘
1.2 为什么 Android 全埋点使用 Post-compile Weaving? Android 的构建流程是先由 javac/kotlinc 编译为 .class,再由 d8/r8 转换为 .dex。AspectJ 在 .class 阶段(javac 之后、dx 之前)织入,属于 Post-compile Weaving(也叫 Binary Weaving)。这种方式不需要修改源码,不需要 AspectJ 编译器替代 javac,与 Gradle 构建系统无缝集成。
二、Gradle 集成:Android AspectJX 插件 2.1 插件配置 buildscript { dependencies { classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10' } } apply plugin: 'android-aspectjx' aspectjx { include 'com.example.app' exclude 'com.google' , 'androidx' , 'com.squareup' , 'com.google.android' enabled true ajcArgs { arg '-Xlint:ignore' arg '-showWeaveInfo' } }
2.2 AspectJX 的工作原理 ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ .kt/.java│ → │ kotlinc/ │ → │ AspectJX │ → │ d8/r8 │ → .dex │ 源码 │ │ javac │ │ (ajc) │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ Post-compile Weaving │ 在 .class 字节码中织入 Advice ▼ ┌─────────────────┐ │ 扫描所有 .class │ │ 匹配 Pointcut │ │ 修改字节码 │ │ 写回 .class │ └─────────────────┘
AspectJX 插件在 Gradle Transform 阶段拦截所有 class 文件,调用 AspectJ 编译器(ajc)进行织入。这是一个独立的构建步骤,会增加构建时间。
三、完整的点击埋点 Aspect 实现 3.1 核心 Aspect:onClick 方法拦截 @Aspect class ClickTrackingAspect { @Pointcut("execution(* android.view.View.OnClickListener+.onClick(android.view.View))" ) fun onClickMethod () { } @Pointcut("call(* android.view.View.setOnClickListener(..))" ) fun setOnClickListener () {} @Pointcut("!within(android..*) && !within(androidx..*)" ) fun notSystemClass () {} @Around("onClickMethod() && notSystemClass()" ) @Throws(Throwable::class) fun aroundClick (joinPoint: ProceedingJoinPoint ) : Any? { val view = joinPoint.args[0 ] as ? View ?: return joinPoint.proceed() val target = joinPoint.`this ` val signature = joinPoint.signature val clickStartTime = SystemClock.elapsedRealtime() val clickProperties = buildClickProperties(view, target, signature) val result: Any? try { result = joinPoint.proceed() val duration = SystemClock.elapsedRealtime() - clickStartTime trackClickSuccess(view, clickProperties, duration) } catch (throwable: Throwable) { val duration = SystemClock.elapsedRealtime() - clickStartTime trackClickException(view, clickProperties, duration, throwable) throw throwable } return result } private fun buildClickProperties ( view: View , target: Any ?, signature: Signature ) : Map<String, Any?> { val properties = mutableMapOf<String, Any?>() properties["view_class" ] = view.javaClass.name properties["view_id" ] = view.id try { if (view.id != View.NO_ID) { properties["view_id_name" ] = view.resources.getResourceEntryName(view.id) } } catch (_: Resources.NotFoundException) {} properties["listener_class" ] = target?.javaClass?.name ?: "unknown" properties["method_name" ] = signature.name properties["declaring_type" ] = signature.declaringType?.name (view as ? TextView)?.let { properties["view_text" ] = it.text?.toString()?.take(100 ) ?: "" } properties["page_name" ] = getCurrentActivityName(view) properties["click_timestamp" ] = System.currentTimeMillis() return properties } private fun trackClickSuccess ( view: View , properties: Map <String , Any?>, durationMs: Long ) { TrackerExecutor.execute { AnalyticsSDK.track("app_click" , properties.toMutableMap().apply { put("status" , "success" ) put("duration_ms" , durationMs) }) } if (durationMs > 200 ) { TrackerExecutor.execute { AnalyticsSDK.track("slow_click" , mapOf( "view_class" to view.javaClass.name, "duration_ms" to durationMs, "view_id" to view.id )) } } } private fun trackClickException ( view: View , properties: Map <String , Any?>, durationMs: Long , throwable: Throwable ) { TrackerExecutor.execute { AnalyticsSDK.track("click_exception" , properties.toMutableMap().apply { put("status" , "exception" ) put("duration_ms" , durationMs) put("exception_type" , throwable.javaClass.name) put("exception_message" , throwable.message ?: "" ) put("exception_stack" , throwable.stackTrace.take(5 ).joinToString("\n" )) }) } } private fun getCurrentActivityName (view: View ) : String { return (view.context as ? Activity)?.javaClass?.simpleName ?: view.context.javaClass.simpleName } }
3.2 更多 Pointcut 示例 @Aspect class RichTrackingAspect { @Pointcut("execution(* android.app.Activity+.onCreate(android.os.Bundle))" ) fun activityOnCreate () {} @Around("activityOnCreate()" ) fun aroundActivityCreate (joinPoint: ProceedingJoinPoint ) { val activity = joinPoint.`this ` as Activity AnalyticsSDK.track("page_create" , mapOf( "page_name" to activity.javaClass.simpleName, "page_class" to activity.javaClass.name, "timestamp" to System.currentTimeMillis() )) joinPoint.proceed() } @Pointcut("execution(* android.app.Activity+.onResume())" ) fun activityOnResume () {} @After("activityOnResume()" ) fun afterActivityResume (joinPoint: JoinPoint ) { val activity = joinPoint.`this ` as Activity AnalyticsSDK.track("page_view" , mapOf( "page_name" to activity.javaClass.simpleName )) } @Pointcut("execution(* android.content.DialogInterface.OnClickListener+.onClick(..))" ) fun dialogOnClick () {} @Around("dialogOnClick()" ) fun aroundDialogClick (joinPoint: ProceedingJoinPoint ) : Any? { val dialog = joinPoint.args[0 ] as ? DialogInterface val which = joinPoint.args.getOrNull(1 ) as ? Int ?: -1 AnalyticsSDK.track("dialog_click" , mapOf( "dialog_class" to dialog?.javaClass?.name, "which_button" to which, "button_name" to when (which) { DialogInterface.BUTTON_POSITIVE -> "positive" DialogInterface.BUTTON_NEGATIVE -> "negative" DialogInterface.BUTTON_NEUTRAL -> "neutral" else -> "item_$which " } )) return joinPoint.proceed() } @Pointcut("execution(* android.widget.AdapterView.OnItemClickListener+.onItemClick(..))" ) fun itemClick () {} @Around("itemClick()" ) fun aroundItemClick (joinPoint: ProceedingJoinPoint ) : Any? { val parent = joinPoint.args[0 ] as ? AdapterView<*> val view = joinPoint.args[1 ] as ? View val position = joinPoint.args[2 ] as ? Int ?: -1 val id = joinPoint.args[3 ] as ? Long ?: -1L if (view != null ) { AnalyticsSDK.track("list_item_click" , mapOf( "adapter_view" to parent?.javaClass?.simpleName, "position" to position, "item_id" to id, "view_class" to view.javaClass.simpleName, "view_text" to ((view as ? TextView)?.text?.toString()?.take(100 ) ?: "" ) )) } return joinPoint.proceed() } @Pointcut("execution(* android.view.MenuItem.OnMenuItemClickListener+.onMenuItemClick(..))" ) fun menuItemClick () {} @Before("menuItemClick()" ) fun beforeMenuItemClick (joinPoint: JoinPoint ) { val menuItem = joinPoint.args[0 ] as ? MenuItem AnalyticsSDK.track("menu_click" , mapOf( "menu_title" to (menuItem?.title?.toString() ?: "" ), "menu_id" to (menuItem?.itemId ?: -1 ) )) } @Pointcut("execution(@com.example.annotation.TrackClick * *(..))" ) fun annotatedWithTrackClick () {} @Around("annotatedWithTrackClick()" ) fun aroundAnnotatedMethod (joinPoint: ProceedingJoinPoint ) : Any? { val method = (joinPoint.signature as MethodSignature).method val annotation = method.getAnnotation(TrackClick::class .java) val eventId = annotation .eventId val startTime = System.currentTimeMillis() val result = joinPoint.proceed() val duration = System.currentTimeMillis() - startTime AnalyticsSDK.track(eventId, mapOf( "method" to method.name, "duration_ms" to duration, "timestamp" to startTime )) return result } }
四、Kotlin 兼容性的坑与解决方案 4.1 Kotlin Lambda 的 onClick Kotlin 代码 view.setOnClickListener { doSomething() } 编译后生成一个实现 View.OnClickListener 的匿名内部类,类名类似 MainActivity$onCreate$1。AspectJ 的 Pointcut execution(* View.OnClickListener+.onClick(..)) 会匹配到它的 onClick 方法。
反编译验证 :
view.setOnClickListener(new View .OnClickListener() { @Override public void onClick (View v) { doSomething(); } }); view.setOnClickListener(new View .OnClickListener() { @Override public void onClick (View v) { TrackingHelper.trackClick(v); doSomething(); } });
4.2 Suspend 函数的兼容性 AspectJ 对 Kotlin 的 suspend 函数支持有限。suspend fun onClick(view: View) 编译后会生成带有 Continuation 参数的方法,Pointcut 需要额外匹配 Continuation 参数签名。
@Pointcut("execution(* *(..)) && args(android.view.View, kotlin.coroutines.Continuation)" ) fun suspendOnClick () {}@Pointcut("execution(* *.*(..)) && args(android.view.View, ..)" ) fun anyMethodWithViewParam () {}
4.3 Inline / Reified 函数的排除 Kotlin 的 inline 函数在编译后被内联到调用点,其字节码本身不包含独立方法体。AspectJ 无法对 inline 函数的「执行」进行织入(因为该方法不存在运行时独立的调用),只能用 call(*) Pointcut 在调用点上织入。
@Pointcut("!execution(* kotlin..*.*(..))" ) fun notKotlinInline () {}
五、构建性能分析与优化 5.1 AspectJ 对构建时间的影响 测量方法:在 Gradle 中添加构建时间分析。
构建阶段 | 无 AspectJ | 有 AspectJ | 增加 --------------------|-----------|-----------|------ javac/kotlinc | 45s | 45s | 0s AspectJ Weaving | 0s | 15-30s | +15-30s dex (d8) | 20s | 20s | 0s Total | 65s | 80-95s | +20-40%
5.2 优化策略 aspectjx { include 'com.example.app.click' , 'com.example.app.event' exclude 'com.google' , 'androidx' , 'com.squareup' , 'com.facebook' enabled !project.hasProperty('debug' ) ajcArgs { arg '-incremental' } }
5.3 Composable 函数(Jetpack Compose) AspectJ 无法直接织入 @Composable 函数。Compose 的编译器插件会在编译期重组函数,导致 AspectJ 在字节码层面看到的结构与源码完全不同。Compose 全埋点需要专门的方案(如 Compose 的 Modifier.clickable 拦截)。
六、AspectJ 与其他方案的全面对比
维度
AspectJ
ASM
Javassist
Window.Callback
OnClickListener 代理
实现方式
声明式 Pointcut/Advice
命令式字节码操作
源码字符串注入
运行时委托
运行时反射
学习曲线
中(AOP 概念)
高(JVM 指令)
低(Java 字符串)
低(委托模式)
低(反射)
灵活度
中(受 Pointcut 语法限制)
极高
中
中
低
编译速度
慢(+15-30s)
中等(+10-20s)
中等(+10-15s)
无影响
无影响
运行时开销
零(编译时织入)
零
零(编译时)
每次触摸 ~1ms
每次点击 ~0.1ms
Kotlin 支持
部分(coroutine 受限)
完全(通过 Java 字节码)
完全
完全
完全
Gradle 维护
需要插件(AspectJX)
需要 Transform(AGP 7-)
需要 Transform
无需
无需
AGP 8.0
插件待适配
需迁移至 AsmClassVisitorFactory
需迁移
完全兼容
完全兼容
七、ProGuard/R8 规则 # 保持 Aspect 类 -keep @org.aspectj.lang.annotation.Aspect class * { *; } # 保持被 @Pointcut, @Around, @Before, @After 标记的方法 -keepclassmembers class * { @org.aspectj.lang.annotation.Pointcut <methods>; @org.aspectj.lang.annotation.Around <methods>; @org.aspectj.lang.annotation.Before <methods>; @org.aspectj.lang.annotation.After <methods>; @org.aspectj.lang.annotation.AfterReturning <methods>; @org.aspectj.lang.annotation.AfterThrowing <methods>; } # 保持 TrackingHelper(Aspect 中调用的辅助类) -keep class com.example.tracking.ClickTrackingAspect { *; } -keep class com.example.tracking.RichTrackingAspect { *; } -keep class com.example.tracking.TrackerExecutor { *; } # JoinPoint 相关类 -keep class org.aspectj.lang.JoinPoint { *; } -keep class org.aspectj.lang.ProceedingJoinPoint { *; } -keep class org.aspectj.lang.Signature { *; } # 防止 R8 把 OnClickListener 匿名内部类优化掉 -keep class * implements android.view.View$OnClickListener { void onClick(android.view.View); }
面试常考问题 Q1:AspectJ 的织入时机是在 javac 编译之前还是之后?
AspectJ 在 Java 编译为 .class 文件之后、.class 打包为 .dex 之前执行。它修改的是字节码。在 Android Gradle 构建流程中,AspectJX 插件在 Transform 阶段插入,扫描所有 class 文件,对匹配 Pointcut 的方法进行字节码增强。具体流程是:源码 → javac/kotlinc → .class → ajc (AspectJ Compiler) 织入 → 织入后的 .class → d8/r8 → .dex。由于是在 javac 之后,AspectJ 看不到原始源码,只能看到编译后的字节码,因此对泛型、lambda 等 Java/Kotlin 语法糖的匹配依赖于 javac 生成的字节码结构。
Q2:@Around 与 @Before/@After 的选择?
全埋点场景推荐 @Around,原因:(1)可以在 joinPoint.proceed() 前后分别采集时间,计算点击处理的耗时;(2)能通过 try-catch 捕获原始 onClick 中的异常并上报,@Before 只能被动地在执行前插入代码,无法感知异常;(3)可以选择性地不执行 原始 onClick 逻辑(如 A/B 测试中关闭某个功能的点击处理),@Before 无法阻止原始方法执行。缺点是 @Around 必须显式调用 proceed() 并处理返回值,代码略多。
Q3:AspectJ 如何处理 Kotlin 的 lambda onClick?
Kotlin 中 view.setOnClickListener { } 编译后生成实现 View.OnClickListener 的匿名内部类,其 onClick 方法符合 Pointcut execution(* View.OnClickListener+.onClick(View)),可以正常织入。反编译后可见 class 文件中实际生成了一个额外类(如 MainActivity$onCreate$1),AspectJ 对其字节码进行修改。但有一个特殊情况:如果 Kotlin 编译器对 lambda 使用了 -Xsam-conversions=class(sam 转换为匿名内部类而非 invokedynamic),字节码结构不同,AspectJ 可能匹配不上。不过默认的 Kotlin 编译行为是生成匿名内部类,所以大多数情况下正常工作。
Q4:AspectJX 插件在 AGP 8.0 中无法使用,如何迁移?
AGP 8.0 完全移除了旧的 Transform API。AspectJX 插件依赖 Transform API,因此无法直接在 AGP 8.0 上运行。迁移策略:(1)等待 AspectJX 官方适配 AGP 8.0 的新 Artifacts Transform API;(2)将 AspectJ 替换为 ASM 的 AsmClassVisitorFactory,这是 AGP 8.0 推荐的字节码操作方式;(3)如果必须继续使用 AspectJ,可以将织入步骤从 Gradle 插件中分离,在 CI 阶段独立运行 ajc 对 .class 文件进行后处理。无论哪种方案,都需要团队对构建流程有深入理解。
Q5:多个 Aspect 作用于同一个 Join Point 时的执行顺序是什么?
当多个 Aspect 的 Advice 匹配到同一个 Join Point 时,执行顺序取决于优先级。规则:(1)通过 @DeclarePrecedence 声明全局优先级,如 @DeclarePrecedence("SecurityAspect, TrackingAspect, LoggingAspect") 表示 Security 最先、Tracking 次之、Logging 最后;(2)同优先级的 Aspect,@Before 按字母序(类名)执行,@After 按字母序反向执行(洋葱模型);(3)在全埋点场景中,如果不显式指定优先级,多个埋点 Aspect 的执行顺序不确定,可能导致埋点信息不完整(如页面信息还未设置就触发了点击埋点)。建议显式声明优先级,将收集页面上下文的 Aspect 设为最高优先级(最先执行)。
AOSP 中相关源码:View.java 的 performClick() 方法(frameworks/base/core/java/android/view/View.java)。AspectJ 自身是一个独立开源项目,其编译器 ajc 的源码位于 github.com/eclipse/org.aspectj。