巧用拦截器,让OkHttp缓存post请求

3,062 阅读8分钟

1 问题场景

一个网络请求接口,app启动时要请求一下,进入MainActivity时要请求一下,甚至每次onResume时都要请求一下。实际的情况可能不太一样,但是像这种一个接口要频繁请求多次的情况多少应该都有遇到,我们需要接口的数据,但是实际数据的变动又没有那么频繁。这样每一次都请求网络可能会造成网络资源浪费和等待。

2 问题的处理思路

2.1 不执行实际网络请求,也不返回数据

比如通过数据更新界面的功能,数据一样就没必要更新界面了。可以在某一个时间段直接不调用请求接口方法,以减少界面的无效刷新,达到限制请求间隔的效果

2.2 缓存请求数据结果

  1. 缓存到内存(变量或LRU)
  2. 缓存到数据库
  3. 缓存到磁盘

有缓存之后同一段时间内再有相同的请求直接返回缓存数据。

这一次本文要讲的是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都是nullnetworkResponse都有值。

这说明请求数据没有被缓存成功,正常的应该第一次请求cacheResponsenullnetworkResponse有值,第二次请求cacheResponse有值,networkResponsenull

为什么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)
      }
    }
  }
}

...

再看看Cacheput方法

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类又不能继承。

自己改,怎么改?有两个思路

  1. 复制OkHttp处理缓存的Cache类和CacheInterceptor类,修改Cacheput方法支持缓存POST请求,然后在复制的CacheInterceptor类中把Cache类的声明引用指向复制修改后的Cache类的对象,将修改后的CacheInterceptor类的对象添加到OkHttp的拦截器列表中。

这是网上能搜到的做法,我觉得复制的代码过多,增加相似功能的类(原Cache类和新Cache类,原CacheInterceptor类和新CacheInterceptor类)。

  1. 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秒内,第一次请求没有缓存数据,会发出实际网络请求,数据回来应该会被缓存起来。这时cacheResponsenullnetworkResponse有值。

第二次请求有缓存数据,直接返回缓存数据,不会再去发出实际网络请求。这时cacheResponse有值,networkResponsenull

实际的log打印符合我们的预期,接口数据被成功缓存和返回了。

最后的收尾总结

利用拦截器让OkHttp缓存POST请求的思路

第一步,在缓存拦截器工作前告诉缓存拦截器CacheInterceptor该接口数据可缓存,如果有有效的缓存数据会直接返回缓存数据。

第二步,在发出实际网络请求前还原为POST请求

第三步,等网络请求数据回来时告诉缓存拦截器CacheInterceptor该接口数据需要缓存起来。

相关的代码已在文中全部贴出。