云计算百科
云计算领域专业知识百科平台

Android AspectJ 入门:从省样板代码到不改源码修三方库

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

前言

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

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

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

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

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

当你需要改一个"不属于你、但又到处都在被调用"的行为时,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 最危险的地方。三个验证手段:

  • 反编译确认: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 把这个方法整个接管。

    @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 #字节码 #三方库 #编译插桩 #性能优化

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Android AspectJ 入门:从省样板代码到不改源码修三方库
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!