Android开源框架系列-OkHttp4.11.0(kotlin版)- 拦截器分析之CacheInterceptor

前言

CacheInterceptor主要处理http请求过程中的缓存逻辑,默认只支持GET类型的请求,当第一次请求时,由于本地没有缓存,会先请求服务器,拿到响应后再将返回内容缓存到本地,再次请求时,满足条件情况下直接使用缓存,减少网络请求的发送,降低对流量的消耗。使okhttp支持缓存,我们首先需要主动的开启缓存配置。

java 复制代码
OkHttpClient client = new OkHttpClient().newBuilder()
        .cache(new Cache(new File(getCacheDir(),"okhttp_cache"),10000))
        .build();

intercept

同样,直接进入拦截器的intercept方法,看看这个缓存拦截器到底是如何实现的。缓存的逻辑是先存再取,所以我们不按照代码顺序来看了,直接按照使用的顺序,先看看缓存是怎么存下来的,从拿到服务端响应开始入手。

缓存写入

networkResponse就是从服务端拿到的数据,用它构建一个新的response对象,并将其成员cacheResponse和networkResponse的body都置空。

scss 复制代码
val response = networkResponse!!.newBuilder()
    .cacheResponse(stripBody(cacheResponse))
    .networkResponse(stripBody(networkResponse))
    .build()

接来下开始进入缓存操作逻辑。这里的cache就是我们给OkHttpClient对象设置的那个cache对象,

scss 复制代码
if (cache != null) {
  //见1.判断是否有响应内容返回;并且是否可以缓存,见2
  if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {
    // 满足缓存的条件,则准备开始缓存,见4
    val cacheRequest = cache.put(response)
    //cacheRequest不为null就说明要成功了,具体写入缓存的细节就不分析了。
    return cacheWritingResponse(cacheRequest, response).also {
      if (cacheResponse != null) {
        listener.cacheMiss(call)
      }
    }
  }

1.promisesBody

方法用于判断响应是否有内容。

kotlin 复制代码
fun Response.promisesBody(): Boolean {
  // 不管响应头是什么,HEAD请求都不会返回内容。
  if (request.method == "HEAD") {
    return false
  }
  //http状态码204(无内容)服务器成功处理了请求,但没有返回任何内容。
  //http状态码304 自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。
  //所以这两种情况下没有响应正文返回
  val responseCode = code
  if ((responseCode < HTTP_CONTINUE || responseCode >= 200) &&
      responseCode != HTTP_NO_CONTENT &&
      responseCode != HTTP_NOT_MODIFIED) {
    return true
  }

  //走到这里说明code值有问题,但是响应头却提示有内容返回,说明二者之一总有一个是错的
  //但是为了最大的兼容性,则尊重响应头的提示,认为有内容返回。
  //Content-Length值不为-1表示有内容,Transfer-Encoding表示分块响应,也是有内容的
  if (headersContentLength() != -1L ||
      "chunked".equals(header("Transfer-Encoding"), ignoreCase = true)) {
    return true
  }
  return false
}

2.isCacheable

响应是否可以缓存取决于它的响应码和响应头。

kotlin 复制代码
fun isCacheable(response: Response, request: Request): Boolean {
  when (response.code) {
    HTTP_OK,
    HTTP_NOT_AUTHORITATIVE,
    HTTP_NO_CONTENT,
    HTTP_MULT_CHOICE,
    HTTP_MOVED_PERM,
    HTTP_NOT_FOUND,
    HTTP_BAD_METHOD,
    HTTP_GONE,
    HTTP_REQ_TOO_LONG,
    HTTP_NOT_IMPLEMENTED,
    StatusLine.HTTP_PERM_REDIRECT -> {
      //这些code下响应都是可以被缓存的
    }

    HTTP_MOVED_TEMP,
    StatusLine.HTTP_TEMP_REDIRECT -> {
      // 当响应码为302或307时,见3.
      if (response.header("Expires") == null &&
          response.cacheControl.maxAgeSeconds == -1 &&
          !response.cacheControl.isPublic &&
          !response.cacheControl.isPrivate) {
        return false
      }
    }

    else -> {
      // 其他code都不能被缓存
      return false
    }
  }
  //最后,请求和响应头中都没有设置Cache-control:no-store,才能缓存,否则不行
  return !response.cacheControl.noStore && !request.cacheControl.noStore
}

3.临时重定向响应302、307

当响应码为302或307时,表示资源被临时重定向到其他地址了。这里提到了几个响应头,我们来了解下这几个头的含义。

vbnet 复制代码
Expires:表示资源过期的时间,格式如:Expires: Sat, 18 Nov 2028 06:17:41 GMT
maxAgeSeconds:表示的是响应头cache-control中的方法:max-age,如"cache-control: 'max-age=43200'",定义了资源可以在客户端缓存多久,单位是秒。43200秒等于12小时
isPublic:表明该资源可以被任何用户缓存,比如客户端,代理服务器等都可以缓存资源。
isPrivate:表明该资源只能被单个用户缓存,当指定private指令后,响应只以特定的用户作为对象。

所以从okhttp对这几个响应头的判断方式来看,针对这种被临时重定向的资源,响应头必须至少包含上边四个取值之一,才能被缓存,否则无法缓存。

4.put

put方法就是准备将响应内容写入缓存了,不过方法内部仍有一些判断,不满足还是会被退回。

kotlin 复制代码
internal fun put(response: Response): CacheRequest? {
  val requestMethod = response.request.method
  //判断若请求方法是POST、PATCH、PUT、DELETE、MOVE时,废弃掉此缓存,从缓存移除,见5
  if (HttpMethod.invalidatesCache(response.request.method)) {
    try {
      remove(response.request)
    } catch (_: IOException) {
      // The cache cannot be written.
    }
    return null
  }

  if (requestMethod != "GET") {
    // 不要缓存非GET响应。从技术上讲,可以缓存HEAD请求和一些POST请求,但这样做的复杂性很高,收益很低。
    return null
  }
  //看看响应头中有没有Vary,并且Vary的值是不是Vary: *,如果是,则不能缓存。见6
  //Vary: *的含义可以理解为,就是告诉缓存服务器(okhttp也算是缓存服务器),不要缓存这个内容
  //必须请求源服务器
  if (response.hasVaryAll()) {
    return null
  }
  //走到这里一定是满足缓存的条件的,构建一个Entry交给DiskLruCache去写入缓存
  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
  }
}

5.invalidatesCache

当请求方法是POST、PATCH、PUT、DELETE、MOVE时,不支持缓存。

ini 复制代码
fun invalidatesCache(method: String): Boolean = (method == "POST" ||
    method == "PATCH" ||
    method == "PUT" ||
    method == "DELETE" ||
    method == "MOVE") // WebDAV

6.Vary头

Vary头是和缓存相关的一个响应头,如Vary: Accept-Encoding。在 HTTP 缓存的过程中,缓存是按照 URL 作为唯一标识来进行缓存的,也就是说,如果两个请求的 URL 是一致的,那么就会使用缓存,不会重新向服务器请求一份新的数据。

但是,如果两个相同URL的请求使用了不同的请求头,那么此时使用相同的缓存将会出现问题。因为缓存是按照 URL 来判断缓存的,如果两个请求的请求头不同,就会导致两个请求产生不同的响应结果,缓存就无法共享。这时候,Vary 头就可以解决这个问题。

Vary 头部的作用就是在响应头中返回一个 Vary 字段,其中包含一组可能影响该响应结果的请求头名称,如"Vary: Accept-Encoding"。当客户端发起一个新的请求,比如使用不同的 Accept-Encoding 头,那么服务器就可以根据 Vary 头部的信息来判断该响应是否可以被缓存。

缓存读取

前边我们分析了缓存写入的逻辑,缓存写入之后,下次请求该url地址,就会先去读取该地址下保存的缓存,并验证是否满足使用的条件。

ini 复制代码
val cacheCandidate = cache?.get(chain.request())
kotlin 复制代码
internal fun get(request: Request): Response? {
  //url转md5
  val key = key(request.url)
  //从DiskLruCache中读取缓存,为空则直接返回,第一次请求时为空
  val snapshot: DiskLruCache.Snapshot = try {
    cache[key] ?: return null
  } catch (_: IOException) {
    return null // Give up because the cache cannot be read.
  }
  //缓存不为空,构建一个Entry
  val entry: Entry = try {
    Entry(snapshot.getSource(ENTRY_METADATA))
  } catch (_: IOException) {
    snapshot.closeQuietly()
    return null
  }
  //从entry中读取到response返回
  val response = entry.response(snapshot)
  if (!entry.matches(request, response)) {
    response.body?.closeQuietly()
    return null
  }

  return response
}

继续往后看拿到缓存之后做了什么?

ini 复制代码
//计算得到一个strategy,strategy对象中包含一个networkRequest和cacheResponse
//最终将根据这两个值来决定是使用缓存还是再次请求网络服务器。
//我们来看下compute方法,见7
val strategy = CacheStrategy.Factory(now, chain.request(), cacheCandidate).compute()
val networkRequest = strategy.networkRequest
val cacheResponse = strategy.cacheResponse

上边得到了CacheStrategy之后,就开始根据CacheStrategy的networkRequest和cacheResponse两个成员来判断究竟是直接使用缓,还是直接请求网络,或者请求网络询问服务器是否使用缓存。

csharp 复制代码
/如果本地有缓存,但是缓存策略返回的cacheResponse说明缓存不可用,关闭这个cacheCandidate
if (cacheCandidate != null && cacheResponse == null) {
  cacheCandidate.body?.closeQuietly()
}

networkRequest、cacheResponse两个都为空,表示缓存不可用,但是又要强制使用缓存,只能返回一个空的结果。

erlang 复制代码
if (networkRequest == null && cacheResponse == null) {
  return Response.Builder()
      .request(chain.request())
      .protocol(Protocol.HTTP_1_1)
      .code(HTTP_GATEWAY_TIMEOUT)
      .message("Unsatisfiable Request (only-if-cached)")
      .body(EMPTY_RESPONSE)
      .sentRequestAtMillis(-1L)
      .receivedResponseAtMillis(System.currentTimeMillis())
      .build().also {
        listener.satisfactionFailure(call, it)
      }
}

继续往下,此时networkRequest为空,但是cacheResponse不为空,于是返回缓存即可。

scss 复制代码
if (networkRequest == null) {
  return cacheResponse!!.newBuilder()
      .cacheResponse(stripBody(cacheResponse))
      .build().also {
        listener.cacheHit(call, it)
      }
}

经过上边的一次判断,可以知道此时networkRequest不为空,cacheResponse可能为空也可能不为空。networkRequest不为空说明,需要询问服务器,那么就要问下服务器究竟能不能使用缓存了。

csharp 复制代码
if (cacheResponse != null) {
  listener.cacheConditionalHit(call, cacheResponse)
} else if (cache != null) {
  listener.cacheMiss(call)
}

var networkResponse: Response? = null
try {
  //进入下一个拦截器,开始请求服务器
  networkResponse = chain.proceed(networkRequest)
} finally {
  if (networkResponse == null && cacheCandidate != null) {
    cacheCandidate.body?.closeQuietly()
  }
}

服务器返回结果,304表示本地缓存和服务端的数据一致,没有发生过改变,那么此时是可以直接使用这个缓存的。

scss 复制代码
if (cacheResponse != null) {
  if (networkResponse?.code == HTTP_NOT_MODIFIED) {
    val response = cacheResponse.newBuilder()
        .headers(combine(cacheResponse.headers, networkResponse.headers))
        .sentRequestAtMillis(networkResponse.sentRequestAtMillis)
        .receivedResponseAtMillis(networkResponse.receivedResponseAtMillis)
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build()

    networkResponse.body!!.close()

    // header发生改变了(combine方法合并的header),更新缓存
    cache!!.trackConditionalCacheHit()
    cache.update(cacheResponse, response)
    //返回缓存
    return response.also {
      listener.cacheHit(call, it)
    }
  } else {
    cacheResponse.body?.closeQuietly()
  }
}

最后,走到这里说明cacheResponse为空,需要直接请求服务器拿到结果。

scss 复制代码
val response = networkResponse!!.newBuilder()
    .cacheResponse(stripBody(cacheResponse))
    .networkResponse(stripBody(networkResponse))
    .build()
//更新缓存的逻辑省略
return response

代码逻辑走到这里,缓存拦截器的主流程逻辑我们就分析完了。

7.compute

kotlin 复制代码
fun compute(): CacheStrategy {
  //见8
  val candidate = computeCandidate()

  // 虽然客观条件不让使用缓存,但是调用方强制设置必须使用缓存,那么只能设置两个null,响应失败
  if (candidate.networkRequest != null && request.cacheControl.onlyIfCached) {
    return CacheStrategy(null, null)
  }

  return candidate
}

8.computeCandidate

根据读到的缓存的具体情况来判断缓存是否可用,然后返回一个CacheStrategy对象,CacheStrategy中包含两个成员,一个是netWorkRequest,一个是cacheResponse,假如netWorkRequest非空,cacheResponse为空,则表示要发起网络请求;假如netWorkRequest为空,cacheResponse非空,则表示缓存可用。

kotlin 复制代码
private fun computeCandidate(): CacheStrategy {
  // 如果本地就没有缓存,那么直接将CacheStrategy中缓存cacheResponse设置为null,
  // 表示要请求网络
  if (cacheResponse == null) {
    return CacheStrategy(request, null)
  }

  // 对于https请求,如果其握手信息为空,则缓存也不能使用,握手信息包含加密方式,tls版本信息等等
  if (request.isHttps && cacheResponse.handshake == null) {
    return CacheStrategy(request, null)
  }

  // 根据响应码决定是否满足缓存的条件,这个方法我们在缓存写入的分析中提到过
  // 缓存写入前也会调用此方法判断响应能否缓存,不能则设置cache为null返回。见上边写入缓存中2
  if (!isCacheable(cacheResponse, request)) {
    return CacheStrategy(request, null)
  }
  //如果请求中cacheControl设置了noCache,这种情况肯定是不能使用缓存,所以cacheResponse设置为null
  //针对第二种情况,是判断请求头中,是否包含If-Modified-Since或If-None-Match,假如包含
  //则表示虽然本地有缓存,但是也要和服务器确认缓存是否可用,见9,
  val requestCaching = request.cacheControl
  if (requestCaching.noCache || hasConditions(request)) {
    return CacheStrategy(request, null)
  }
  //接下来开始对缓存中的cacheControl做判断了
  val responseCaching = cacheResponse.cacheControl
  //计算缓存的年龄,也就是缓存用了多久了,见10.
  val ageMillis = cacheResponseAge()
  //计算缓存的有效期,见11
  var freshMillis = computeFreshnessLifetime()

  //maxAgeSeconds != -1表示本次请求时,指定了缓存的有效时间
  //Cache-Control:max-age[秒] :资源最大有效时间
  if (requestCaching.maxAgeSeconds != -1) {
    //所以从指定的有效值和实际的有效值中取最小值作为有效时间
    freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))
  }
    
  //请求中有没有强制指定最小有效期时间,
  //min-fresh=[秒] :(请求)缓存最小新鲜度(用户认为这个缓存有效的时长)
  var minFreshMillis: Long = 0
  if (requestCaching.minFreshSeconds != -1) {
    minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds.toLong())
  }

  var maxStaleMillis: Long = 0
  //mustRevalidate(must-revalidate)(响应)不允许使用过期缓存
  //max-stale(请求)缓存过期后多久内仍然有效
  //这里表示调用方指定了不能使用过期缓存,所以要在缓存有效期内才能使用
  if (!responseCaching.mustRevalidate && requestCaching.maxStaleSeconds != -1) {
    maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds.toLong())
  }
  //如果没有指定不使用缓存,并且缓存还在指定的有效期内,则是满足使用缓存条件的
  if (!responseCaching.noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
    val builder = cacheResponse.newBuilder()
    //缩小时间范围判断下,虽然有效但是不新鲜了,通过响应头添加警告
    if (ageMillis + minFreshMillis >= freshMillis) {
      builder.addHeader("Warning", "110 HttpURLConnection "Response is stale"")
    }
    val oneDayMillis = 24 * 60 * 60 * 1000L
    //缓存响应超过24小时,需要附加警告。
    if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
      builder.addHeader("Warning", "113 HttpURLConnection "Heuristic expiration"")
    }
    //返回缓存
    return CacheStrategy(null, builder.build())
  }

  val conditionName: String
  val conditionValue: String?
  when {
    //缓存响应头中包含ETag标识,则请求中添加If-None-Match头,询问服务器资源是否存在
    etag != null -> {
      conditionName = "If-None-Match"
      conditionValue = etag
    }
    //缓存响应头中包含Last-Modified标识,则请求中添加If-Modified-Since头,询问服务器资源是否改变
    lastModified != null -> {
      conditionName = "If-Modified-Since"
      conditionValue = lastModifiedString
    }
    //缓存响应头中包含Date标识,请求中添加If-Modified-Since头,询问服务器资源是否改变
    servedDate != null -> {
      conditionName = "If-Modified-Since"
      conditionValue = servedDateString
    }
    //如果上边这些都没有,直接发送一个常规请求
    else -> return CacheStrategy(request, null) 
  }
  //将上边指定的header添加到request中
  val conditionalRequestHeaders = request.headers.newBuilder()
  conditionalRequestHeaders.addLenient(conditionName, conditionValue!!)

  val conditionalRequest = request.newBuilder()
      .headers(conditionalRequestHeaders.build())
      .build()
  //返回最终的缓存策略
  return CacheStrategy(conditionalRequest, cacheResponse)
}

9.hasConditions

kotlin 复制代码
private fun hasConditions(request: Request): Boolean =
    request.header("If-Modified-Since") != null || request.header("If-None-Match") != null
If-Modify-Since:

和Last-Modified关联使用,服务器下发一次资源,会同时下发资源的最后修改时间(Last-Modified),当客户端再次请求这个资源时,把上一次保存的最后修改时间带给服务器(通过请求头If-Modify-Since把Last-Modified带给服务器),服务器会把这个时间与服务器上实际文件的最后修改时间进行比较。如果时间一致,那么返回HTTP状态码304(不返回文件内容),客户端接到之后,就直接使用本地缓存文件;如果时间不一致,就返回HTTP状态码200和新的文件内容,客户端接到之后,会丢弃旧文件,把新文件缓存起来。

If-None-Match

和Etag关联使用,第一次发起http请求资源时,服务器会返回一个Etag(假设Etag:abcdefg1234567),在第二次发起同一个请求时,客户端在请求头同时发送一个If-None-Match,而它的值就是Etag的值(If-None-Match:abcdefg1234567),这个请求头需要客户端自己设置。然后服务器收到后会对比客户端发送过来的Etag是否与服务器的相同,如果相同,就将If-None-Match的值设为false,返回状态为304,客户端继续使用本地缓存;如果不相同,就将If-None-Match的值设为true,返回状态为200,客户端重新解析服务器返回的数据

10.cacheResponseAge

kotlin 复制代码
private fun cacheResponseAge(): Long {
  //servedDate指的就是当前缓存从服务器请求下来时,请求头中Date的时间值,
  //表示的是服务端返回这个资源时的时间。
  val servedDate = this.servedDate
  //receivedResponseMillis是客户端记录的请求被接收到的时间值
  //所以receivedResponseMillis - servedDate.time得到的是请求到这个资源时,
  //资源的寿命已经有多长了,如我断点得到的403,已经403毫秒了
  val apparentReceivedAge = if (servedDate != null) {
    maxOf(0, receivedResponseMillis - servedDate.time)
  } else {
    0
  }
  //响应头"Age"的值,如果没有这个头,则使用上边我们计算出的apparentReceivedAge赋值
  val receivedAge = if (ageSeconds != -1) {
    maxOf(apparentReceivedAge, SECONDS.toMillis(ageSeconds.toLong()))
  } else {
    apparentReceivedAge
  }
  //请求的时长减响应的时长,是请求到响应消耗的时长
  val responseDuration = receivedResponseMillis - sentRequestMillis
  //当前时间减去响应时间,得到的就是响应之后已经过了多久
  val residentDuration = nowMillis - receivedResponseMillis
  //所以最终缓存的年龄计算得到的就是这个资源被使用多久了
  return receivedAge + responseDuration + residentDuration
}

11.computeFreshnessLifetime

计算缓存的有效期

kotlin 复制代码
private fun computeFreshnessLifetime(): Long {
  val responseCaching = cacheResponse!!.cacheControl
  //缓存有没有指定有效期,如果maxAgeSeconds != -1表示指定了,那么返回这个时间
  if (responseCaching.maxAgeSeconds != -1) {
    return SECONDS.toMillis(responseCaching.maxAgeSeconds.toLong())
  }
  //缓存有没有指定过期时间,如果指定了,expires != null,
  val expires = this.expires
  if (expires != null) {
    //用过期时间减去接收到缓存的时间,得到的就是缓存在本地的有效期
    val servedMillis = servedDate?.time ?: receivedResponseMillis
    val delta = expires.time - servedMillis
    return if (delta > 0L) delta else 0L
  }
  //如果缓存响应头中包含了上次修改时间,并且请求这个缓存时的get请求中没有添加参数(query == null,见12)
  if (lastModified != null && cacheResponse.request.url.query == null) {
    // 用服务端响应的时间减去上次修改的时间,就是资源已经存在的时间
    val servedMillis = servedDate?.time ?: sentRequestMillis
    val delta = servedMillis - lastModified!!.time
    return if (delta > 0L) delta / 10 else 0L
  }

  return 0L
}

12.query指什么

okhttp中有明确的注释,若get请求中添加了请求参数,则query不为空,query就是参数部分。

ruby 复制代码
* | URL                               | `query()`              |
* | :-------------------------------- | :--------------------- |
* | `http://host/`                    | null                   |
* | `http://host/?`                   | `""`                   |
* | `http://host/?a=apple&k=key+lime` | `"a=apple&k=key lime"` |
* | `http://host/?a=apple&a=apricot`  | `"a=apple&a=apricot"`  |
* | `http://host/?a=apple&b`          | `"a=apple&b"`          |

总结

这篇文章缓存拦截器逻辑的分析耽误了很久,主要在写文章之前我希望先通过demo将流程完整的再验证一遍,虽然之前也分析过java版本的okhttp源码,但主要还是抱着浅尝则止的态度,一些问题没有深究,所以这次中间遇到了一些小问题导致迟迟无法往下推进,到今天总算完成了,研究或者分析问题,应该抱着剥洋葱的态度,一层一层,由外及里,才能有更深刻的认识,本文虽然相对于之前深入了一些细节,但还是有很多没有聊到的,因为篇幅有限,欢迎讨论。

相关推荐
Kapaseker1 小时前
2026年,我们还该不该学编程?
android·kotlin
雨白17 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk17 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING18 小时前
RN容器启动优化实践
android·react native
恋猫de小郭20 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker1 天前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴1 天前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭1 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab2 天前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe2 天前
Now in Android 架构模式全面分析
android·android jetpack