18、网络请求注解:Retrofit风格的注解实现、动态代理与反射结合

说实话,Retrofit 这个库,咱们 Android 开发几乎天天用。但你有没有想过,它底层到底是怎么把「一个接口方法」变成「一个完整的网络请求」的?

我个人习惯把这种技术叫做「声明式网络请求」。你只需要写个接口,加几个注解,剩下的脏活累活,框架全帮你干了。今天我们就来手撕一下这个机制的核心——注解 + 动态代理 + 反射。

18.1 Retrofit 的核心思想

先看一个典型的 Retrofit 用法:

interface ApiService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Int): User
}

你写了个接口,加了个 @GET 注解,然后 Retrofit 就能帮你发 HTTP 请求了。这背后其实就三步:

  1. 解析注解:从方法上拿到 @GET@Path 等注解信息。
  2. 动态代理:在运行时生成接口的实现类。
  3. 反射调用:把方法调用转换成网络请求。

嗯,这里要注意,Retrofit 并没有在编译期生成代码,它用的是运行时的动态代理。这一点和很多人的直觉不一样。

核心结论:Retrofit 的注解处理发生在运行时,通过动态代理拦截方法调用,再通过反射读取注解信息,最终拼装成 OkHttp 的 Request。

18.2 自定义网络请求注解

我们先定义一套自己的注解。我建议你按照 Retrofit 的风格来,但可以简化一些。

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class GET(val value: String)

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Path(val value: String)

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Query(val value: String)

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Body

这里我用了 RUNTIME 保留策略,因为我们要在运行时通过反射读取它们。如果你用 SOURCECLASS,运行时是拿不到的。

小提示:注解的 @Retention 一定要选对。我见过不少新手把 RUNTIME 写成 SOURCE,结果反射拿不到注解,排查了半天。

18.3 动态代理:接口的「替身」

动态代理是 Java 反射包里的一个机制。它可以在运行时动态创建一个接口的实现类。说白了,你定义一个接口,不用写实现类,代理对象会帮你拦截所有方法调用。

看代码:

fun <T> create(serviceClass: Class<T>): T {
    return Proxy.newProxyInstance(
        serviceClass.classLoader,
        arrayOf(serviceClass)
    ) { proxy, method, args ->
        // 这里就是拦截点
        handleMethod(method, args)
    } as T
}

这个 handleMethod 就是核心。它要做三件事:

  1. 读取方法上的 @GET 注解,拿到 URL 路径。
  2. 读取参数上的 @Path@Query@Body 注解,拿到参数值。
  3. 拼装成 HTTP 请求,返回结果。

18.4 反射读取注解信息

接下来就是反射上场了。我们来看 handleMethod 的具体实现:

private fun handleMethod(method: Method, args: Array<Any?>): Any? {
    // 1. 获取方法上的 GET 注解
    val getAnnotation = method.getAnnotation(GET::class.java)
        ?: throw IllegalArgumentException("缺少 @GET 注解")

    var urlPath = getAnnotation.value
    val parameterAnnotations = method.parameterAnnotations
    val parameterTypes = method.parameterTypes

    // 2. 遍历参数,处理 @Path、@Query、@Body
    val queryParams = mutableMapOf<String, String>()
    var body: Any? = null

    for (i in args.indices) {
        val annotations = parameterAnnotations[i]
        for (annotation in annotations) {
            when (annotation) {
                is Path -> {
                    val placeholder = "{${annotation.value}}"
                    urlPath = urlPath.replace(placeholder, args[i].toString())
                }
                is Query -> {
                    queryParams[annotation.value] = args[i].toString()
                }
                is Body -> {
                    body = args[i]
                }
            }
        }
    }

    // 3. 拼装请求(这里简化了,实际要拼接 baseUrl)
    println("请求路径: $urlPath")
    println("查询参数: $queryParams")
    println("请求体: $body")

    // 4. 返回模拟结果(实际会走 OkHttp)
    return "模拟响应"
}

这里有个细节:method.parameterAnnotations 返回的是一个二维数组。第一维是参数索引,第二维是该参数上的所有注解。为什么是二维?因为一个参数上可以加多个注解嘛。

注意:反射读取注解时,getAnnotationparameterAnnotations 的性能开销都不小。如果你在循环中频繁调用,建议做缓存。我曾在项目中遇到过因为反射调用过多导致卡顿的问题,后来用 Map<Method, MethodInfo> 缓存了解析结果,性能提升很明显。

18.5 完整示例:跑起来看看

我们把上面几块拼起来,写个完整的 demo:

interface ApiService {
    @GET("users/{id}/posts")
    fun getPosts(
        @Path("id") userId: Int,
        @Query("page") page: Int
    ): String
}

fun main() {
    val service = create(ApiService::class.java)
    val result = service.getPosts(123, 2)
    println(result)
}

运行结果:

请求路径: users/123/posts
查询参数: {page=2}
请求体: null
模拟响应

你看,@Path("id")123 替换进了 URL,@Query("page")2 变成了查询参数。整个过程没有写一行实现代码,全靠注解 + 动态代理 + 反射。

18.6 知识体系图

下面这张图帮你理清整个流程:

网络请求注解核心流程 接口定义 @GET @Path @Query 动态代理 Proxy.newProxyInstance 反射解析 getAnnotation 拼装 HTTP 请求 URL + 参数 + Body 发送网络请求 OkHttp / 模拟返回 关键点 • 注解保留策略必须为 RUNTIME • 动态代理拦截所有方法调用 • 反射读取注解和参数值 • 建议缓存反射结果提升性能 • 支持 @Path、@Query、@Body 等

18.7 避坑指南

最后分享几个我踩过的坑:

  • 注解保留策略:一定要用 RUNTIME,否则反射拿不到。我曾经在代码审查时发现有人用了 SOURCE,问他为什么,他说「我忘了」。
  • 参数顺序:反射拿到的参数注解数组,顺序必须和接口定义一致。如果你在接口里调换了参数位置,反射解析时就会乱掉。
  • 性能问题:动态代理本身开销不大,但反射读取注解是耗时的。建议在第一次调用时解析并缓存结果,后续直接复用。
  • 泛型擦除:如果你要解析返回类型的泛型(比如 List<User>),需要用 ParameterizedType 来处理。Retrofit 在这方面做了很多工作,我们这里只是演示核心原理,所以简化了。

好了,这一章的内容就到这里。你想想看,其实 Retrofit 的核心并不神秘,就是注解 + 动态代理 + 反射这三板斧。掌握了这些,你甚至可以自己写一个轻量级的网络请求框架。

公众号:蓝海资料掘金营,微信deep3321