织入的是字节码里的连接点,不是你以为的"方法内部某一步"
前言
做 Android 的多少都用过 AOP 的教程:埋点、登录校验、防抖点击。照着写完一个 demo 很顺,放进真实工程就开始出问题——切面不生效、构建变慢、升级三方库之后线上悄悄走回了原实现。
真正难的不是写切面,而是判断哪些地方能拦、哪些不能拦。很多人的心智模型是"我能修改任何方法的内部逻辑",于是拿它去做本该改源码的事,最后得到一个依赖反射、依赖私有字段、上游一升级就失效的东西。
我在一个四十模块的工程里维护过十几个切面,其中五个是拦截第三方库的。下面从织入时机讲起,一路讲到"不改一行三方库源码,把它的崩溃修掉",重点始终是那条判断线。
一、AOP 最适合的不是"增强",是"收口"
很多教程把 AOP 定位成"增强",但在大型工程里它最大的价值是另一件事:
当你需要改一个"不属于你、但又到处都在被调用"的行为时,AOP 是成本最低的收口方式。
这个工程里真实存在的诉求:
| 统一 Toast 组件(工程里有 3 套实现) | 全局搜索替换,改 200+ 处,漏一处就样式不一致 | 一个切面,4 个切入点 |
| 老三方库在子线程改 UI,必崩 | fork 源码重新编译(还得跟上游同步) | 拦截该方法,切主线程重试 |
| 第三方 native 库有重复释放内存 Bug,会闪退 | 等上游修 / 自己编 so | 拦截 delete(),加去重保护 |
| 某个品牌要把导出目录换掉 | 改基础库源码,所有品牌都受影响 | 拦截静态方法,按品牌返回不同值 |
| 埋点要覆盖 7 类页面事件 | 每个 Activity 手写,漏一个补一个 | 一个切面批量织入 |
五件事的共同点:修改点分散、不属于单一业务、改源码代价极高。 这就是 AOP 的主场。注意它们全都是"改一个已经存在的调用行为",没有一件是"在方法中间插一段业务逻辑"——这条界线后面会反复出现。
二、五分钟上手:引入、注解与切点
2.1 引入
// 根目录 build.gradle
buildscript {
dependencies {
classpath 'org.aspectj:aspectjtools:1.9.7'
}
}
// 需要织入的模块 build.gradle
plugins {
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(..))")
fun aroundBar(joinPoint: ProceedingJoinPoint) {
val target = joinPoint.target // 被切对象实例(静态方法为 null)
val args = joinPoint.args // 参数数组
val signature = joinPoint.signature // 方法签名
// 放行原方法
joinPoint.proceed()
// 或修改参数后放行
joinPoint.proceed(arrayOf(args[0], "newValue"))
}
target 是"被织入的那个对象",signature 是"哪个方法"——排查"这个埋点是谁触发的"时,只有 signature 能回答。
三、第一个实战:批量埋点切面
这是工程里切面数量最多的一类。诉求是统计所有页面的进入/退出、所有关键按钮点击。
@Aspect
class StatisticsAspect {
// 页面进入
@After("execution(* com.xxx.base.BaseActivity.onCreate(..))")
fun onActivityCreate(joinPoint: JoinPoint) {
val act = joinPoint.target as Activity
Statistics.pageStart(act.javaClass.simpleName)
}
// 页面退出
@After("execution(* com.xxx.base.BaseActivity.onDestroy(..))")
fun onActivityDestroy(joinPoint: JoinPoint) {
val act = joinPoint.target as Activity
Statistics.pageEnd(act.javaClass.simpleName)
}
// Fragment 的可见性埋点(注意 onResume/onPause 不等于对用户可见)
@After("execution(* com.xxx.base.BaseFragment.onResume(..))")
fun onFragmentResume(joinPoint: JoinPoint) {
if ((joinPoint.target as Fragment).userVisibleHint) {
Statistics.pageStart(joinPoint.target.javaClass.simpleName)
}
}
// 统一点击事件埋点
@After("execution(* com.xxx.widget.StatisticsButton.onClick(..))")
fun onClick(joinPoint: JoinPoint) {
val view = joinPoint.target as View
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(..))")
fun around(joinPoint: ProceedingJoinPoint) {
try {
// 切面逻辑
doSomething()
} catch (e: Throwable) {
Log.e(TAG, "aspect failed", e) // 吞掉,绝不让切面逻辑搞崩宿主
}
joinPoint.proceed() // 无论如何放行
}
切面是增强,不是依赖。 切面挂了,业务应该照常跑。
4.3 不要用它藏业务规则
AOP 适合做"横切关注点"(日志、埋点、权限、防护),不适合做"业务分支"(这个按钮在 A 品牌走流程 1,B 品牌走流程 2)。后者一旦藏在切面里,读代码的人永远看不到控制流。
判断标准:新人读业务代码时,会不会因为不知道有这个切面而误判行为? 会,那就别放切面。
五、怎么知道切面到底生效了
织入是编译期行为,失败了不会有运行时报错——这是 AOP 最危险的地方。三个验证手段:
第 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 把这个方法整个接管。
@Aspect
class MapMarkerAspect {
/**
* 修复 setMapScroll 中需要用到主线程更新界面的问题
*/
@Around("execution(* org.osmdroid.views.MapView.setMapScroll(..))")
fun setMapScroll(joinPoint: ProceedingJoinPoint) {
val mapView = joinPoint.target as MapView
// 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(..))")
fun setMapScroll(joinPoint: ProceedingJoinPoint) {
val mapView = joinPoint.target as MapView
try {
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 在它绘制完之后补画:
@Aspect
class MapMarkerAspect {
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
}
private val textSize = SizeUtils.dp2px(10F)
@After("execution(* org.osmdroid.views.overlay.Marker.draw(..))")
fun onMarkerDraw(joinPoint: JoinPoint) {
val marker = joinPoint.target as Marker
if (marker.icon == null || !marker.isEnabled) {
return
}
val title = marker.title
if (title.isNullOrEmpty()) {
return
}
textPaint.textSize = textSize.toFloat()
// 屏幕坐标是私有的,读不到就放弃这一次绘制
val point = ReflectHelper.reflect(marker).field("mPositionPixels").get<Point?>() ?: return
val canvas = joinPoint.args[0] as Canvas
val textWidth = 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(..))")
fun onSetDefaultIcon(joinPoint: ProceedingJoinPoint) {
val marker = joinPoint.target as Marker
if (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 包自检:目标方法还在不在
fun checkAspectTargets(): List<String> {
val problems = 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 字段已变更,切面失效")
}
return problems
}
升级三方库后跑一次自检,比线上出问题再查划算太多。
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(..))")
fun around(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 #字节码 #三方库 #编译插桩 #性能优化
网硕互联帮助中心





评论前必须登录!
注册