简介这份资源是面向Android开发者的AOP实战Demo基于Kotlin语言结合AspectJ框架实现面向切面编程适合已掌握Kotlin基础、希望提升代码可维护性与模块化能力的中级开发者。Demo以FastClickAop为核心示例通过切点表达式与Around等通知拦截View的onClick方法解决快速连续点击导致事件重复触发的防抖动问题同时展示了引入AspectJ依赖、创建切面类、定义通知、配置编译期织入等完整流程。压缩包共64个文件约953KB包含8个kt源码文件、11个xml布局与配置、4个gradle构建脚本及jar、properties等依赖与配置文件目录分为api、app等模块结构清晰便于对照学习。目前已有494人学习下载。读者可从中掌握AOP在Android中的落地方式理解切面与通知的编写思路并将该模式迁移到日志记录、权限控制、性能统计等场景提升代码组织效率。1. 从一次「日志满天飞」的翻车说起AspectJ 在 Kotlin 里到底能干什么去年帮一个团队看代码一个业务方法里塞了 8 行日志、3 行权限判断、2 行耗时统计真正的业务逻辑被挤到最底下。问为什么不用 AOP回答是「Kotlin 里 AspectJ 配起来太玄学注解处理器和 kapt 老打架」。这个场景太典型了——AOP 不是新概念但在 Android Kotlin 这套组合里落地门槛确实比 Java 时代高了一截。这份android 实现AOP 使用Aspectj Kotlin版Demo.zip就是冲着这个痛点来的。它演示的是用 AspectJ 在 Android 工程里做切面编程语言是 Kotlin覆盖注解定义、切点匹配、编译期织入、运行时验证这条完整链路。适合两类人一类是知道 AOP 概念但没在 Kotlin 工程里真正跑通过切面的开发者另一类是跑通过但被 kapt、transform、混淆搞得反复翻车的熟手。下面按「原理选型 → 环境搭建 → 切面编写 → 排错 → 进阶」的顺序拆开讲每一步都落到能抄的代码和参数上。2. 为什么是 AspectJ 而不是动态代理Kotlin 场景下的选型逻辑2.1 三种 AOP 方案在 Android 上的真实差异Android 上做 AOP常见路线有三条动态代理JDK Proxy / CGLIB、字节码插桩ASM / Javassist、AspectJ。很多人第一反应是动态代理但它在 Android 上有硬伤——JDK Proxy 只能代理接口CGLIB 依赖字节码生成且对 final 类无效而 Kotlin 默认类就是 final 的。你写一个class UserRepository不加open根本没法被 CGLIB 代理这是 Kotlin 语言特性直接带来的限制。ASM 和 Javassist 属于手写插桩灵活度最高但你要自己处理 ClassVisitor、MethodVisitor写一个「方法进入前打日志」的切面可能要上百行维护成本极高。AspectJ 的价值在于它提供了一套声明式的切点表达式语言Before、After、Around直接标注织入交给编译器或构建插件完成。代价是引入额外的编译流程这也是后面所有坑的根源。方案代理范围Kotlin final 类编写成本织入时机JDK 动态代理仅接口不适用低运行时CGLIB类非 final不支持中运行时ASM/Javassist任意支持高编译期/构建期AspectJ任意支持低编译期/构建期选 AspectJ 的核心理由就一条它能在不改动业务代码、不要求类 open 的前提下把横切逻辑织进去。日志、埋点、权限、耗时统计、防抖这些场景全都受益。2.2 编译期织入和加载期织入的区别AspectJ 有两种织入方式。编译期织入CTWCompile-Time Weaving是在.kt/.java编译成.class的阶段由 ajc 编译器把切面代码合并进目标类。加载期织入LTWLoad-Time Weaving是类加载时通过 javaagent 动态修改字节码。Android 上几乎只用 CTW因为 LTW 依赖 JVM 的 Instrumentation APIART 运行时并不完整支持。CTW 在 Android 工程里的实现路径是Gradle 插件在compileDebugKotlin之后、dex之前插入一个 transform 任务用 ajc 重新处理所有.class文件。这就是为什么 AspectJ 的 Gradle 配置必须挂在正确的 task 顺序上——挂错了切面代码根本没机会进字节码运行时自然什么都切不到。理解这一点后面 80% 的「切面不生效」问题都能自己定位。2.3 这份 Demo 的工程结构预期拿到压缩包后合理的工程结构应该包含这几块根目录的build.gradle配置 AspectJ 插件 classpath、app 模块的build.gradle应用插件、声明依赖、一个aspect包放切面类、一个annotation包放自定义注解、以及若干被切的业务类。如果解压后发现切面和业务代码混在一起建议先按包名拆开切面类统一放aspect包方便后续用within()或execution()精确匹配也避免切面切到自己造成无限递归。3. 环境搭建Gradle 配置、依赖版本与 kapt 的配合3.1 根工程与模块的 Gradle 配置AspectJ 在 Android 上主流用沪江开源的gradle-android-aspectj-plugin或类似的 transform 插件。配置分两层根build.gradle声明插件依赖app 模块应用插件并加依赖。根目录build.gradlebuildscript { repositories { google() mavenCentral() } dependencies { // AspectJ 的 Gradle 织入插件负责在编译后插入 ajc 处理 classpath com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10 } }app 模块build.gradleapply plugin: com.android.application apply plugin: kotlin-android apply plugin: kotlin-kapt apply plugin: android-aspectjx // 关键应用织入插件 android { compileSdkVersion 33 defaultConfig { minSdkVersion 21 targetSdkVersion 33 } // 如果开了混淆需要保留切面相关类见 5.2 } aspectjx { // 只处理自己的包避免扫描第三方 aar 拖慢构建 include com.example.aopdemo // 排除掉不需要织入的库减少编译时间 exclude androidx, kotlin, org.jetbrains } dependencies { implementation org.aspectj:aspectjrt:1.9.7 // 运行时库必须 implementation org.jetbrains.kotlin:kotlin-stdlib:1.8.0 }aspectjx块里的include和exclude是最容易被忽略的参数。不写include插件会扫描所有 class构建时间可能从 30 秒涨到 3 分钟不写exclude第三方库里的类也可能被织入引发莫名其妙的崩溃。我一般先只 include 自己的包确认切面生效后再按需放开。3.2 依赖版本匹配与 kapt 冲突处理AspectJ 运行时库aspectjrt的版本要和插件兼容。1.9.x 系列是当前稳定线1.9.7 对 Kotlin 1.8 支持较好。如果工程里同时用了 kapt比如 Room、Moshi要注意 kapt 生成的代码默认在build/tmp/kapt3下AspectJ 的 transform 如果跑在 kapt 之前这些生成类不会被织入。常见做法是把aspectjx插件的处理顺序放在 kapt 之后或者干脆不对 kapt 生成的类做切面——绝大多数场景也不需要。另一个高频冲突是 Kotlin 的all-open或parcelize插件。它们和 AspectJ 都会修改字节码同时开启时偶尔出现ClassFormatError。如果遇到先关掉all-open验证是不是它引起的再决定是否保留。血泪经验是切面类本身不要加Parcelize也不要用all-open去 open 切面类切面只需要被 ajc 识别不需要被代理。3.3 验证织入是否真的发生配置完先别急着写业务切面用最小切面验证链路。写一个切所有 Activity 的onCreate的切面跑起来看日志有没有输出。如果没输出问题一定在构建配置不在切面代码。验证方法有两个一是看build/intermediates下 transform 后的 class 文件用javap -c反编译看方法体里有没有插入切面调用二是直接跑 App 看 Logcat。前者更可靠后者更快。建议两个都做一遍确认织入确实发生再往下写复杂切面。4. 切面编写注解定义、切点表达式与 Kotlin 语法适配4.1 自定义注解与切点绑定AOP 最实用的模式是「自定义注解 切面」。先定义一个注解标在需要拦截的方法上切面通过annotation()匹配。Kotlin 定义注解要注意Retention和Target。package com.example.aopdemo.annotation // 运行时保留切面在编译期读取但保留策略用 RUNTIME 更稳妥 Retention(AnnotationRetention.RUNTIME) // 只允许标在函数上 Target(AnnotationTarget.FUNCTION) annotation class LogTime( val tag: String , // 日志标签默认空则用方法名 val threshold: Long 0L // 耗时阈值超过才打印单位毫秒 )threshold这个参数是实战里加的——不是所有方法都值得打日志只打超过阈值的慢方法日志量能降一个数量级。tag留空时用方法签名兜底避免每个注解都要手写标签。4.2 用 Around 实现耗时统计切面Around是最灵活的切点类型能拿到ProceedingJoinPoint控制目标方法的执行。下面这个切面统计被LogTime标注的方法耗时。package com.example.aopdemo.aspect import android.util.Log import com.example.aopdemo.annotation.LogTime 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 LogTimeAspect { // 切点所有被 LogTime 标注的方法 Pointcut(annotation(com.example.aopdemo.annotation.LogTime)) fun logTimePointcut() {} Around(logTimePointcut() annotation(logTime)) fun aroundLogTime(joinPoint: ProceedingJoinPoint, logTime: LogTime): Any? { val methodName joinPoint.signature.name val tag logTime.tag.ifEmpty { methodName } val start System.currentTimeMillis() return try { // 执行原方法返回值必须透传否则调用方拿到 null joinPoint.proceed() } finally { val cost System.currentTimeMillis() - start if (cost logTime.threshold) { Log.d(AOP-Time, $tag cost${cost}ms) } } } }几个关键点。第一Around的切点表达式里 annotation(logTime)把注解实例绑定到方法参数这样才能读到tag和threshold只写annotation(全类名)是拿不到实例的。第二joinPoint.proceed()的返回值必须return这是新手最常见的翻车点——忘了 return被切方法返回 null上层直接 NPE。第三finally里统计耗时保证方法抛异常时也能记录。4.3 用 Before 做权限校验与参数打印Before适合做前置检查比如权限判断、参数合法性校验。它拿不到返回值但能拿到方法参数。Aspect class PermissionAspect { Before(execution(* com.example.aopdemo.business.*.*(..)) annotation(requirePermission)) fun checkPermission(joinPoint: JoinPoint, requirePermission: RequirePermission) { val permission requirePermission.value if (!PermissionChecker.has(permission)) { // 抛异常中断原方法执行 throw SecurityException(missing permission: $permission) } } }execution(* com.example.aopdemo.business.*.*(..))的含义逐段拆第一个*是返回类型任意com.example.aopdemo.business.*是包下任意类第二个*是任意方法(..)是任意参数。这个表达式配合annotation双重限定既缩小了匹配范围又保证只有标注的方法才走校验。注意Before里抛异常会直接中断目标方法这是它和Around的区别——Around可以选择不调用proceed()Before只能靠抛异常。4.4 Kotlin 空安全与切面返回值的坑Kotlin 的空安全在切面里会制造一些隐蔽问题。如果被切方法的返回类型是非空String而切面里joinPoint.proceed()返回Any?编译器不会报错但运行时如果 proceed 返回 nullKotlin 的调用方会抛NullPointerException。解决办法是在切面里显式处理val result joinPoint.proceed() // 如果原方法声明非空返回这里要保证不返回 null return result ?: throw IllegalStateException(${joinPoint.signature} returned null)另一个坑是挂起函数。Kotlin 的suspend函数编译后多一个Continuation参数AspectJ 的切点表达式默认匹配不到。要切挂起函数得用execution(* com.example.aopdemo.business.*.*(.., kotlin.coroutines.Continuation))这种带 Continuation 的签名或者干脆把切面逻辑放在非挂起的包装方法上。这一点在 Demo 里如果没覆盖自己扩展时要特别注意。5. 避坑与排查切面不生效、构建变慢、混淆崩溃的定位方法5.1 切面完全不生效从 transform 任务顺序查起现象切面代码写好了注解也标了跑起来日志一条没有。原因通常是 transform 任务没跑或者跑在了错误的阶段。解决步骤先在 Gradle 控制台执行./gradlew :app:assembleDebug --info搜索aspectjx或transform关键字看有没有对应的 task 执行记录。如果没有说明插件没应用成功检查apply plugin是否写在 app 模块而不是根模块。如果有 task 但切面仍不生效检查aspectjx的include是否漏了切面所在的包——切面类本身也要在 include 范围内否则 ajc 看不到切面定义。5.2 混淆后崩溃keep 规则漏了切面类现象debug 正常release 包一跑就崩堆栈指向切面里的NoSuchMethodError或ClassNotFoundException。原因是 R8/ProGuard 把切面类或注解类裁掉了。解决在proguard-rules.pro里加保留规则。# 保留所有切面类 -keep class com.example.aopdemo.aspect.** { *; } # 保留自定义注解 -keep interface com.example.aopdemo.annotation.** # 保留被注解标注的成员防止注解信息被剥离 -keepclassmembers class * { com.example.aopdemo.annotation.* methods; } # AspectJ 运行时 -keep class org.aspectj.** { *; }-keepclassmembers那条尤其重要它保证被注解标注的方法不被重命名否则切点表达式里的方法名匹配会失效。5.3 构建时间暴涨include 范围失控现象加了 AspectJ 后每次编译从 20 秒变成 2 分钟以上。原因是aspectjx默认扫描所有 class包括 androidx、kotlin stdlib 这些大库。解决严格写include只包含自己的业务包exclude里加上androidx、kotlin、org.jetbrains、com.google。如果工程有多个模块只在需要切面的模块应用插件不要全工程铺开。实测把 include 从全量收窄到单个包构建时间能回到接近原来的水平。5.4 切面递归调用切面切到了自己现象日志里同一个方法被打印了成百上千次或者直接 StackOverflowError。原因是切点表达式范围太大把切面类自己的方法也切了切面调用切面形成递归。解决在切点表达式里排除切面包比如execution(* com.example.aopdemo.business.*.*(..)) !within(com.example.aopdemo.aspect.*)。!within是排除指定类型的关键字写切点时养成加排除的习惯能省掉很多调试时间。5.5 注解参数读不到绑定语法写错现象切面能执行但logTime.tag永远是默认值。原因是切点表达式里只写了annotation(全类名)没有把注解实例绑定到方法参数。正确写法是annotation(logTime)其中logTime是切面方法的参数名类型为注解类型。这个绑定语法 AspectJ 文档里有但很容易被忽略因为不绑定也能切只是拿不到参数值属于「静默失败」排查起来最费时间。6. 进阶技巧切点表达式优化与多切面执行顺序控制切点表达式写得好不好直接决定切面的可维护性和运行开销。execution是最常用的指示符但within、this、target、args组合起来能表达更精确的语义。比如只想切实现了某个接口的类用this(com.example.aopdemo.business.Repository)只想切参数是特定类型的方法用args(java.lang.String)。表达式越精确ajc 在织入阶段需要处理的类越少构建越快运行时也不会误伤。多切面共存时执行顺序由Order或DeclarePrecedence控制。默认顺序不确定这在「日志切面」和「权限切面」同时存在时是隐患——权限校验应该在日志之前还是之后如果权限切面抛异常日志切面还能不能记录我一般用DeclarePrecedence显式声明Aspect DeclarePrecedence(PermissionAspect, LogTimeAspect) class LogTimeAspect { // ... }这行的意思是PermissionAspect优先于LogTimeAspect权限先校验通过了才进日志切面。顺序写反了未授权的方法也会被记录日志属于信息泄露。验证切面是否按预期织入除了看日志还可以用javap反编译。以耗时切面为例织入后目标方法的字节码里会多出对LogTimeAspect.aspectOf().aroundLogTime(...)的调用。执行javap -c -p app/build/intermediates/javac/debug/classes/com/example/aopdemo/business/UserRepository.class在方法体里搜aspectOf搜到就说明织入成功。这个方法比跑 App 更直接尤其在 CI 环境里没法跑 UI 时特别有用。还有一个容易被忽略的点切面里的Log.d在 release 包里应该关掉。常见做法是给切面加一个开关从BuildConfig.DEBUG读取或者用注解的threshold设一个极大值来变相关闭。我习惯在切面里统一走一个AopLogger内部判断BuildConfig.DEBUG这样 release 包不会因为日志拖慢性能也不用改切面代码。从那以后我每次配 AspectJ都强制先跑一遍最小切面验证织入再写业务切面最后加混淆规则跑一次 release 包。这三步走完基本不会再出现「debug 好好的release 就崩」的情况。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?