1 问题场景
一个网络请求接口,app启动时要请求一下,进入MainActivity时要请求一下,甚至每次onResume时都要请求一下。实际的情况可能不太一样,但是像这种一个接口要频繁请求多次的情况多少应该都有遇到,我们需要接口的数据,但是实际数据的变动又没有那么频繁。这样每一次都请求网络可能会造成网络资源浪费和等待。
2 问题的处理思路
2.1 不执行实际网络请求,也不返回数据
比如通过数据更新界面的功能,数据一样就没必要更新界面了。可以在某一个时间段直接不调用请求接口方法,以减少界面的无效刷新,达到限制请求间隔的效果
2.2 缓存请求数据结果
- 缓存到内存(变量或LRU)
- 缓存到数据库
- 缓存到磁盘
有缓存之后同一段时间内再有相同的请求直接返回缓存数据。
这一次本文要讲的是OkHttp自带缓存到磁盘的功能。
3 问题的解决
3.1 启用OkHttp缓存功能
3.1.1 OkHttp缓存相关的初始化配置
@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
// 10m
val diskCacheSize = 10L shl 20
return OkHttpClient.Builder()
.readTimeout(60, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
//这里给OkHttp设置缓存功能
.cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
.build()
}
这里OkHttpClient.Builder().cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
给OkHttp设置缓存功能,但实际会不会触发缓存还得看返回数据的请求头配置。
3.1.2 手动给返回数据的请求头配置有效缓存时长
给OkHttp设置的拦截器
private fun Request.isCachePostRequest(): Boolean = run {
url.toString().contains(APP_INFO_URL, true)
}
class CachePostResponseInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
// 发起请求
var response = chain.proceed(request)
// 获得请求结果
// 为该接口设置缓存
if (response.request.isCachePostRequest()) {
response = response.newBuilder()
.removeHeader("Pragma")
// 缓存 60秒
.addHeader("Cache-Control", "max-age=60")
.build()
}
return response
}
}
给接口数据设置缓存的方法有好几种,这里通过配置请求头参数Cache-Control的方法设置缓存,缓存时长60秒。
然后给OkHttp添加这个拦截器
@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
// 10m
val diskCacheSize = 10L shl 20
return OkHttpClient.Builder()
.readTimeout(60, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
//这里给OkHttp设置缓存功能
.cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
// 添加给返回数据设置缓存的拦截器
.addNetworkInterceptor(CachePostResponseInterceptor())
.build()
}
这里看样子应该大功告成,不过要验证是否缓存需要添加一下日志,还是通过拦截器的方法,这里临时添加一个用于打印的拦截器。
@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
// 10m
val diskCacheSize = 10L shl 20
return OkHttpClient.Builder()
.readTimeout(60, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
//这里给OkHttp设置缓存功能
.cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
// 临时的用于打印日志的拦截器
.addInterceptor {
val request = it.request()
val response = it.proceed(request)
Timber.e("cacheResponse: ${response.cacheResponse} networkResponse: ${response.networkResponse}")
response
}
// 添加给返回数据设置缓存的拦截器
.addNetworkInterceptor(CachePostResponseInterceptor())
.build()
}
这里为什么CachePostResponseInterceptor拦截器是用addNetworkInterceptor方法添加,而日志打印拦截器是通过addInterceptor方法添加先不解释,要讲清楚原理需要讲解OkHttp拦截器的工作原理和责任链设计模式的工作流程,这些内容都可以另开几篇文章写了。
先运行看一下效果。
E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78)
cacheResponse: null networkResponse: Response{protocol=http/1.1, code=200, message=, url=...}
E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78)
cacheResponse: null networkResponse: Response{protocol=http/1.1, code=200, message=, url=...}
两次请求时间间隔60秒内,cacheResponse都是null,networkResponse都有值。
这说明请求数据没有被缓存成功,正常的应该第一次请求cacheResponse为null,networkResponse有值,第二次请求cacheResponse有值,networkResponse为null。
为什么OkHttp没有缓存我们的接口数据?我们去看一下OkHttp是怎么缓存数据的。
3.1.3 OkHttp缓存数据的工作逻辑
OkHttp中缓存数据的工作是交给CacheInterceptor拦截器
查看CacheInterceptor类的代码可以发现缓存的保存是在网络请求数据返回时并且Cache对象引用存在,这个Cache就是前面设置OkHttpClient.Builder().cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))时的Cache。
...
val response = networkResponse!!.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.networkResponse(stripBody(networkResponse))
.build()
if (cache != null) {
if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {
// 一个关键的点
// Offer this request to the cache.
val cacheRequest = cache.put(response)
return cacheWritingResponse(cacheRequest, response).also {
if (cacheResponse != null) {
// This will log a conditional cache miss only.
listener.cacheMiss(call)
}
}
}
}
...
再看看Cache的put方法
internal fun put(response: Response): CacheRequest? {
val requestMethod = response.request.method
if (HttpMethod.invalidatesCache(response.request.method)) {
try {
remove(response.request)
} catch (_: IOException) {
// The cache cannot be written.
}
return null
}
// 只支持 GET请求
if (requestMethod != "GET") {
// Don't cache non-GET responses. We're technically allowed to cache HEAD requests and some
// POST requests, but the complexity of doing so is high and the benefit is low.
return null
}
if (response.hasVaryAll()) {
return null
}
val entry = Entry(response)
var editor: DiskLruCache.Editor? = null
try {
editor = cache.edit(key(response.request.url)) ?: return null
entry.writeTo(editor)
return RealCacheRequest(editor)
} catch (_: IOException) {
abortQuietly(editor)
return null
}
}
可以看到只支持缓存GET请求,不是GET请求直接返回null。
看看我们的请求接口,是一个POST请求!
@POST(APP_INFO_URL)
suspend fun appInfo(@Body map: Map<String, String?>): Response<AppInfo>
怎么办怎么办,OkHttp只有GET请求才缓存数据,这是合理的,但是我们有时却碰到POST请求需要缓存数据的情况,这样的一种情况存在可能说明后端写的接口请求方式不太合适,要后端去改吗?不是很有必要。
Cache类又不能继承。
自己改,怎么改?有两个思路
- 复制
OkHttp处理缓存的Cache类和CacheInterceptor类,修改Cache的put方法支持缓存POST请求,然后在复制的CacheInterceptor类中把Cache类的声明引用指向复制修改后的Cache类的对象,将修改后的CacheInterceptor类的对象添加到OkHttp的拦截器列表中。
这是网上能搜到的做法,我觉得复制的代码过多,增加相似功能的类(原Cache类和新Cache类,原CacheInterceptor类和新CacheInterceptor类)。
- 在
OkHttp处理缓存拦截器工作前把特定需要缓存数据的POST请求改成GET,先通过缓存这一关(如果有有效的缓存数据直接返回缓存数据),然后在发出实际网络请求前还原为POST请求去正确请求数据,等请求数据回来又重新把POST请求改成GET(以缓存数据)。
这个处理思路只需要两个拦截器,是我采取的处理方案。
3.2 让OkHttp缓存Post请求
3.2.1 在OkHttp处理缓存拦截器工作前把特定需要缓存数据的POST请求转成GET请求
这个拦截器要解决的问题是告诉缓存拦截器CacheInterceptor该接口数据可缓存,如果有有效的缓存数据会直接返回缓存数据。
/**
* POST 转换成 GET
*/
class TransformPostRequestInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
// 缓存
if (request.isCachePostRequest()) {
val builder = request.newBuilder()
// 把 POST 改成 GET
.method("GET", null)
.cacheControl(
CacheControl.Builder()
.maxAge(60, TimeUnit.SECONDS)
.build()
)
// 保存 body
saveRequestBody(builder, request.body)
request = builder.build()
}
return chain.proceed(request)
}
}
这个拦截器的关键核心是把POST请求改成GET请求,调用request.newBuilder().method("GET", request.body)构造新的请求就是GET请求,但是一运行你会发现程序Crash(崩溃)了
查看method方法,发现对于GET请求,他不让我们设置RequestBody
open fun method(method: String, body: RequestBody?): Builder = apply {
require(method.isNotEmpty()) {
"method.isEmpty() == true"
}
if (body == null) {
require(!HttpMethod.requiresRequestBody(method)) {
"method $method must have a request body."
}
} else {
require(HttpMethod.permitsRequestBody(method)) {
"method $method must not have a request body."
}
}
this.method = method
this.body = body
}
@kotlin.internal.InlineOnly
public inline fun require(value: Boolean, lazyMessage: () -> Any): Unit {
contract {
returns() implies value
}
if (!value) {
val message = lazyMessage()
throw IllegalArgumentException(message.toString())
}
}
@JvmStatic // Despite being 'internal', this method is called by popular 3rd party SDKs.
fun permitsRequestBody(method: String): Boolean = !(method == "GET" || method == "HEAD")
而这个RequestBody是我们的请求参数信息,是必需要保存的,不然丢失请求参数。怎么办,只能反射给他设置。
private fun saveRequestBody(builder: Request.Builder, body: RequestBody?) {
val bodyField = builder.javaClass.getDeclaredField("body")
bodyField.isAccessible = true
bodyField.set(builder, body)
}
3.2.2 还原为POST请求去发出实际请求,等请求数据回来又重新把POST请求改成GET以缓存数据
这个拦截器要处理的问题是要正确的发出网络请求,同时等网络请求数据回来要告诉缓存拦截器CacheInterceptor该接口数据需要缓存起来。
/**
* Response的缓存
*/
class CachePostResponseInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
// 实际请求前
if (request.isCachePostRequest()) {
request = request.newBuilder()
.method("POST", request.body)
.build()
}
// 发起请求
var response = chain.proceed(request)
// 获得请求结果
// 为该接口缓存
if (response.request.isCachePostRequest()) {
val builder = response.request.newBuilder()
// 把 POST 改成 GET
.method("GET", null)
// 保存 body
saveRequestBody(builder, request.body)
response = response.newBuilder()
.request(builder.build())
.removeHeader("Pragma")
// 缓存 60秒
.addHeader("Cache-Control", "max-age=60")
.build()
}
return response
}
}
给OkHttp设置拦截器
@Singleton
@Provides
fun provideOkHttpClient(app: Application): OkHttpClient {
// 10m
val diskCacheSize = 10L shl 20
return OkHttpClient.Builder()
.readTimeout(60, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
//这里给OkHttp设置缓存功能
.cache(Cache(File(app.externalCacheDir, "net"), diskCacheSize))
.addInterceptor(TransformPostRequestInterceptor())
.addInterceptor {
val request = it.request()
val response = it.proceed(request)
Timber.e("cacheResponse: ${response.cacheResponse} networkResponse: ${response.networkResponse}")
response
}
// 添加给返回数据设置缓存的拦截器
.addNetworkInterceptor(CachePostResponseInterceptor())
.build()
}
运行看看看效果。
E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78)
cacheResponse: null networkResponse: Response{protocol=http/1.1, code=200, message=, url=...}
E/NetWorkModule$provideOkHttpClient$$inlined$-addInterceptor: (Interceptor.kt:78)
cacheResponse: Response{protocol=http/1.1, code=200, message=, url=...} networkResponse: null
两次请求时间间隔60秒内,第一次请求没有缓存数据,会发出实际网络请求,数据回来应该会被缓存起来。这时cacheResponse为null,networkResponse有值。
第二次请求有缓存数据,直接返回缓存数据,不会再去发出实际网络请求。这时cacheResponse有值,networkResponse为null。
实际的log打印符合我们的预期,接口数据被成功缓存和返回了。
最后的收尾总结
利用拦截器让OkHttp缓存POST请求的思路
第一步,在缓存拦截器工作前告诉缓存拦截器CacheInterceptor该接口数据可缓存,如果有有效的缓存数据会直接返回缓存数据。
第二步,在发出实际网络请求前还原为POST请求
第三步,等网络请求数据回来时告诉缓存拦截器CacheInterceptor该接口数据需要缓存起来。
相关的代码已在文中全部贴出。