背景:从一个断网提示说起
老版本的京东在断网时,所有按钮点击都是没有反应的,并不会像现在这样:点击依旧跳转页面,只是会有无网络的占位符或者提示。
这是怎么做到的?
难道是在每一个按钮的点击回调中都进行判断吗?像这样:
Button(
onClick = {
if (isNetworkDisconnected()) {
return@Button
}
onButtonClick()
},
) {
Text("平平无奇的按钮")
}
// 此代码位于 MainActivity 中
private fun onButtonClick() {
Toast.makeText(this, "有网络,执行业务逻辑", Toast.LENGTH_SHORT).show()
}
private fun isNetworkDisconnected(): Boolean {
// 判断是否断网
return false
}
不需要这么做,如果每个按钮都这么写,既繁琐还笨重。我们有一种更优雅的方式:面向切面编程(AOP)。
什么是 AOP?
AOP 是一种编程思想,它将共同行为(如网络检查、日志、事务处理)从主业务中抽取出来,这样我们能在不改动原先主业务代码的前提下,给代码执行的前后加上额外的功能。
大家所熟悉的动态代理、生命周期的监听其实都有运用这种思想。接下来,我会使用另一种将这种思想发挥到极致的框架——AspectJ,来实现最开始提到的全局断网拦截功能。
引入与配置 AspectJ
首先在 build.gradle.kts (Module :app) 中引入插件和依赖:
import io.freefair.gradle.plugins.android.aspectj.AjcWeave
import java.util.Properties
plugins {
// 编译后织入插件(Android项目专属)
id("io.freefair.android.aspectj.post-compile-weaving") version "8.13.0"
}
dependencies {
// AspectJ 核心运行库
implementation("org.aspectj:aspectjrt:1.9.22")
}
// AjcWeave 织入时需要 android.jar 才能解析 android.* 类型
tasks.withType<AjcWeave>().configureEach {
val localProps = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
}
val sdkDir = file(
localProps.getProperty("sdk.dir")
?: System.getenv("ANDROID_HOME")
?: System.getenv("ANDROID_SDK_ROOT")
?: error("Android SDK not found (set sdk.dir / ANDROID_HOME)")
)
// 注意:我这里使用的是 API 37,你可以根据自己项目的实际 compileSdk 版本进行修改
val androidJar = sdkDir.resolve("platforms").listFiles()
?.filter { it.isDirectory && it.name.startsWith("android-37") }
?.sortedByDescending { it.name }
?.map { it.resolve("android.jar") }
?.firstOrNull { it.isFile }
?: error("android.jar for API 37 not found under ${sdkDir.resolve("platforms")}")
ajcOptions {
bootclasspath.from(androidJar)
compilerArgs.add("-Xlint:cantFindType=ignore")
}
}
这个插件会在 Android Gradle Plugin (AGP) 跑完后,再在自己的织入任务中修改已经编译好的字节码。
由于 freefair 插件目前的兼容性限制,我们还需要在 gradle.properties 中关闭配置缓存:
# freefair AspectJ AjcWeave 任务尚不支持 configuration cache
org.gradle.configuration-cache=false
定义切点与处理逻辑
和反射一样,我们要有一个锚点(注解),系统才知道要改动哪段代码。
先自定义一个注解:
@Retention(AnnotationRetention.BINARY) // 保留到字节码中,这样插件才能看到
@Target(AnnotationTarget.FUNCTION) // 放到方法上
annotation class NetworkCheck
接着,增加寻找锚点和处理拦截逻辑的切面类:
@Aspect // 声明这是一个切面类,交由 AspectJ 处理
class SectionAspect {
/**
* 找到要处理的切点(注解)
*
* `* *(..)` 表示拦截匹配到的所有方法
*/
@Pointcut("execution(@com.example.aoplearn.NetworkCheck * *(..))")
fun networkCheckBehavior() {
}
/**
* 处理切面,也就是前后新增的逻辑
*
* 在方法执行前先检查网络,断网则提示并拦截,有网则放行
*/
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
@Around("networkCheckBehavior()")
fun networkCheckPoint(joinPoint: ProceedingJoinPoint): Any? {
// 拿到被拦截方法所属的对象,这里的是MainActivity
val context = getContext(joinPoint.target) ?: return joinPoint.proceed()
val hasNetwork = isNetworkAvailable(context)
if (!hasNetwork) {
Toast.makeText(context, "网络不可用,请检查网络连接", Toast.LENGTH_SHORT).show()
// 拦截,直接返回 null,不执行原方法
return null
}
// 放行,执行原方法
return joinPoint.proceed()
}
/**
* 通过对象获取上下文
*/
private fun getContext(any: Any): Context? =
when (any) {
is Activity -> {
any as? Context
}
// 注:纯 Compose 环境默认没有 AndroidX 的 Fragment,所以这里注释掉
// is Fragment -> {
// any.activity as? Context
// }
is View -> {
any.context
}
else -> null
}
/**
* 判断网络是否可用
*/
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(context: Context): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
最后别忘了在 AndroidManifest.xml 中加上访问网络状态的权限:<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
这样以后,我们只需在任何需要检查网络的地方加上 @NetworkCheck 注解,就可以自动实现断网拦截了,无需写重复的判断代码。
揭秘:它是怎么实现的?
编译期“织入”
这个操作是怎么实现的?是反射吗?
不是。我们前面提到过,它是通过修改字节码实现的。
整个过程其实是在项目的编译构建(Build)阶段完成的:AspectJ 编译器(ajc)会介入编译过程,通过 @Pointcut 注解扫描并找到所有加上了 @NetworkCheck 注解的切点。
紧接着,在生成 .class 字节码文件时,它会将我们写的切面代码(使用 @Around 注解的方法)直接“复制粘贴”织入到这些切点方法中,从而实现了无缝的逻辑替换。
通过反编译验证
我们通过反编译验证一下,看看字节码是否有被改动:
-
首先打一个 Release 包。
-
然后使用 jadx 反编译这个 APK 文件。(下载地址:Release 1.5.6 · skylot/jadx)
反编译后,可以看到我们原本的点击方法 onHelloWorldBtnClick() 的内部逻辑变成了这样:
private final void onHelloWorldBtnClick() {
JoinPoint joinPointMakeJP = Factory.makeJP(ajc$tjp_0, this, this);
onHelloWorldBtnClick_aroundBody1$advice(this, joinPointMakeJP, SectionAspect.aspectOf(), (ProceedingJoinPoint) joinPointMakeJP);
}
而原本真正的业务逻辑和切面逻辑,被抽离替换成了下面这两个静态方法:
// 原本的业务逻辑被抽取到了这个 _aroundBody0 方法中
private static final /* synthetic */ void onHelloWorldBtnClick_aroundBody0(MainActivity mainActivity, JoinPoint joinPoint) {
Toast.makeText(mainActivity, "有网络,执行业务逻辑", 0).show();
}
// 织入的切面逻辑
private static final /* synthetic */ Object onHelloWorldBtnClick_aroundBody1$advice(MainActivity mainActivity, JoinPoint joinPoint, SectionAspect sectionAspect, ProceedingJoinPoint joinPoint2) {
Intrinsics.checkNotNullParameter(joinPoint2, "joinPoint");
Object target = joinPoint2.getTarget();
Intrinsics.checkNotNullExpressionValue(target, "getTarget(...)");
// 对应切面里的 getContext
Context context = sectionAspect.getContext(target);
if (context == null) {
onHelloWorldBtnClick_aroundBody0(mainActivity, joinPoint2);
return null;
}
// 对应切面里的网络判断
if (!sectionAspect.isNetworkAvailable(context)) {
Toast.makeText(context, "网络不可用,请检查网络连接", 0).show();
return null;
}
// 网络可用,放行执行原业务逻辑
onHelloWorldBtnClick_aroundBody0(mainActivity, joinPoint2);
return null;
}
总结
AOP 可以高度解耦代码,它有很多的应用场景,例如:
-
无痕埋点:拦截按钮的点击事件并自动上报,业务层无感知。
-
日志打印:自动记录关键方法的入参、出参和执行的耗时。
-
权限检查:在点击某个功能前自动检查是否拥有相机、定位等权限。
它也有缺点:一是会增加编译耗时;二是出现 bug 时,排查链路会变长。