Android AOP 切面编程实战:优雅地处理全局断网拦截

3 阅读2分钟

背景:从一个断网提示说起

老版本的京东在断网时,所有按钮点击都是没有反应的,并不会像现在这样:点击依旧跳转页面,只是会有无网络的占位符或者提示。

这是怎么做到的?

难道是在每一个按钮的点击回调中都进行判断吗?像这样:

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 注解的方法)直接“复制粘贴”织入到这些切点方法中,从而实现了无缝的逻辑替换。

通过反编译验证

我们通过反编译验证一下,看看字节码是否有被改动:

  1. 首先打一个 Release 包。

  2. 然后使用 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 时,排查链路会变长。