Android 不改一行源码:用 AspectJ 做三类拦截

AspectJ 织入的是 Java 字节码的调用点,不是方法的实现体。

有些麻烦,你第一反应绝不会是「去改别人的源码」。

工程里的 Toast 有三套实现,样式各不相同,想在全局收口成一套;老三方地图库在子线程改 UI,线上随机崩;第三方 native 库有重复释放内存的缺陷,每天都要闪退几次。这三件事的共同点是:要改的代码都不在你手里------第一件散落在两百多处调用点,后两件的源码根本不在自己的仓库里。

常规做法无非三种:全局搜索替换、fork 源码自己维护、反射替换实例。三条路我们都试过,代价一次比一次高。这篇讲的是第四条路:用 AspectJ 把目标方法的调用点接管过来,一行源码都不动。

但它不是万能钥匙。三类拦截的难度是递进的------切自己代码里的注解最简单,切三方库的公开方法要处理兜底,切 native 方法声明则要先绕过一个常见误解。能不能拦、拦到什么程度,其实只取决于一句判断:AspectJ 织入的是「Java 字节码的调用点」,不是「方法的实现体」。 把这句话想透,剩下的都是推演。

一、先立一把尺子:织入的是调用点,不是实现体

先看 AspectJ 在 Android 构建链里的位置:它拿到的是 .class,在 dex 之前做改写。

flowchart LR S["Kotlin / Java 源码"] --> J["kotlinc / javac 生成 .class"] J --> W["ajc 织入 Aspect 切面代码"] W --> R["R8 混淆与裁剪"] R --> D["生成 dex"] D --> APK["打包成 APK"]

这条链解释了两件事。第一,AspectJ 天然和 Transform、Tinker、R8 抢同一个位置 ------它们都是对 .class 做后处理,插队顺序决定了谁先看到什么。第二,织入发生在 R8 之前,所以 R8 的裁剪和内联是在切面生效之后发生的,这留下了一个后面要讲的坑。

再看它的工作原理。一段业务代码 caller.bar() 编译出来是 invokevirtual Foo.bar 这样一条字节码指令。ajc 做的事,是在这条指令的前后 插入切面逻辑,而不是去改写 Foo.bar 方法体内部的代码。

flowchart TD A["业务代码 caller.bar()"] --> B["字节码 invokevirtual Foo.bar"] B --> C["ajc 在调用点插入切面代理"] C --> D["advice 先执行"] D --> E["joinPoint.proceed() 才回到 Foo.bar 实现体"] F["Foo.bar 的实现体本身"] -.->|"不被改写"| C

这张图值得反复看。它能推出三条结论,也是全文所有案例的判据:

  1. 凡是有 Java 字节码调用点的地方,都能拦。 哪怕被调的是 native 方法、是三方库、是系统 API,只要调用动作发生在 Java 字节码里,切面就有机会插手。
  2. 方法体内部做的事,切面看不见。 Foo.bar() 里自己又调了 baz(),切面无法只替换「中间的那一步」------它只能整体接管 bar()。
  3. 发生在 Java 字节码之外的调用,切面完全够不到。 比如 C 层代码直接调用另一个 C 函数,中间根本没有经过 Java 调用点。

于是三类拦截的难度曲线可以画成一张路线图:

flowchart LR L1["第一级 切自己的注解"] --> L2["第二级 切三方库公开方法"] L2 --> L3["第三级 切 native 方法声明"] L1 -.->|"改自己的代码"| N1["只省样板 风险最低"] L2 -.->|"改别人的 Java 实现"| N2["必须兜底退化"] L3 -.->|"改别人的 C 实现入口"| N3["治标不治本 要留开关"]

三级共享同一把尺子,差别只在「被切的东西属于谁」。把三类的边界摊开对比:

切什么 怎么写 拦不到什么
自己代码里带某注解的方法 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)。后者一旦藏在切面里,读代码的人永远看不到控制流。

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

怎么知道切面到底生效了

织入是编译期行为,失败了不会有运行时报错。三个验证手段:

  1. 反编译确认 :jadx 打开 APK,搜你的切面类名,看目标方法里有没有织入代码;
  2. 织入日志 :开启 AspectJ 的 -showWeaveInfo,构建时会打印每个切点织入了多少处;
  3. 运行时断言: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」 → 不会崩,但地图再也不刷新,而且没有任何报错------功能静默丢失比偶发崩溃更难发现。

对位置更新这种「有它更好、没它也不致命」的能力,选偶发崩溃是可接受的取舍;但如果换成支付、下发指令这类不能静默失败的能力,就必须选「宁可不刷新也不能错」。

整条拦截链路可以用一张图看清:

flowchart TD A["设备回调线程 每秒10次"] --> B["调用 MapView.setMapScroll"] B --> C{"切点命中?"} C -->|"命中"| D["反射写 mMapScrollX / mMapScrollY"] D --> E["App.post 到主线程 requestLayout"] E --> H["正常刷新 不崩溃"] C -->|"未命中 上游改了签名"| F["原方法在子线程直接改 UI"] F --> G["CalledFromWrongThreadException"] D -.->|"反射失败"| I["catch 后 proceed 退回原实现"] I --> F

场景二:给 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
        )
    }
}

值得注意的三点:

  1. @After 拿到的是「已经画完的 Canvas」,此时再画就叠在上面,天然实现「补充绘制」;
  2. Paint 对象复用 :这个方法每秒被调用几百次,onDraw 里 new 对象是最经典的性能杀手;
  3. 读不到私有坐标就 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)的典型特征,不是普通空指针。

排查到根因的结论是这样的(这段注释来自真实代码):

  1. 数据集里对要素的读写存在多线程安全问题;
  2. 该库由 SWIG 自动生成样板代码,包含一个循环的回收线程,而 delete 方法是 public 的,外部可以调用,导致线程安全问题,存在内存重复释放或访问已释放内存;
  3. 调试发现存在不同的对象但 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 的完整时序是这样:

sequenceDiagram participant Biz as 业务Java代码 participant Aspect as NativeAspect participant Ptr as releasedPtr集合 participant JNI as FeatureNative.delete participant C as libgdal.so Biz->>Aspect: feature.delete() Aspect->>Ptr: 查询 swigCPtr 是否已释放 Ptr-->>Aspect: 未释放 Aspect->>Ptr: 记录该地址 Aspect->>JNI: proceed() JNI->>C: JNI delete(swigCPtr) C-->>JNI: 释放内存 Note over Biz,Aspect: 第二次持有同一指针的对象再来 delete Biz->>Aspect: feature2.delete() 同一 swigCPtr Aspect->>Ptr: 查询 swigCPtr 是否已释放 Ptr-->>Aspect: 已释放 直接跳过 Note over Aspect,C: 不再进入 native 层 避免 double free

这段代码里值得抄的五个细节

细节一:为什么能拦到 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()。 一旦看清这一点,你会发现「崩溃防护」本质上就是「给方法装一个可编程的门」。

必须承认的代价

  1. 治标不治本 :根因是三方库的设计缺陷,切面只是挡住了触发路径。上游修好后,这个切面必须删掉------所以注释里要写清楚「这是临时方案,依赖 swigCPtr 字段名」。
  2. 掩盖问题 :一旦加了防护,原先的崩溃消失了,但如果防护逻辑本身有漏洞,你会失去「崩溃」这个信号,转成更难查的内存泄漏。
  3. 性能损耗 :每次 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 调用点?

相关推荐
TST-魚爷1 小时前
易视讯会议摄像头 + 全向麦克风实操讲解稿
android·电脑·钉钉·腾讯会议·zoom会议
嘟哩DuliDuli1 小时前
AI创作工作区的项目上下文与多模态任务边界
android·人工智能·安全·ai·软件工程
imDwAaY3 小时前
MySQL 存储引擎是什么?
android·数据库·mysql
熊猫钓鱼>_>4 小时前
开源鸿蒙平台 KMP 三方库 kotlin-csv 适配全流程:从编码假设到沙箱真机互通验证
开发语言·人工智能·华为·kotlin·harmonyos·鸿蒙
Meteors.4 小时前
Android Compose学习02:基础组件与 Modifier
android·学习
其实防守也摸鱼5 小时前
存储管理:页面置换算法学习任务(测验题要求给出置换页面顺序,缺页次数,缺页率)
android·前端·数据库·学习·oracle·自动化
mmsx6 小时前
Android 地图上绘制 KML:解析、样式与渲染
android·xml
运行时异常7 小时前
【无标题】
android
右手吉他7 小时前
高通平台IA APN问题分析之一
android·物联网