在Retrofit中如何使用多个不同的BaseUrl
简介
在 Android 开发时,我们一般使用 Retrofit + OkHttp 进行网络请求,但是我们知道在 Retrofit 中只能给它设置一个 BaseUrl ,但往往事与愿违,我们的项目中往往存在多 BaseUrl 的情况,这个时候我们一般有以下几种解决方案。
- 创建多个 Retrofit 实例,每个 Retrofit 对应一个 BaseUrl
- 在 Service 接口请求方法上使用 @GET、@POST 等注解时使用接口地址的全路径名称
- 在 Service 接口请求方法参数中使用 @Url 注解写上接口地址的全路径名称
那么,有没有一种方案可以实现只使用一个 Retrofit 实例,而且可以不用写接口地址的全路径呢?没错,这就是我将要带大家实现的方案 —— 基于自定义注解和自定义拦截器实现的方案。
我们都知道 Retrofit 是基于 OkHttp 网络框架进行的封装(如果不知道也没关系,现在你知道啦)。我们可以使用注解的方式实现接口请求参数的配置,也可以使用 OkHttp 的拦截器对 Retrofit 的请求进行拦截处理。
OkHttp 拦截器常见的应用场景
例如:日志拦截(打印请求的参数或者响应),Header 拦截(常见的需求就是给每个请求都加上同一个请求头) 。
通过本篇文章你将学到以下内容:
- 注解的自定义与使用
- OkHttp 拦截器的自定义与使用
- Kotlin 扩展函数的定义与使用
- Kotlin 泛型实化
- Kotlin 内联函数的定义与使用
BaseUrl 注解的使用效果
注解作用于方法
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : 阳光沙滩 Api
*/
interface SobApi {
/**
* 获取轮播图
*/
@SobBaseUrl
@GET("ast/home/loop/list")
suspend fun loadBanner(): ApiResponse<List<SobBanner>>
}
注解作用于整个接口
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : 玩安卓 Api
*/
@WanAndroidBaseUrl
interface WanAndroidApi {
/**
* 获取轮播图
*/
@GET("banner/json")
suspend fun loadBanner(): ApiResponse<List<WanAndroidBanner>>
}
实现思路分析
1、在拦截器中获取到当前请求方法(或当前请求方法所在接口)上的自定义注解
2、拿到自定义注解上的 BaseUrl 地址
3、将当前请求的 Url 地址使用自定义注解上的 BaseUrl 地址进行替换
难点分析
- 如何在拦截器中获取到当前请求方法(或当前请求方法所在接口)上的自定义注解
- 如何知道我们在拦截器中获取到的注解是带有 BaseUrl 地址的自定义注解
针对难点1:
我们可以使用 request.tag(Invocation::class.java?.method()
获取到当前请求方法的 Method
对象,有了 Method
对象我们就可以获取到当前请求方法上的注解。我们还可以通过 method.declaringClass
方法获取到当前请求方法所在的 class 或者 interface 所代表的对象,有了这个对象我们就可以获取到当前请求方法所在接口上的注解。
针对难点2:
我们可以自定义一个叫做 BaseUrl 的元注解,然后在具体的 BaseUrl 注解上使用该注解,然后在拦截器中获取到含有 BaseUrl 注解的注解(可能有点绕,看完具体实现代码你应该就能明白啦)。
元注解:本身是注解的同时,还可以用来修饰其他注解的注解
添加依赖
// Android网络请求库:https://github.com/square/retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
代码实现
- 首先,我们需要自定义一个叫做 BaseUrl 的元注解
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : BaseUrl 元注解
*/
@Target(AnnotationTarget.ANNOTATION_CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class BaseUrl(val value: String)
@Target,这个注解指定了被修饰的注解可以在什么地方使用,也就是目标;
@Retention,这个注解指定了被修饰的注解是不是编译后可见、是不是运行时可见,也就是保留的位置;
Target 注解的取值
public enum class AnnotationTarget {
// 类、接口、object、注解类
CLASS,
// 注解类
ANNOTATION_CLASS,
// 泛型参数
TYPE_PARAMETER,
// 属性
PROPERTY,
// 字段、幕后字段
FIELD,
// 局部变量
LOCAL_VARIABLE,
// 函数参数
VALUE_PARAMETER,
// 构造器
CONSTRUCTOR,
// 函数(方法)
FUNCTION,
// 属性的getter
PROPERTY_GETTER,
// 属性的setter
PROPERTY_SETTER,
// 类型
TYPE,
// 表达式
EXPRESSION,
// 文件
FILE,
// 类型别名
@SinceKotlin("1.1")
TYPEALIAS
}
Retention 注解的取值
public enum class AnnotationRetention {
// 注解只存在于源代码,编译后不可见
SOURCE,
// 注解编译后可见,运行时不可见
BINARY,
// 编译后可见,运行时可见
RUNTIME
}
我们以上自定义的 BaseUrl 注解本身作用于注解类,并且在编译后和运行时都可见(意思就是在编译后和运行的时候都能获取到该注解)。
- 然后,我们再定义一个具体的 BaseUrl 注解
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : SobBaseUrl 注解
*/
@BaseUrl(value = SOB_BASE_URL)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class SobBaseUrl
- 再然后,我们需要自定义一个 BaseUrl 拦截器
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : BaseUrl 拦截器
*/
class BaseUrlInterceptor(private val baseUrl: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// 获取到当前请求的 request 对象
val oldRequest = chain.request()
// 获取到请求的调用标记对象,如果获取不到则不处理该请求
val invocation = oldRequest.tag(Invocation::class.java) ?: return chain.proceed(oldRequest)
// 获取到当前请求的 Method 对象
val method = invocation.method()
// 优先获取到方法上的 BaseUrl 注解
var baseUrlAnnotationForMethod = method.getBaseUrlAnnotationOrNull()
// 如果从方法上获取到的 BaseUrl 注解为 `null`,则尝试获取接口类上的 BaseUrl 注解
if (baseUrlAnnotationForMethod == null) {
// 当前方法所在的类对象
val declaringClass = method.declaringClass
// 获取到接口类上的 BaseUrl 注解
baseUrlAnnotationForMethod = declaringClass.getBaseUrlAnnotationOrNull()
}
// 如果方法上和接口类上都没有 BaseUrl 注解,我们则认为这个请求的 BaseUrl 不需要被替换
baseUrlAnnotationForMethod ?: return chain.proceed(oldRequest)
// 能走到这里,则代表我们已经获取到了 BaseUrl 注解,开始替换 BaseUrl 的操作
// 我们先拿到当前请求的 url 地址(该 url 地址是全路径地址)
val oldUrl = oldRequest.url.toString()
// 如果 url 以 BaseUrl 开始,则返回去除 BaseUrl 后的后缀 url,否则返回 null
val newUrl = if (oldUrl.startsWith(baseUrl)) {
// 拿到除了 baseUrl 之外的 url 地址后缀
val urlSuffix = oldUrl.replaceFirst(baseUrl, "")
// 拼接出新的 url 地址
baseUrlAnnotationForMethod.value + urlSuffix
} else {
oldUrl
}
// 使用新的 url 地址构建 request 对象
val newRequest = oldRequest.newBuilder()
.url(newUrl)
.build()
// 使用新的 request 对象执行请求
return chain.proceed(newRequest)
}
/**
* 获取到 BaseUrl 注解,如果获取不到则返回 `null`
*/
private fun Class<*>.getBaseUrlAnnotationOrNull(): BaseUrl? = annotations.firstOrNull()
/**
* 获取到 BaseUrl 注解,如果获取不到则返回 `null`
*/
private fun Method.getBaseUrlAnnotationOrNull(): BaseUrl? = annotations.firstOrNull()
/**
* 通过遍历获取到第一个指定类型的注解,如果获取不到则返回 `null`
*/
private inline fun <reified T : Annotation> Array<Annotation>.firstOrNull(): T? {
forEach { annotation ->
annotation.getCustomAnnotationOrNull<T>().takeUnless { it == null }?.let { return it }
}
return null
}
/**
* 获取到自定义注解,如果获取不到则返回 `null`
*/
private inline fun <reified T : Annotation> Annotation.getCustomAnnotationOrNull(): T? =
annotationClass.java.getAnnotation(T::class.java)
}
- 最后别忘了给 OkHttpClinent 添加这个拦截器哦
val client = OkHttpClient.Builder()
.addInterceptor(BaseUrlInterceptor(BASE_URL))
.build()
okay,以上代码似乎已经能够满足我们的需求了,可是如果我的请求方法不想要替换 baseUrl 的话我们该怎么办呢?
针对这个问题,我的想法是新增一个叫做 Ignore 的注解。对于请求来说这个 Ignore 注解一般只作用于单个请求方法,所以我们将它 Target 的值指定为 AnnotationTarget.FUNCTION
,而且这个 Ignore 注解的优先级比方法和接口类上的 BaseUrl 注解都要高,所以我们将它放到判断的最前面。接下来跟着我一起优化一下代码吧~
- 首先,我们需要定义一个 Ignore 注解
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/22
* desc : 需要忽略处理的 BaseUrl 的请求
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Ignore
- 然后在代码中获取到这个注解并使用
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : BaseUrl 拦截器
*/
class UrlInterceptor(private val baseUrl: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// ...
// 获取到当前请求的 Method 对象
val method = invocation.method()
// ↓↓↓ 新增的代码 ↓↓↓
// 如果获取到了 Ignore 注解,那么不处理该请求
method.getIgnoreAnnotationOrNull()?.let { return chain.proceed(oldRequest) }
// ↑↑↑ 新增的代码 ↑↑↑
//...
// 使用新的 request 对象执行请求
return chain.proceed(newRequest)
}
// ↓↓↓ 新增的代码 ↓↓↓
/**
* 获取到 Ignore 注解,如果获取不到则返回 `null`
*/
private fun Method.getIgnoreAnnotationOrNull(): Ignore? = getAnnotation(Ignore::class.java)
// ↑↑↑ 新增的代码 ↑↑↑
// ...
}
Ignore 注解的使用效果
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : 应用 Api
*/
@SobBaseUrl
interface AppApi {
/**
* 检查 App 更新
*/
@Ignore
@GET("appconfig.json")
suspend fun checkAppUpdate(): ApiResponse<AppUpdateInfo>
}
代码重构
到这里你以为就已经结束了吗?不,其实还有优化的空间。
以上 BaseUrlInterceptor 的实现,我们都是基于只有一个 Retrofit 实例的前提来编写的代码,可是如果我们的使用者确实需要不止一个 Retrofit 实例呢?那岂不是每个 Retrofit 都需要重新创建一个 BaseUrlInterceptor 实例?
有没有一种方法可以解决呢?我的答案是:有。我们可以让外部传入一个 Set 集合,然后在拦截器里判断是否有需要替换的 baseUrl ,如果有才替换,没有的话我们就进行默认处理就 okay 了。
好,那让我们进行 BaseUrlInterceptor 代码的重构之旅吧!
/**
* author : A Lonely Cat
* github : https://github.com/anjiemo/SunnyBeach
* time : 2022/06/13
* desc : BaseUrl 拦截器,参数为 baseUrl 的 Set 集合,此集合的存在可以使当前拦截器给多个 Retrofit 实例共用而不需要创建多个 BaseUrlInterceptor 实例
*/
class BaseUrlInterceptor(private val baseUrlSet: Set<String>) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// 获取到当前请求的 request 对象
with(chain.request()) {
// 获取到请求的调用标记对象,如果获取不到则不处理该请求
val invocation = tag(Invocation::class.java) ?: return chain.proceed(this)
// 获取到当前请求的 Method 对象
val method = invocation.method()
// 如果该请求包含 Ignore 注解,则不做处理
method.getIgnoreAnnotationOrNull()?.let { return chain.proceed(this) }
// 拿到方法上的 BaseUrl 注解,如果拿不到,则拿到类上面的 BaseUrl 注解
val baseUrlAnnotation = method.getBaseUrlAnnotationOrNull() ?: method.declaringClass.getBaseUrlAnnotationOrNull()
// 如果方法上和类上的 BaseUrl 注解都拿不到,则不处理
baseUrlAnnotation ?: return chain.proceed(this)
// 能走到这里,则代表我们已经获取到了 BaseUrl 注解,开始替换 BaseUrl 的操作
// 我们先拿到当前请求的 url 地址(该 url 地址是全路径地址)
val oldUrl = url.toString()
// 如果 url 以 BaseUrl 开始,则返回去除 BaseUrl 后的后缀 url,否则返回 null
val suffixUrl = baseUrlSet.firstOrNull { oldUrl.startsWith(it) }?.let { oldUrl.replaceFirst(it, "") }
// 后缀 url 不为 null,则返回替换后的 url,否则返回原来的 url
val newUrl = if (suffixUrl != null) baseUrlAnnotation.value + suffixUrl else oldUrl
// 使用新的 url 地址构建 request 对象
val newRequest = newBuilder()
.url(newUrl)
.build()
// 使用新的 request 对象执行请求
return chain.proceed(newRequest)
}
}
/**
* 获取到 Ignore 注解,如果获取不到则返回 `null`
*/
private fun Method.getIgnoreAnnotationOrNull(): Ignore? = getAnnotation(Ignore::class.java)
/**
* 获取到 BaseUrl 注解,如果获取不到则返回 `null`
*/
private fun Class<*>.getBaseUrlAnnotationOrNull(): BaseUrl? = annotations.firstOrNull()
/**
* 获取到 BaseUrl 注解,如果获取不到则返回 `null`
*/
private fun Method.getBaseUrlAnnotationOrNull(): BaseUrl? = annotations.firstOrNull()
/**
* 通过遍历获取到第一个指定类型的注解,如果获取不到则返回 `null`
*/
private inline fun <reified T : Annotation> Array<Annotation>.firstOrNull(): T? {
forEach { annotation ->
annotation.getCustomAnnotationOrNull<T>().takeUnless { it == null }?.let { return it }
}
return null
}
/**
* 获取到自定义注解,如果获取不到则返回 `null`
*/
private inline fun <reified T : Annotation> Annotation.getCustomAnnotationOrNull(): T? =
annotationClass.java.getAnnotation(T::class.java)
}
结语
好啦,至此我们编写的拦截器已经能适应大部分场景的使用了,如果你想看更完整的项目代码,请查看我在 Github 上的 阳光沙滩APP项目 ,你也可以在这里点击下载我写的 阳光沙滩APP客户端 进行体验。
如果对你有帮助的话,欢迎一键三连+关注哦~