简介这份资源是面向 Android 开发者的 AOP 实战 Demo基于 Kotlin 语言结合 AspectJ 框架实现面向切面编程适合已掌握 Kotlin 基础、希望提升代码可维护性与模块化能力的中级开发者。压缩包共 64 个文件约 953KB包含 8 个 kt 源码文件、11 个 xml 配置、4 个 gradle 构建脚本及若干 properties、proguard 规则与 jar 依赖覆盖 api 与 app 两个模块结构完整可直接导入 Android Studio 运行。Demo 以 FastClickAop 为切入点演示如何通过 Aspect 定义切面、编写切点表达式与 Around 通知在编译期织入逻辑解决按钮快速连点导致重复触发的问题。读者可借此掌握 AspectJ 与 Kotlin 的集成方式、切面类编写与织入配置并延伸至日志记录、性能监控、权限控制等场景。目前已有 494 人学习适合作为 AOP 入门的参考范例。1. 从一次「日志埋点失控」说起AspectJ 在 Kotlin 里到底解决什么问题一个 Android 项目做到中期最容易失控的不是业务逻辑而是横切关注点。登录校验、点击防抖、耗时统计、权限拦截、埋点上报这些东西散落在几十个 Activity 和 ViewModel 里改一次埋点规则要翻遍整个工程。我见过一个模块光onClick里就塞了防抖、埋点、跳转三件事后来产品要改埋点字段改到第三个文件就改漏了。这就是 AOP 要解决的问题把「和业务无关、但每个业务都要做」的逻辑抽出来用切面统一织入。Android 上做 AOP 的方案不少AspectJ 是落地最稳、语法最接近 Java 原生注解的一个配合 Kotlin 也能跑通。这篇笔记就围绕「android 实现 AOP 使用 Aspectj Kotlin 版」这个方向把环境配置、切点写法、Kotlin 特有的坑和验证方法讲清楚适合已经会写 Kotlin、但被横切逻辑折磨过的 Android 开发者。AspectJ 的核心价值在于「编译期织入」——它在字节码生成阶段把切面代码插进目标方法运行时几乎没有反射开销这点比动态代理方案更适合移动端。Kotlin 版 Demo 的意义在于Kotlin 的final类、object单例、扩展函数、属性访问器这些语法糖会让切点表达式和 Java 版有明显差异直接抄 Java 教程大概率翻车。下面按「先跑通最小闭环再拆参数再排坑」的顺序推进。2. 把 AspectJ 接进 Kotlin 工程Gradle 配置与最小可运行切面2.1 为什么选 AspectJ 而不是其他 AOP 方案Android 上常见的 AOP 手段有三类动态代理运行时代理接口、ASM/Javassist 字节码插桩自己写 Transform、AspectJ注解驱动 编译期织入。动态代理只能代理接口Activity 这种类没法直接代理ASM 灵活但学习曲线陡写一个方法插桩要处理一堆 ClassVisitor 回调AspectJ 用Aspect、Pointcut、Around这套注解声明式描述「在哪里、做什么」织入交给编译器。代价是引入 AspectJ 的 Gradle 插件和 runtime 库包体积增加很小runtime 约几十 KB编译时间会涨一点。选型上我的判断标准很简单如果横切逻辑是「方法级、规则相对固定、需要覆盖大量类」AspectJ 性价比最高如果是「需要动态开关、运行时决定织入目标」那 AspectJ 的编译期织入反而不合适得考虑其他方案。绝大多数埋点、防抖、耗时统计场景AspectJ 够用。2.2 Gradle 配置插件、依赖与 Kotlin 兼容AspectJ 官方没有专门的 Kotlin Gradle 插件社区常用的是一个第三方 Gradle 插件来触发ajc编译。配置分三步根工程加插件 classpathapp 模块应用插件加 runtime 依赖。下面是一份可抄的配置骨架。// 根 build.gradleGroovy DSL buildscript { repositories { google() mavenCentral() } dependencies { // AspectJ 织入插件版本按工程实际选这里用占位 classpath org.aspectj:aspectjtools:1.9.7 classpath com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10 } }// app/build.gradle apply plugin: com.android.application apply plugin: kotlin-android apply plugin: android-aspectjx // 关键触发 ajc 织入 android { compileSdkVersion 33 defaultConfig { minSdkVersion 21 targetSdkVersion 33 } // Kotlin 与 AspectJ 共存时建议开启这个避免部分类被跳过 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation org.aspectj:aspectjrt:1.9.7 // 运行时库切面注解靠它 implementation androidx.appcompat:appcompat:1.6.1 implementation org.jetbrains.kotlin:kotlin-stdlib:1.8.0 } aspectjx { // 只织入自己的包避免把第三方库也扫一遍导致编译爆炸 include com.example.aopdemo exclude androidx, kotlin }逻辑说明android-aspectjx插件在 Android 构建流程里插入了一个 Transform对 class 文件调用ajc编译。aspectjx块里的include/exclude是性能关键——不限制范围的话插件会扫描所有依赖的 class编译时间可能从几十秒涨到几分钟。参数上include写你自己的包名exclude把 androidx、kotlin 标准库这些排除掉它们不需要织入。提示不同版本的 AspectJ 插件对 AGP 版本有兼容要求升级 AGP 后如果报ajc相关错误先查插件版本是否匹配别急着改代码。2.3 第一个切面用 Around 统计方法耗时配置好之后写一个最小切面验证链路是否通。目标是在某个方法执行前后打时间戳算出耗时。package com.example.aopdemo.aspect import android.util.Log import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.annotation.Around import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.Pointcut Aspect class TimeCostAspect { // 切点匹配 com.example.aopdemo.business 包下所有类的所有公开方法 Pointcut(execution(* com.example.aopdemo.business.*.*(..))) fun businessMethods() { } Around(businessMethods()) fun aroundBusiness(joinPoint: ProceedingJoinPoint): Any? { val start System.currentTimeMillis() return try { joinPoint.proceed() // 执行原方法 } finally { val cost System.currentTimeMillis() - start Log.d(AOP, ${joinPoint.signature.name} 耗时 ${cost}ms) } } }逻辑说明Pointcut定义匹配规则execution(* com.example.aopdemo.business.*.*(..))的含义是「business 包下任意类的任意方法任意参数」。Around拿到ProceedingJoinPoint调用proceed()执行原逻辑前后夹时间戳。finally保证原方法抛异常时也能记录耗时。参数上joinPoint.signature.name取方法名joinPoint.args可以取参数数组做更细的日志。写完切面后在business包下随便写一个方法调用跑起来看 Logcat 有没有AOP标签输出。有输出说明织入成功没有的话先检查aspectjx的include是否覆盖了目标包。3. Kotlin 语法下的切点表达式final 类、object 与属性访问器3.1 Kotlin 类默认 final切点匹配要注意什么Java 里类默认可以被继承AspectJ 织入时对final方法有特殊处理。Kotlin 的类和方法默认就是final这本身不影响 AspectJ 的编译期织入织入是改字节码不是继承但会影响你写切点时的判断。比如你想匹配「所有继承自 BaseActivity 的子类方法」用execution(* com.example.aopdemo.BaseActivity.*(..))这种通配在 Kotlin 里如果子类是final匹配逻辑依然成立因为看的是类型关系不是继承能力。真正容易踩的是object单例。Kotlin 的object编译后是一个类加一个INSTANCE静态字段方法本身是实例方法。切点写execution(* com.example.aopdemo.Singleton.doSomething(..))能匹配到但如果你写的是execution(* com.example.aopdemo.Singleton.INSTANCE.doSomething(..))就会失败因为源码层面没有INSTANCE这个写法。记住切点表达式匹配的是源码可见的签名不是编译后的字节码结构。3.2 匹配 Kotlin 属性访问器getter/setter 的切点写法Kotlin 的属性var name: String编译后会生成getName()和setName()。如果你想在属性赋值时做校验切点要针对 setter 写。Aspect class PropertyCheckAspect { // 匹配 User 类中所有属性的 setter Pointcut(execution(void com.example.aopdemo.model.User.set*(..))) fun userSetters() { } Before(userSetters()) fun beforeSet(joinPoint: ProceedingJoinPoint) { val field joinPoint.signature.name.removePrefix(set) val value joinPoint.args.firstOrNull() Log.d(AOP, 设置属性 $field $value) } }逻辑说明set*(..)匹配所有以set开头的方法返回类型写void是因为 Kotlin 的 setter 返回Unit编译后对应void。joinPoint.args.firstOrNull()取第一个参数也就是赋的值。这里有个细节如果属性是val只读只有 getter 没有 setter切点匹配不到别以为是配置错了。3.3 扩展函数与顶层函数的切点匹配Kotlin 的扩展函数编译后是静态方法第一个参数是接收者。比如fun String.toSafeInt(): Int编译成UtilsKt.toSafeInt(String)。切点要按静态方法写。// 源码扩展函数 fun String.toSafeInt(): Int this.toIntOrNull() ?: 0 // 切点匹配文件名 方法名 Pointcut(execution(int com.example.aopdemo.ext.StringExtKt.toSafeInt(..))) fun safeIntPointcut() { }逻辑说明扩展函数所在的文件StringExt.kt编译后生成StringExtKt类方法名不变接收者变成第一个参数。切点里写StringExtKt.toSafeInt才能匹配。这个规则对顶层函数同样适用——顶层函数编译后挂在「文件名 Kt」的类上。很多人按源码的String.toSafeInt写切点编译不报错但织入不生效就是没换算成 JVM 签名。4. 避坑与排查AspectJ Kotlin 最常见的 5 个翻车现场4.1 切面不生效Logcat 一片安静现象切面类写好了目标方法也调用了但没有任何日志输出。原因通常有三个一是aspectjx的include没覆盖目标包插件根本没扫到二是切点表达式写错比如包名拼错、返回类型不匹配三是切面类本身没被编译进织入范围比如放在了exclude的包里。解决先把include放宽到整个 app 包确认能生效后再收窄用ajc的编译日志确认切面类是否被处理。4.2 编译报错「cant determine superclass of missing type」现象接入 AspectJ 后编译失败提示找不到某个类的父类。原因AspectJ 编译时需要完整的类路径某些只在运行时提供的依赖比如provided或compileOnly在织入阶段缺失。解决把相关依赖改成implementation或者用aspectjx的exclude把报错的包排除掉。这个错误在引入大量第三方 SDK 的工程里很常见血泪经验是别硬刚先排除再逐个加回。4.3 Kotlin 的 suspend 函数切点匹配异常现象对suspend函数写切点编译通过但运行时报类型转换错误。原因suspend函数编译后签名多了一个Continuation参数返回类型是Object。切点里如果写死返回类型或参数个数就会不匹配。解决切点用(..)匹配任意参数返回类型用*然后在切面里通过joinPoint.args判断实际参数。不要试图精确匹配Continuation不同 Kotlin 版本生成的签名有差异。4.4 织入后方法数暴涨导致 65536 限制现象接入 AspectJ 后某些老工程突然报method count exceeds 65536。原因AspectJ 织入会在目标方法里插入切面调用每个被织入的方法都会增加指令间接推高方法数。解决开启multiDexEnabled true同时用aspectjx的include严格限制织入范围别全工程扫。我一般只对埋点、防抖这类高频切面开全量耗时统计这种调试用的切面只在 debug 构建里启用。4.5 切面里抛异常导致业务崩溃现象切面代码里一个空指针直接把原业务方法带崩了。原因Around里proceed()前后的代码和原方法在同一个调用栈切面抛异常会向上传播。解决切面里的日志、统计逻辑全部包try-catch绝不让切面异常影响业务。尤其是joinPoint.args取值、signature解析这些操作参数为 null 时很容易炸。原则是切面可以失败业务不能受影响。5. 进阶用自定义注解 切点做精准防抖与埋点5.1 自定义注解定义切点标记全量匹配包下所有方法太粗暴实际项目里更常用「注解驱动」——只对加了特定注解的方法织入。先定义一个 Kotlin 注解。Target(AnnotationTarget.FUNCTION) Retention(AnnotationRetention.BINARY) annotation class ClickDebounce(val interval: Long 500L)逻辑说明Target(FUNCTION)限定只能标在方法上Retention(BINARY)保证注解保留到字节码阶段AspectJ 编译期才能读到。interval参数让每个方法可以自定义防抖间隔默认 500ms。5.2 切点匹配注解并读取参数Aspect class DebounceAspect { // 匹配所有带 ClickDebounce 注解的方法 Pointcut(execution(com.example.aopdemo.annotation.ClickDebounce * *(..)) annotation(debounce)) fun debouncePointcut(debounce: ClickDebounce) { } Around(debouncePointcut(debounce)) fun aroundDebounce(joinPoint: ProceedingJoinPoint, debounce: ClickDebounce): Any? { val now System.currentTimeMillis() val key joinPoint.signature.toShortString() val last lastClickMap[key] ?: 0L if (now - last debounce.interval) { return null // 拦截不执行原方法 } lastClickMap[key] now return joinPoint.proceed() } private val lastClickMap mutableMapOfString, Long() }逻辑说明切点里annotation(debounce)把注解实例绑定到方法参数debounce这样切面里能直接读debounce.interval。joinPoint.signature.toShortString()生成方法唯一标识作为 map 的 key。如果距离上次点击小于间隔直接return null拦截。参数上interval在注解里配置不同按钮可以设不同值比如提交按钮 1000ms普通跳转 300ms。5.3 验证切面是否真的织入反编译看字节码切面不生效时最可靠的验证手段是反编译 APK 看目标方法里有没有切面调用。用jadx打开 APK找到目标类看方法体里是否出现了TimeCostAspect或DebounceAspect的调用指令。如果方法体还是原样说明织入没发生回去查aspectjx配置如果出现了切面调用但运行没日志那是切面内部逻辑问题。这个习惯帮我省了很多「猜配置」的时间。5.4 一个我坚持了三年的习惯每次接入新的 AOP 切面我都会先写一个「最小验证 Demo」——一个空 Activity、一个带注解的方法、一个只打日志的切面跑通之后再往真实业务里搬。因为 AspectJ 的配置问题往往在工程复杂后才暴露等业务代码堆上去再排查成本翻倍。另外切面代码我从不写复杂逻辑统计、上报、拦截判断都控制在十行以内复杂的下沉到工具类里调用。切面越薄出问题越好定位。希望帮到你。本文还有配套的精品资源点击获取