☰
Android AspectJ 入门:从省样板代码到不改源码修三方库
2026/10/29 2:05:26 网站建设 项目流程

织入的是字节码里的连接点,不是你以为的"方法内部某一步"

前言

做 Android 的多少都用过 AOP 的教程:埋点、登录校验、防抖点击。照着写完一个 demo 很顺,放进真实工程就开始出问题——切面不生效、构建变慢、升级三方库之后线上悄悄走回了原实现。

真正难的不是写切面,而是判断哪些地方能拦、哪些不能拦。很多人的心智模型是"我能修改任何方法的内部逻辑",于是拿它去做本该改源码的事,最后得到一个依赖反射、依赖私有字段、上游一升级就失效的东西。

我在一个四十模块的工程里维护过十几个切面,其中五个是拦截第三方库的。下面从织入时机讲起,一路讲到"不改一行三方库源码,把它的崩溃修掉",重点始终是那条判断线。

一、AOP 最适合的不是"增强",是"收口"

很多教程把 AOP 定位成"增强",但在大型工程里它最大的价值是另一件事:

当你需要改一个"不属于你、但又到处都在被调用"的行为时,AOP 是成本最低的收口方式。

这个工程里真实存在的诉求:

诉求没有 AOP 的做法有 AOP 的做法
统一 Toast 组件(工程里有 3 套实现)全局搜索替换,改 200+ 处,漏一处就样式不一致一个切面,4 个切入点
老三方库在子线程改 UI,必崩fork 源码重新编译(还得跟上游同步)拦截该方法,切主线程重试
第三方 native 库有重复释放内存 Bug,会闪退等上游修 / 自己编 so拦截delete(),加去重保护
某个品牌要把导出目录换掉改基础库源码,所有品牌都受影响拦截静态方法,按品牌返回不同值
埋点要覆盖 7 类页面事件每个 Activity 手写,漏一个补一个一个切面批量织入

五件事的共同点:修改点分散、不属于单一业务、改源码代价极高。这就是 AOP 的主场。注意它们全都是"改一个已经存在的调用行为",没有一件是"在方法中间插一段业务逻辑"——这条界线后面会反复出现。

二、五分钟上手:引入、注解与切点

2.1 引入

// 根目录 build.gradlebuildscript{dependencies{classpath'org.aspectj:aspectjtools:1.9.7'}}
// 需要织入的模块 build.gradleplugins{id'android-aspectjx'// 或自行用 aspectjtools 接管 javac 之后的织入阶段}

AspectJ 织入发生在.class生成之后、dex 之前。它天然和 Transform、Tinker、R8 抢同一个位置——这一点决定了后面很多"为什么不生效"的问题:如果某个类没有参与这次织入(不在织入范围内、或已被别的插件先行处理),你的切点对它就是空的。

2.2 五个注解,覆盖 95% 的场景

注解时机能否阻止原方法执行典型用途
@Before方法执行前不能(可抛异常)打日志、埋点
@After方法执行后(含异常)不能收尾、补充绘制
@AfterReturning正常返回后不能改返回值结果过滤
@AfterThrowing抛异常后不能异常上报
@Around包住整个方法能(不调proceed()即阻断)替换实现、加 guard

只有@Around能阻止原方法执行,也只有它能改返回值。这一条是所有"替换实现"方案的基石:想接管一个方法,就用@Around并且不调proceed()。

2.3 切点表达式速查

// 某个具体方法(最常用)@Around("execution(* org.osmdroid.views.MapView.setMapScroll(..))")// 静态方法@Around("execution(static * com.xxx.AppDirectory.getExportDirectory(..))")// 某个包下所有类的所有方法@Around("execution(* com.xxx.device..*(..))")// 所有带某注解的方法@Around("execution(@com.xxx.anno.NeedLogin * *(..))")// 所有 Activity 的 onCreate@Around("execution(* android.app.Activity+.onCreate(..))")

语法拆解:

execution( [修饰符] 返回类型 类名.方法名(参数) ) ↑ ↑ ↑ ↑ static? * 全限定名 .. 表示任意参数

..在参数位置表示"任意个任意类型参数",在包名后表示"包含子包"。中括号里的修饰符可省,+表示"该类及其子类"。

2.4 拿到被切的对象与参数

@Around("execution(* com.xxx.Foo.bar(..))")funaroundBar(joinPoint:ProceedingJoinPoint){valtarget=joinPoint.target// 被切对象实例(静态方法为 null)valargs=joinPoint.args// 参数数组valsignature=joinPoint.signature// 方法签名// 放行原方法joinPoint.proceed()// 或修改参数后放行joinPoint.proceed(arrayOf(args[0],"newValue"))}

target是"被织入的那个对象",signature是"哪个方法"——排查"这个埋点是谁触发的"时,只有signature能回答。

三、第一个实战:批量埋点切面

这是工程里切面数量最多的一类。诉求是统计所有页面的进入/退出、所有关键按钮点击。

@AspectclassStatisticsAspect{// 页面进入@After("execution(* com.xxx.base.BaseActivity.onCreate(..))")funonActivityCreate(joinPoint:JoinPoint){valact=joinPoint.targetasActivity Statistics.pageStart(act.javaClass.simpleName)}// 页面退出@After("execution(* com.xxx.base.BaseActivity.onDestroy(..))")funonActivityDestroy(joinPoint:JoinPoint){valact=joinPoint.targetasActivity Statistics.pageEnd(act.javaClass.simpleName)}// Fragment 的可见性埋点(注意 onResume/onPause 不等于对用户可见)@After("execution(* com.xxx.base.BaseFragment.onResume(..))")funonFragmentResume(joinPoint:JoinPoint){if((joinPoint.targetasFragment).userVisibleHint){Statistics.pageStart(joinPoint.target.javaClass.simpleName)}}// 统一点击事件埋点@After("execution(* com.xxx.widget.StatisticsButton.onClick(..))")funonClick(joinPoint:JoinPoint){valview=joinPoint.targetasView Statistics.event(view.getTag(R.id.tag_event_name)as?String?:return)}}

收益是真实的:7 个切入点覆盖全站,新增页面零成本接入。代价也是真实的:一旦埋点口径要变,改的是切面而不是调用点,排查时你很难回答"这个埋点到底是谁触发的"。

所以有一条经验规则:

埋点切面必须打日志,并且日志要包含joinPoint.signature。否则半年后没人知道某个事件是从哪织进去的。

四、AOP 三原则

4.1 切面里不做耗时操作

切面代码会被织入到调用频率可能极高的方法里。在View.draw()或RecyclerView.onBindViewHolder()上织入一个含 IO 的切面,能把帧率打到 20——draw一帧要调几百次,一次几毫秒的 IO 就够毁掉整屏。

4.2 切面逻辑必须可降级

@Around("execution(* com.xxx.Foo.bar(..))")funaround(joinPoint:ProceedingJoinPoint){try{// 切面逻辑doSomething()}catch(e:Throwable){Log.e(TAG,"aspect failed",e)// 吞掉,绝不让切面逻辑搞崩宿主}joinPoint.proceed()// 无论如何放行}

切面是增强,不是依赖。切面挂了,业务应该照常跑。

4.3 不要用它藏业务规则

AOP 适合做"横切关注点"(日志、埋点、权限、防护),不适合做"业务分支"(这个按钮在 A 品牌走流程 1,B 品牌走流程 2)。后者一旦藏在切面里,读代码的人永远看不到控制流。

判断标准:新人读业务代码时,会不会因为不知道有这个切面而误判行为?会,那就别放切面。

五、怎么知道切面到底生效了

织入是编译期行为,失败了不会有运行时报错——这是 AOP 最危险的地方。三个验证手段:

  1. 反编译确认:jadx打开 APK,搜切面类名,看目标方法里有没有织入代码;
  2. 织入日志:开启-showWeaveInfo,构建时打印每个切点织入了多少处;
  3. 运行时断言:Debug 包里加自检页,反射调用被切方法,验证返回值符合预期。

第 2 条最实用:

aspectjx{// 打印织入信息ajcArgs'-showWeaveInfo'}

如果某次构建后织入数量突然变成 0,通常是因为:目标方法被 R8 内联/删除了(切点匹配失败),或者表达式里的类名写错了——AspectJ 对类名错误是静默的,写错一个包名不会报错,只是什么都没织进去。

六、真实案例:三方地图库在子线程改 UI

工程里用的开源地图库(osmdroid)有一个方法,内部直接改了滚动偏移量,但它自己没有切主线程。业务代码在设备数据回调线程(每秒 10 次)里调用它更新地图位置,结果随机崩溃:

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. at android.view.View.requestLayout(...) at org.osmdroid.views.MapView.setMapScroll(MapView.java:xxx)

常规解法有三条,都不太好:

方案问题
调用处全部包一层runOnUiThread调用点几十处,还会漏;线程切换有延迟,位置更新滞后
fork 源码改一改,自己发版要跟上游同步,升级时 merge 痛苦;so 和依赖也得一起维护
反射替换实现类只能替换自己构造的对象,库内部 new 出来的换不掉

最终走了第四条路:用 AspectJ 把这个方法整个接管。

@AspectclassMapMarkerAspect{/** * 修复 setMapScroll 中需要用到主线程更新界面的问题 */@Around("execution(* org.osmdroid.views.MapView.setMapScroll(..))")funsetMapScroll(joinPoint:ProceedingJoinPoint){valmapView=joinPoint.targetasMapView// 1) 用反射直接把值写进私有字段,不触发任何 UI 操作ReflectHelper.reflect(mapView).field("mMapScrollX",joinPoint.args[0])ReflectHelper.reflect(mapView).field("mMapScrollY",joinPoint.args[1])// 2) 只把"请求重绘"这一步切到主线程App.post{mapView.requestLayout()}}}

这个切面的设计有讲究:

  • 不调用joinPoint.proceed()→ 原方法的崩溃代码根本不会执行;
  • 不把整个方法切到主线程→ 高频调用下post一堆消息会淹没主线程,这里只把最后的requestLayout切走,值的写入仍在原线程(纯字段赋值,线程安全由外层保证);
  • 反射写私有字段→ 这是唯一要承担的风险点,上游改字段名就会失效,所以要在字段取值失败时兜底走proceed()。

更稳的写法:

@Around("execution(* org.osmdroid.views.MapView.setMapScroll(..))")funsetMapScroll(joinPoint:ProceedingJoinPoint){valmapView=joinPoint.targetasMapViewtry{ReflectHelper.reflect(mapView).field("mMapScrollX",joinPoint.args[0])ReflectHelper.reflect(mapView).field("mMapScrollY",joinPoint.args[1])App.post{mapView.requestLayout()}}catch(e:Throwable){Log.e(TAG,"setMapScroll aspect failed, fallback",e)joinPoint.proceed()// 字段结构变了就退回原实现,宁可偶发崩溃也不丢功能}}

这条兜底策略适用于所有"拦截三方库"的切面:你拦的是别人随时会改的实现,切面必须能在对方改了之后优雅退化。

七、补充行为与替换实现

7.1 给 Marker 补上它不支持的文字标签

需求:KML 图层加载出来的点要显示点名,而库的Marker只支持图标。用@After在它绘制完之后补画:

@AspectclassMapMarkerAspect{privatevaltextPaint=Paint(Paint.ANTI_ALIAS_FLAG).apply{color=Color.BLUE}privatevaltextSize=SizeUtils.dp2px(10F)@After("execution(* org.osmdroid.views.overlay.Marker.draw(..))")funonMarkerDraw(joinPoint:JoinPoint){valmarker=joinPoint.targetasMarkerif(marker.icon==null||!marker.isEnabled){return}valtitle=marker.titleif(title.isNullOrEmpty()){return}textPaint.textSize=textSize.toFloat()// 屏幕坐标是私有的,读不到就放弃这一次绘制valpoint=ReflectHelper.reflect(marker).field("mPositionPixels").get<Point?>()?:returnvalcanvas=joinPoint.args[0]asCanvasvaltextWidth=textPaint.measureText(title)canvas.drawText(title,point.x.toFloat()-textWidth/2,point.y+textSize.toFloat(),textPaint)}}

三点值得注意:@After拿到的是"已经画完的 Canvas",此时再画就叠在上面,天然实现补充绘制;Paint对象复用,这个方法每秒被调用几百次,onDraw里 new 对象是最经典的性能杀手;读不到私有坐标就return,绘制路径抛异常会中断整个界面绘制,比少画一个标签严重得多。

7.2 替换掉库的默认图标逻辑

库的Marker.setDefaultIcon()会给每个点套默认图标。要的是"用自定义透明图占位 + 固定锚点",于是直接接管,不让它执行:

@Around("execution(* org.osmdroid.views.overlay.Marker.setDefaultIcon(..))")funonSetDefaultIcon(joinPoint:ProceedingJoinPoint){valmarker=joinPoint.targetasMarkerif(markerDefaultIcon==null){markerDefaultIcon=ActivityUtils.getTopActivity().resources.getDrawable(R.mipmap.icon_marker_default)}marker.icon=markerDefaultIcon marker.setAnchor(Marker.ANCHOR_CENTER,Marker.ANCHOR_BOTTOM)// 注意:没有 proceed() —— 原实现被完全替换}

八、拦截三方库的四个风险

8.1 版本升级后切点失效(静默)

这是最大的风险。上游改了方法签名、改了私有字段名,你的切点匹配不到,构建照样成功,运行时悄无声息地走回原实现。

应对:把"织入数量"纳入 CI;为关键切面写自检用例,反射验证目标方法与字段还在:

// Debug 包自检:目标方法还在不在funcheckAspectTargets():List<String>{valproblems=mutableListOf<String>()runCatching{MapView::class.java.getDeclaredMethod("setMapScroll",Int::class.java,Int::class.java)}.onFailure{problems.add("MapView.setMapScroll 签名已变更,切面失效")}runCatching{MapView::class.java.getDeclaredField("mMapScrollX")}.onFailure{problems.add("MapView.mMapScrollX 字段已变更,切面失效")}returnproblems}

升级三方库后跑一次自检,比线上出问题再查划算太多。

8.2 R8/ProGuard 把被切的方法内联或删除

AspectJ 织入在 R8之前,所以切点是能匹配到的;但 R8 之后可能把目标方法内联进调用方,导致织入代码位置偏移(通常不影响功能,但堆栈会变得诡异)。更危险的是R8 删掉了你反射读的那个私有字段。

应对是给反射访问的字段加 keep 规则:

-keepclassmembers class org.osmdroid.views.MapView { int mMapScrollX; int mMapScrollY; } -keepclassmembers class org.osmdroid.views.overlay.Marker { android.graphics.Point mPositionPixels; }

8.3 切面自己抛异常

绘制路径、Binder 回调路径上的异常会引发连锁崩溃。所有切面逻辑都应遵循:

@Around("execution(* com.thirdparty.Foo.bar(..))")funaround(joinPoint:ProceedingJoinPoint){try{replaceImplementation(joinPoint)}catch(e:Throwable){Log.e(TAG,"aspect failed",e)joinPoint.proceed()// 降级:走回原实现}}

8.4 多人协作时"改了行为但没人知道"

拦截三方库是隐式的行为修改。建议:切面文件集中放一个aspect/目录,命名统一XxxAspect;每个切面顶部写注释块,说明"拦截了什么 / 为什么 / 上游修好后能否移除";PR 模板里加一条"是否改了三方库或切面行为"。

九、什么时候不该用 AOP 改三方库

情况应该怎么做
上游已经修了、或者你有源码直接改源码,别留切面
要改的是方法的内部逻辑中间某一步AOP 只能整体替换,改不了中间步骤,考虑 fork
目标方法是final/private/ 内联的 Kotlin 顶层函数织入可能失败,改用别的方案
改动涉及大量状态(需要访问库内部多个私有成员)切面会变成反射地狱,可维护性极差

一句话:AOP 适合替换"整个方法",不适合"修改方法的一半"。

十、想通"能拦什么",比会写切面更重要

回到那条判断线。AspectJ 能改的只有字节码里的连接点:方法的执行体(execution)、方法的调用点(call)、字段读写(field)、异常处理块(handler)、构造与静态初始化。它改不了"方法体中间的第 7 行",也改不了没有被织入的类——没被织入,就等于不存在。

由此可以自己推出全部结论:

  • 能拦一个完整方法(execution+@Around不调proceed()),因为执行体是连接点;
  • 不能改方法的一半,因为没有"第 7 行"这个连接点,只能整体接管后自己重写;
  • private/final/ 内联函数容易失效,因为连接点在这个层次上可能被编译器抹掉(内联把方法体搬走了,就没有"方法"可拦);
  • @After能补充绘制,因为"方法正常返回"也是连接点,此时 Canvas 状态还在;
  • 拦截三方库 = 依赖对方实现细节,所以必须有兜底与自检——你拦的东西随时会被上游改名。

这也是为什么拦截三方库的切面必须可降级:它不是架构的一部分,是一层"临时补丁",上游修好就该删掉。

结论

  • AOP 在大型工程里的核心价值是收口:改一个不属于你、但到处被调用的行为。
  • 五个注解里只有@Around能阻断执行、能改返回值:替换实现靠的就是"不调proceed()"。
  • 三条原则:不做耗时操作、必须可降级、不藏业务规则。
  • 织入失败是静默的:构建期必须打印-showWeaveInfo,否则切点写错你也不会知道。
  • 拦截三方库的标准姿势:@Around+ 不调proceed()+try/catch兜底降级。
  • 需要"补充"而非"替换"时用@After,并复用Paint这类高频对象,避免在绘制路径里 new。
  • 反射私有字段必须写 keep 规则,否则 Release 包直接失效。
  • 切点失效要能被发现:织入计数进 CI,自检用例反射验证方法与字段存在。
  • AOP 只适合替换整个方法,不适合修改方法内部的一半;上游有源码就直接改源码。

这十几个切面里,凡是活得久的,都是"拦截一个完整方法 + 有兜底 + 有自检"的;凡是三个月后被删掉的,都是想拿它改方法内部逻辑的。如果你也在用 AspectJ 修三方库,欢迎在评论区说说你踩过的"切点静默失效",那类问题最值得互相提醒。

关键词标签:#Android#AOP#AspectJ#字节码#三方库#编译插桩#性能优化

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询