织入的是字节码里的连接点,不是你以为的"方法内部某一步"
前言
做 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 最危险的地方。三个验证手段:
- 反编译确认:
jadx打开 APK,搜切面类名,看目标方法里有没有织入代码; - 织入日志:开启
-showWeaveInfo,构建时打印每个切点织入了多少处; - 运行时断言: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#字节码#三方库#编译插桩#性能优化