AspectJ 织入的是 Java 字节码的调用点,不是方法的实现体。
有些麻烦,你第一反应绝不会是「去改别人的源码」。
工程里的 Toast 有三套实现,样式各不相同,想在全局收口成一套;老三方地图库在子线程改 UI,线上随机崩;第三方 native 库有重复释放内存的缺陷,每天都要闪退几次。这三件事的共同点是:要改的代码都不在你手里------第一件散落在两百多处调用点,后两件的源码根本不在自己的仓库里。
常规做法无非三种:全局搜索替换、fork 源码自己维护、反射替换实例。三条路我们都试过,代价一次比一次高。这篇讲的是第四条路:用 AspectJ 把目标方法的调用点接管过来,一行源码都不动。
但它不是万能钥匙。三类拦截的难度是递进的------切自己代码里的注解最简单,切三方库的公开方法要处理兜底,切 native 方法声明则要先绕过一个常见误解。能不能拦、拦到什么程度,其实只取决于一句判断:AspectJ 织入的是「Java 字节码的调用点」,不是「方法的实现体」。 把这句话想透,剩下的都是推演。
一、先立一把尺子:织入的是调用点,不是实现体
先看 AspectJ 在 Android 构建链里的位置:它拿到的是 .class,在 dex 之前做改写。
这条链解释了两件事。第一,AspectJ 天然和 Transform、Tinker、R8 抢同一个位置 ------它们都是对 .class 做后处理,插队顺序决定了谁先看到什么。第二,织入发生在 R8 之前,所以 R8 的裁剪和内联是在切面生效之后发生的,这留下了一个后面要讲的坑。
再看它的工作原理。一段业务代码 caller.bar() 编译出来是 invokevirtual Foo.bar 这样一条字节码指令。ajc 做的事,是在这条指令的前后 插入切面逻辑,而不是去改写 Foo.bar 方法体内部的代码。
这张图值得反复看。它能推出三条结论,也是全文所有案例的判据:
- 凡是有 Java 字节码调用点的地方,都能拦。 哪怕被调的是 native 方法、是三方库、是系统 API,只要调用动作发生在 Java 字节码里,切面就有机会插手。
- 方法体内部做的事,切面看不见。
Foo.bar()里自己又调了baz(),切面无法只替换「中间的那一步」------它只能整体接管bar()。 - 发生在 Java 字节码之外的调用,切面完全够不到。 比如 C 层代码直接调用另一个 C 函数,中间根本没有经过 Java 调用点。
于是三类拦截的难度曲线可以画成一张路线图:
三级共享同一把尺子,差别只在「被切的东西属于谁」。把三类的边界摊开对比:
| 切什么 | 怎么写 | 拦不到什么 |
|---|---|---|
| 自己代码里带某注解的方法 | execution(@com.xxx.anno.NeedLogin * *(..)) |
注解加在类级别而非方法级别时;不含调用点的方法 |
| 三方库的公开方法 | execution(* org.osmdroid.views.MapView.setMapScroll(..)) |
库内部私有方法、库自己 C 层之间的互相调用 |
| native 方法声明 | execution(* org.gdal.ogr.FeatureNative.delete(..)) |
native 层(C/C++)自己发起的调用;只有「Java → native 声明」这一跳可拦 |
最后一行是第三级的关键,后面会展开。
二、五分钟上手:Android 上的 AspectJ
引入方式
groovy
// 根目录 build.gradle
buildscript {
dependencies {
classpath 'org.aspectj:aspectjtools:1.9.7'
}
}
groovy
// 需要织入的模块 build.gradle
plugins {
id 'android-aspectjx' // 或自行用 aspectjtools 接管 javac 之后的织入阶段
}
五个注解,覆盖 95% 的场景
| 注解 | 时机 | 能否阻止原方法执行 | 典型用途 |
|---|---|---|---|
@Before |
方法执行前 | 不能(可抛异常) | 打日志、埋点 |
@After |
方法执行后(含异常) | 不能 | 收尾、补充绘制 |
@AfterReturning |
正常返回后 | 不能(可改返回值?不能) | 结果过滤 |
@AfterThrowing |
抛异常后 | 不能 | 异常上报 |
@Around |
包住整个方法 | 能 (不调 proceed() 即阻断) |
替换实现、加 guard |
最关键的一点:只有 @Around 能阻止原方法执行,也只有它能改返回值。 后面三类拦截里,凡是「替换行为」的场景用的全是 @Around,只有「补充行为」才用 @After。
切点表达式速查
kotlin
// 某个具体方法(最常用)
@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(..))")
语法拆解:
scss
execution( [修饰符] 返回类型 类名.方法名(参数) )
↑ ↑ ↑ ↑
static? * 全限定名 .. 表示任意参数
.. 在参数位置表示「任意个任意类型参数」,在包名后表示「包含子包」。+ 表示「该类及其子类」。这两处是写切点时最容易记混的细节:com.xxx.device..* 里的 .. 在后,表示含子包;setMapScroll(..) 里的 .. 在括号内,表示任意参数。
拿到被切的对象与参数
kotlin
@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"))
}
三个字段里,signature 的分量常被低估。埋点、防护、日志类切面都应该把它打印出来------切面把「谁调用、从哪织入」这件事从代码里抹掉了,signature 是唯一能把它还原回现场的东西。
三、第一级:切自己代码里的注解
这一级的对象是自家代码。它不改任何不属于你的东西,风险最低,也最适合拿来练手------但如果只把它理解成「省样板代码」,就会错过它在大型工程里真正的定位。
定位:AOP 最适合的不是「增强」,是「收口」
很多 AOP 教程会教你用它做埋点、做登录校验、做防抖点击。这些都对,但在大型工程里它最大的价值其实是另一件事:
当你需要改一个「不属于你、但又到处都在被调用」的行为时,AOP 是成本最低的收口方式。
举几个这个工程里真实存在的诉求:
| 诉求 | 没有 AOP 的做法 | 有 AOP 的做法 |
|---|---|---|
| 统一 Toast 组件(工程里有 3 套 Toast 实现) | 全局搜索替换,改 200+ 处,漏一处就样式不一致 | 一个切面,4 个切入点 |
| 老三方库在子线程改 UI,必崩 | fork 源码重新编译(还得跟上游同步) | 拦截该方法,切主线程重试 |
| 第三方 native 库有重复释放内存 Bug,会闪退 | 等上游修 / 自己编 so | 拦截 delete(),加去重保护 |
| 某个品牌要把导出目录换掉 | 改基础库源码,所有品牌都受影响 | 拦截静态方法,按品牌返回不同值 |
| 埋点要覆盖 7 类页面事件 | 每个 Activity 手写,漏一个补一个 | 一个切面批量织入 |
这五件事的共同点是:修改点分散、不属于单一业务、且改源码代价极高。 这就是 AOP 的主场。注意第一件和第五件都是「改自己的代码」------它们的成本高,不是因为代码够不着,而是因为调用点太分散。
实战:批量埋点切面
这是这个工程里切面数量最多的一类。诉求是:统计所有页面的进入/退出、所有关键按钮点击。
kotlin
@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。 否则半年后没人知道某个事件是从哪织进去的。
这里有个容易忽略的边界:BaseActivity.onCreate 被 @After 织入后,任何继承 BaseActivity 的子类只要没有重写 onCreate,走的也是同一段字节码 ,切面天然覆盖;但一旦子类重写了 onCreate 且没有 super.onCreate(),切点就落在子类方法上,父类那次的织入不会被触发。这类「继承链 + 重写」的差异,是埋点漏报最常见的来源。
AOP 三原则(决定你会不会后悔)
原则 1:切面里不做耗时操作
切面代码会被织入到调用频率可能极高 的方法里。在 View.draw() 或 RecyclerView.onBindViewHolder() 上织入一个含 IO 的切面,能把帧率打到 20。
原则 2:切面逻辑必须可降级
kotlin
@Around("execution(* com.xxx.Foo.bar(..))")
fun around(joinPoint: ProceedingJoinPoint) {
try {
// 切面逻辑
doSomething()
} catch (e: Throwable) {
Log.e(TAG, "aspect failed", e) // 吞掉,绝不让切面逻辑搞崩宿主
}
joinPoint.proceed() // 无论如何放行
}
切面是增强,不是依赖。 切面挂了,业务应该照常跑。
原则 3:不要用它藏业务规则
AOP 很适合做「横切关注点」(日志、埋点、权限、防护),很不适合做「业务分支」(这个按钮在 A 品牌走流程 1,B 品牌走流程 2)。后者一旦藏在切面里,读代码的人永远看不到控制流。
判断标准:新人读业务代码时,会不会因为不知道有这个切面而误判行为? 会,那就别放切面。
怎么知道切面到底生效了
织入是编译期行为,失败了不会有运行时报错。三个验证手段:
- 反编译确认 :
jadx打开 APK,搜你的切面类名,看目标方法里有没有织入代码; - 织入日志 :开启 AspectJ 的
-showWeaveInfo,构建时会打印每个切点织入了多少处; - 运行时断言:Debug 包里加一个自检页面,反射调用被切方法,验证返回值符合预期。
第 2 条最实用,Gradle 里加:
groovy
aspectjx {
// 打印织入信息
ajcArgs '-showWeaveInfo'
}
如果某次构建后织入数量突然变成 0,通常是因为:目标方法被 R8 内联/删除了(切点匹配失败),或者表达式里的类名写错了(AspectJ 对类名错误是静默的)。
「静默」是这个工具最需要警惕的性格:切点写错、类名拼错、目标被裁剪,三种情况的表现完全一样------构建成功,运行正常,切面什么也没做。它不会有编译错误提示你。所以第一级虽然简单,也必须从第一天起就把织入计数纳入观察。
四、第二级:切三方库的公开方法
这一级的对象换成了别人的 Java 实现。我们改不了它的源码,也不打算 fork。
场景一:第三方地图库在子线程改 UI
工程里用的开源地图库(osmdroid)有一个方法,内部直接改了滚动偏移量,但它自己没有切主线程。我们的业务代码在设备数据回调线程(每秒 10 次)里调用它更新地图位置,结果就是随机崩溃:
bash
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 把这个方法整个接管。
kotlin
@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()。
更稳的写法:
kotlin
@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() // 字段结构变了就退回原实现,宁可偶发崩溃也不丢功能
}
}
这条兜底策略适用于所有「拦截三方库」的切面:你拦的是别人随时会改的实现,切面必须能在对方改了之后优雅退化。
这里的失败路径值得单独说清,因为它决定了「宁可偶发崩溃」还是「宁可功能丢失」:
- 兜底走
proceed()→ 上游改字段后就退回原实现,最坏情况是偶尔崩一次,但地图位置一定在更新; - 兜底选择「静默 return」 → 不会崩,但地图再也不刷新,而且没有任何报错------功能静默丢失比偶发崩溃更难发现。
对位置更新这种「有它更好、没它也不致命」的能力,选偶发崩溃是可接受的取舍;但如果换成支付、下发指令这类不能静默失败的能力,就必须选「宁可不刷新也不能错」。
整条拦截链路可以用一张图看清:
场景二:给 Marker 补上它不支持的文字标签
需求:KML 图层加载出来的点,要显示点名。库的 Marker 只支持图标,不支持文字。
这里的诉求是「补充」而不是「替换」,所以用 @After 在它绘制完之后补画即可:
kotlin
@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,不抛异常------绘制路径抛异常会导致整个界面绘制中断,比少画一个标签严重得多。
这三点的排序本身就是一次取舍:在绘制路径上,「少做一点」永远优于「抛出去」。 因为 draw 是渲染管线里的单点,它抛异常不是让一个标签消失,而是让整帧作废。
场景三:替换掉库的默认图标逻辑
库的 Marker.setDefaultIcon() 会给每个点套一个默认图标。我们要的是「用自定义透明图占位 + 固定锚点」,于是直接接管,不让它执行:
kotlin
@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() ------ 原实现被完全替换
}
三个场景放在一起看,第二级的模式非常清楚:要「替换整个方法」用 @Around 且不调 proceed();要「在原行为之上补一刀」用 @After。 区别只在 proceed() 调不调、以及在哪调。
五、第三级:切 native 方法声明
到了这一级,对象看起来最不可能:一个底层的 C 实现。但它反而是最能体现「调用点 vs 实现体」这条尺子的地方。
一个「每天崩几次、堆栈全是问号」的问题
工程里用 GDAL 读写矢量地图文件(SHP/KML 等),集成后线上出现随机闪退,特征非常典型:
- 崩溃堆栈里没有一行 Java 代码 ,只有
libc.so里的abort; - 崩溃日志形如
Fatal signal 11 (SIGSEGV), code 1, fault addr 0x...; - 必现路径:加载背景矢量图层、快速切换、退出页面;
- 而且崩溃点每次都不一样------这是内存被破坏(heap corruption)的典型特征,不是普通空指针。
排查到根因的结论是这样的(这段注释来自真实代码):
- 数据集里对要素的读写存在多线程安全问题;
- 该库由 SWIG 自动生成样板代码,包含一个循环的回收线程,而
delete方法是public的,外部可以调用,导致线程安全问题,存在内存重复释放或访问已释放内存;- 调试发现存在不同的对象但
swigCPtr值相同 的情况,导致JNI.delete(swigCPtr)重复释放同一块内存。
翻译成人话:两个不同的 Java 对象,持有同一个 native 指针;其中一个被回收后,另一个再 delete 就是 double free。
这里有个值得记住的诊断经验:崩溃点每次不一样,通常不是「某个函数有 bug」,而是「内存已经被别人踩坏了」。 真正的破坏发生在更早的某一次写入,崩溃只是受害者在错误的时间捡到了垃圾地址。所以看到「堆栈全是问号、地址每次不同」,第一反应应该是找哪个操作在破坏内存,而不是盯着崩溃时正在执行的那个函数。
为什么不能改源码
理论上正确的修法是改三方库:让 delete 变私有、加引用计数、修 SWIG 模板。但现实是:
- 这是带 JNI 的库,改一处要重新交叉编译 so(这个项目里编译链本身就很重,光是 cmake 版本都有坑);
- 上游短期内不会合入这个修复;
- 即使自己编译,后续上游升级又要重新打补丁。
于是我们做了个看起来不可能的事:在 Java 层把这个 native 方法拦下来,自己加一道去重闸门。
切面实现
kotlin
/**
* 修复使用矢量库加载背景地图导致闪退的问题
*
* 问题:
* 1. 数据集中对要素的读写存在多线程安全问题(基础库已加锁)
* 2. SWIG 生成的代码里 delete 是 public 的,外部可调用,存在重复释放
* 3. 不同对象可能持有相同的 swigCPtr,导致重复释放同一块内存
*
* 方案:用集合记录已释放的地址,重复的直接跳过
*/
@Aspect
class NativeAspect {
private val locker = Any()
private val releasedPtr = Collections.synchronizedSet(HashSet<Long>())
@Around("execution(* org.gdal.ogr.FeatureNative.delete(..))")
fun deleteFeatureNative(joinPoint: ProceedingJoinPoint) {
synchronized(locker) {
deleteFeature(joinPoint)
}
}
private fun deleteFeature(joinPoint: ProceedingJoinPoint) {
// 反射读取 SWIG 生成的 native 指针字段
val swigCPtr = ReflectHelper.reflect(joinPoint.target)
.field("swigCPtr")
.get<Long>()
if (swigCPtr != 0L && !releasedPtr.contains(swigCPtr)) {
releasedPtr.add(swigCPtr)
joinPoint.proceed() // 真释放
if (releasedPtr.size > 50000) {
releasedPtr.clear() // 防止集合无限膨胀
}
}
// 否则:静默跳过,避免 double free
}
}
就这么三十行,把「每天崩几次」变成了「再没崩过」。
被切面包住之后,一次 delete 的完整时序是这样:
这段代码里值得抄的五个细节
细节一:为什么能拦到 native 方法
JNI 方法在 Java 层就是一个 public native void delete() 的普通方法声明 。AspectJ 织入的是 Java 字节码的调用点 ,而调用 feature.delete() 就是一个标准的 invokevirtual 指令------所以它跟拦截普通 Java 方法没有任何区别。
限制 :切面对库内部自己调用的 native 方法 无效(那种是 C 层直接调用,不经过 Java 字节码)。它只对「Java 代码 → native 声明」这一跳有效。这个场景里正好是业务代码和库的 Java 层在调 delete(),所以可行。
这一步是全文那句判断最直接的应用:native 方法之所以能拦,不是因为 AspectJ 懂 C,而是因为它只需要拦下Java 那一跳 。反过来说,如果 double free 是 C 层内部两个函数互调造成的,这个方案一开始就不成立------在动手前先确认「触发路径有没有 Java 调用点」,能省掉大量白工。
细节二:synchronized(locker) 不能省
问题根因就是多线程并发释放。加锁之后,检查与释放变成一个原子操作:
scss
线程 A:检查 releasedPtr(0x1234)? 无 → 加入 → proceed()
线程 B:检查 releasedPtr(0x1234)? 有 → 跳过
如果只检查不加锁,两个线程可能同时通过检查,还是 double free。
细节三:用 Collections.synchronizedSet + 外层锁,是不是重复了?
是的,这里是双保险 :外层 synchronized 保证「检查-加入-释放」的原子性,synchronizedSet 兜住其他可能的访问路径。在并发问题上,冗余的锁比精巧的乐观并发更值得信赖------尤其当你无法完全掌握三方库的调用时序时。
细节四:集合必须设上限
releasedPtr 只增不减就是内存泄漏。这里设了 50000 上限后清空。
但要注意清空带来的语义风险:地址是会被复用的,清空后如果某个「本该被去重」的旧地址再次出现,就会放行一次,理论上仍有风险。权衡点在于:
- 上限太小 → 频繁清空 → 去重失效,崩溃回来;
- 上限太大 → 集合占用内存上升(50000 个 Long 约几 MB)。
更好的做法 是用 LongSparseArray 或者按「当前存活对象」来维护(对象销毁时移除),但那需要能拿到销毁时机------在这个场景拿不到,所以用了容量上限 + 清空的工程近似。
细节五:反射读 swigCPtr 是最后手段,且必须 keep
SWIG 生成的字段叫 swigCPtr,混淆后可能被改名(虽然它是 public/protected 的概率较高)。Release 包必须加:
proguard
-keepclassmembers class org.gdal.ogr.FeatureNative {
long swigCPtr;
}
并且要有降级:反射失败时宁可 proceed()(走回原实现,可能崩),也不要吞掉释放(那会变成 native 内存泄漏,更隐蔽)。
kotlin
private fun deleteFeature(joinPoint: ProceedingJoinPoint) {
val swigCPtr = try {
ReflectHelper.reflect(joinPoint.target).field("swigCPtr").get<Long>()
} catch (e: Throwable) {
Log.e(TAG, "read swigCPtr failed, fallback to original", e)
-1L
}
if (swigCPtr < 0L) {
joinPoint.proceed() // 拿不到指针,退回原实现
return
}
// ... 原逻辑
}
这里的降级方向和前面地图场景是一致的------宁可偶发崩溃,也不要静默吞掉释放。因为吞掉释放会让内存只增不减,进程最终被 OOM Kill,而堆栈里同样没有一行 Java 代码,排查难度比 double free 还高一个量级。
这类「崩溃防护切面」的通用套路
不只是 native 库,任何第三方/系统 API 的已知缺陷都可以套这个模板:
kotlin
@Aspect
class GuardAspect {
@Around("execution(* com.thirdparty.SomeClass.riskyMethod(..))")
fun guard(joinPoint: ProceedingJoinPoint) {
try {
if (!preCheck(joinPoint)) {
return // 拦截:不满足条件就不执行
}
joinPoint.proceed() // 放行
} catch (e: Throwable) {
Log.e(TAG, "guarded: ${joinPoint.signature}", e)
// 可选:上报到自建的崩溃通道(注意别依赖系统 UncaughtExceptionHandler)
}
}
private fun preCheck(joinPoint: ProceedingJoinPoint): Boolean {
// 去重 / 状态校验 / 线程校验 / 版本校验
return true
}
}
preCheck 里常见四类守卫:
| 守卫类型 | 例子 |
|---|---|
| 去重 | 本文的 native 指针去重 |
| 状态校验 | 设备已断开就不再发指令 |
| 线程校验 | 必须在主线程执行的方法,切线程后再调 |
| 版本校验 | 某系统版本上该 API 有 Bug,直接跳过或换实现 |
这四类守卫的形态完全一致:入口判条件,不满足就 return(或换一种方式重试),满足才 proceed()。 一旦看清这一点,你会发现「崩溃防护」本质上就是「给方法装一个可编程的门」。
必须承认的代价
- 治标不治本 :根因是三方库的设计缺陷,切面只是挡住了触发路径。上游修好后,这个切面必须删掉------所以注释里要写清楚「这是临时方案,依赖
swigCPtr字段名」。 - 掩盖问题 :一旦加了防护,原先的崩溃消失了,但如果防护逻辑本身有漏洞,你会失去「崩溃」这个信号,转成更难查的内存泄漏。
- 性能损耗 :每次
delete都加锁 + 集合查询。在这个场景里delete不是每帧调用,可以接受;如果是每帧几千次的热点方法,这个方案就不成立。
所以我的建议是:用切面做防护的同时,务必保留一行可动态开关的开关位 (比如从配置读取 enableNativeGuard),出问题时能远程关掉,验证是不是防护引入的。
六、三级共通的坑、代价与取舍
三级走完,坑的形态高度相似。先把最常见的几个列成对照表:
| 现象 | 根因 | 解法 |
|---|---|---|
| 升级三方库后崩溃回来了 | 上游改了签名或字段名,切点静默失配 | 织入计数纳入 CI + 反射自检用例 |
| Release 包切面失效,Debug 却正常 | R8 删掉了反射访问的私有字段 | -keepclassmembers 保住目标字段 |
| 整块界面绘制中断 | 绘制路径上的切面抛了异常 | 切面内 try/catch,异常不溢出到宿主 |
| native 内存泄漏,比崩溃更隐蔽 | 反射失败后吞掉了释放 | 反射失败降级为 proceed() |
| 帧率掉到 20 | 在高频方法上织入含 IO 的切面 | 切面内不做耗时操作 |
| 自检页面查不出问题 | 反射自检和真实切点用的签名不一致 | 自检用例与切点表达式保持同源 |
下面几条是逐条展开。
风险一:版本升级后切点失效(静默)
这是最大的风险。上游改了方法签名、改了私有字段名,你的切点匹配不到,构建照样成功,运行时悄无声息地走回原实现。
应对:把「织入数量」纳入 CI。开启 AspectJ 的织入信息输出,构建日志里能看到每个切点的织入计数;为关键切面写一个「自检」用例,反射验证目标方法存在且签名未变:
kotlin
// 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
}
升级三方库后跑一次自检,比线上出问题再查划算太多。
风险二:R8 / ProGuard 把被切的方法内联或删除
AspectJ 织入在 R8 之前 ,所以切点是能匹配到的;但 R8 之后可能把目标方法内联进调用方,导致你的织入代码位置偏移(通常不影响功能,但会让堆栈变得诡异)。更危险的是 R8 删掉了你依赖的私有字段(反射读的那个)。
应对:给反射访问的字段加 keep 规则:
proguard
-keepclassmembers class org.osmdroid.views.MapView {
int mMapScrollX;
int mMapScrollY;
}
-keepclassmembers class org.osmdroid.views.overlay.Marker {
android.graphics.Point mPositionPixels;
}
风险三:切面自己抛异常
绘制路径、Binder 回调路径上的异常会引发连锁崩溃。所有切面逻辑都应遵循:
kotlin
@Around("execution(* com.thirdparty.Foo.bar(..))")
fun around(joinPoint: ProceedingJoinPoint) {
try {
replaceImplementation(joinPoint)
} catch (e: Throwable) {
Log.e(TAG, "aspect failed", e)
joinPoint.proceed() // 降级:走回原实现
}
}
风险四:多人协作时「改了行为但没人知道」
拦截三方库是隐式的行为修改。建议:
- 切面文件集中放一个
aspect/目录,命名统一XxxAspect; - 每个切面顶部写注释块,说明「拦截了什么 / 为什么 / 上游修好后能否移除」;
- 在 PR 模板里加一条:「是否改了三方库或切面行为?」
什么时候不该用 AOP 改三方库
| 情况 | 应该怎么做 |
|---|---|
| 上游已经修了、或者你有源码 | 直接改源码,别留切面 |
| 要改的是方法的内部逻辑中间某一步 | AOP 只能整体替换,改不了中间步骤,考虑 fork |
目标方法是 final / private / 内联的 Kotlin 顶层函数 |
织入可能失败,改用别的方案 |
| 改动涉及大量状态(需要访问库内部多个私有成员) | 切面会变成反射地狱,可维护性极差 |
一句话:AOP 适合替换「整个方法」,不适合「修改方法的一半」。 这条又回到了第一节那把尺子------切面站在调用点上看方法,看到的永远是一个不可分割的整体。
最后把 AspectJ 和它的三个替代方案摆在一起对比,方便判断该不该用它:
| 方案 | 能改谁的代码 | 改动粒度 | 主要代价 |
|---|---|---|---|
| 全局搜索替换 | 自己的调用点 | 逐个调用点 | 漏改、口径难统一 |
| fork 源码 | 手里有源码的三方库 | 任意 | 跟上游同步、升级要 merge |
| 反射替换实现 | 自己构造出来的实例 | 实例级 | 库内部 new 出来的对象换不掉 |
| AspectJ 切面 | 任意可达的 Java 调用点 | 整个方法 | 静默失效、需 keep 规则、需兜底降级 |
七、小结
- 一句话总纲:AspectJ 织入的是「Java 字节码的调用点」,不是「方法的实现体」------凡是走 Java 调用的一跳都能拦,C 层内部互调、方法体中间的某一步都拦不到。
- 三级递进:切自己的注解(省样板)→ 切三方库公开方法(换实现)→ 切 native 方法声明(加防护),难度一档比一档高,判据始终是同一条。
- 只有
@Around能阻断执行和改返回值 ;要「替换」用@Around且不调proceed(),要「补充」用@After。 - 拦三方库四件套:不调
proceed()换实现 +try/catch兜底降级 + 反射字段写 keep 规则 + 织入计数进 CI。 - 防护切面要留远程开关 ,注释里写清移除条件;它会掩盖原始信号,配套要有内存监控,别只看崩溃率。
- 三条原则:不做耗时操作、可降级、不藏业务规则。
系列导航:Android 技术总结系列第 2 篇(共 9 篇)。上一篇《Android 插件化架构:40 个 Gradle 模块的拆分、通信与共存》,下一篇《Android AOP 的多品牌定制与代价》。
你手上的工程里,有没有哪个三方库的行为是你「想改但不敢改」的?先别急着 fork------按那条尺子量一下:它的触发路径上,有没有一个 Java 调用点?