WebView组件封装(四)——三级缓存实现H5页面秒开之OkHttp缓存拦截器源码

1,692 阅读15分钟

WebView组件封装系列文章

Github地址:github.com/Peakmain/Pk… 欢迎大家来踩哦

三级缓存实现H5页面秒开效果

三级缓存优化后的效果

优化前效果.gif酒店列表优化后效果.gif

一、前言

1.1 shouldInteruptRequest

  • WebViewClient中的shouldInteruptRequest可以用于拦截并处理所有资源的请求,包括js、css、字体等。
  • 这方法允许应用程序截获WebView的所有资源请求,然后根据需要进行处理,例如加载本地缓存、请求修改内容,或者阻止某些资源的加载
  • 返回 null表示放弃对当前资源请求的处理,WebView将不会加载该资源
  • 调用 super.shouldInterceptRequest 可以获取 WebView 默认的资源响应,可在基础上进行定制
  • shouldInterceptRequest方法在主线程中执行,因此长时间处理可能导致WebView的阻塞

1.2 WebResourceResponse

shouldInteruptRequest要求我们返回WebResourceResponse,那么构建WebResourceResponse需要哪些参数呢?我们会发现WebResourceResponse有两个构造函数

构造函数一:基本构造函数
public WebResourceResponse(String mimeType, String encoding,
        InputStream data) {
  
}
  • 参数
    • mimeType:资源的 MIME 类型,例如 "text/html", "image/png"。
    • encoding:资源的编码类型,例如 "UTF-8"。
    • data:一个包含资源数据的 InputStream
构造函数二:详细构造函数
public WebResourceResponse(String mimeType, String encoding, int statusCode,
        @NonNull String reasonPhrase, Map<String, String> responseHeaders, InputStream data) {
 
}
  • 参数
    • mimeType:资源的 MIME 类型,例如 "text/html", "image/png"。
    • encoding:资源的编码类型,例如 "UTF-8"。
    • statusCode:HTTP 状态码,例如 200(OK)、404(Not Found)等。
    • reasonPhrase:HTTP 状态信息,例如 "OK"、"Not Found"。
    • responseHeaders:一个包含响应头的 Map
    • data:一个包含资源数据的 InputStream

两个构造将函数用哪个?

  • 在一些情况下,资源的文件扩展名可能与实际的MIME类型不匹配,因此根据Content-Type来确定资源类型会更加准确和可靠
  • 在某些资源如图片、ts等结尾可能不是.jpg等结尾,如pic.quanjing.com/ku/0g/QJ880… ,所以根据 Content-Type 来判断资源类型是一个好的做法,特别是当你需要处理各种类型的资源时,而文件扩展名不足以提供准确的信息时

二、OkHttp拦截请求与共享缓存

2.1 代码实现

字节数组曾分享一篇文章Android WebView H5 秒开方案总结曾提过可以使用OkHttp进行拦截请求与共享缓存,具体细节大家可以看上面的文章。

fun getWebResourceResponse(
    request: WebResourceRequest,
    callback: (WebResourceResponse?) -> Unit,
) {
    val url = request.url.toString()
    val requestBuilder = Request.Builder().url(url).method(request.method, null)
    val requestHeaders = request.requestHeaders
    requestHeaders?.forEach { requestBuilder.addHeader(it.key, it.value) }

    mOkHttpClient.newCall(requestBuilder.build()).enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {
            // 处理网络请求失败的情况
            callback(null)
        }

        override fun onResponse(call: Call, response: Response) {
            if (!response.isSuccessful) {
                callback(null)
                return
            }

            response.body?.let { body ->
                val mimeType = response.header("content-type", body.contentType()?.type)
                val encoding = response.header("content-encoding", "utf-8")

                val responseHeaders = mutableMapOf<String, String>()
                for (header in response.headers) {
                    responseHeaders[header.first] = header.second
                }

                val message = response.message.ifBlank { "OK" }
                val webResourceResponse =
                    WebResourceResponse(mimeType, encoding, body.byteStream())
                webResourceResponse.responseHeaders = responseHeaders
                webResourceResponse.setStatusCodeAndReasonPhrase(response.code, message)

                callback(webResourceResponse)
            } ?: callback(null)
        }
    })
}

我在进行公司项目多次运行的时候,发现并没有快多少,至少眼中的白屏一直存在,就很难受。OKHttp明明有缓存拦截器,为什么还是不快呢?

2.2 OkHttp拦截器CacheInterceptor源码(OkHttp版本4.2.2)

CacheInterceptor拦截器,在发出请求前,判断是否命中缓存。如果命中则可以不请求,直接使用缓存的相应(只会存在GET请求的缓存) 具体步骤如下:

  1. 从缓存中获得对应请求的响应缓存
  2. 创建缓存侧率CacheStrategy,创建时会判断是否能够使用缓存,在CacheStrategy有两个成员:networkRequest(代表有网络请求)和cacheResponse(代表有缓存)
networkRequestcacheResponse说明
NULLNot NULL直接使用缓存
NULLNULLokHttp直接返回504
NOT NULLNULL向服务器发送请求
NOT NULLNOT NULL发起请求,若得到相应为304(无修改),则更新缓存相应并返回
  1. 交给下一个拦截器处理
  2. 后续如果返回304则用缓存的响应,否则使用网络响应并缓存本次响应(只缓存GET请求的响应)

2.2.1 整体逻辑代码

override fun intercept(chain: Interceptor.Chain): Response {
  //1.  从缓存中获得对应请求的响应缓存
  val cacheCandidate = cache?.get(chain.request())
  //2、获取缓存策略
  val strategy = CacheStrategy.Factory(now, chain.request(), cacheCandidate).compute()
  val networkRequest = strategy.networkRequest
  val cacheResponse = strategy.cacheResponse
  //3、如果networkRequest和cacheResponse都为null,直接返回504
  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()
  }

  //4、networkRequest为空,但是cacheResponse不为空,则使用缓存
  if (networkRequest == null) {
    return cacheResponse!!.newBuilder()
        .cacheResponse(stripBody(cacheResponse))
        .build()
  }
  //5、只要networkRequest不为空,就一定会执行网络请求
  var networkResponse: Response? = null
  try {
    networkResponse = chain.proceed(networkRequest)
  } 
  //6、如果cacheResponse不为空而且返回304则更新缓存
  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()
      cache!!.trackConditionalCacheHit()
      cache.update(cacheResponse, response)
      return response
    } else {
      cacheResponse.body?.closeQuietly()
    }
  }
  //7、没有cacheResponse,直接网络请求构建返回
  val response = networkResponse!!.newBuilder()
      .cacheResponse(stripBody(cacheResponse))
      .networkResponse(stripBody(networkResponse))
      .build()
  //8、是否设置了缓存,这里需要用户自己在设置client时设置cache
  if (cache != null) {
    //9、需要判断资源是否可缓存
    if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {
      val cacheRequest = cache.put(response)
      return cacheWritingResponse(cacheRequest, response)
    }

    if (HttpMethod.invalidatesCache(networkRequest.method)) {
      try {
        cache.remove(networkRequest)
      } catch (_: IOException) {
        // The cache cannot be written.
      }
    }
  }

  return response
}

整体逻辑还是非常明确的,主要根据CacheStrategy两个成员判断采用哪种缓存策略。

  1. 如果 networkRequest 为空但 cacheResponse 不为空,直接使用缓存响应
  2. 如果有网络请求,就会执行网络请求,即使有缓存,不会立即返回缓存,而是发起网络请求
  3. 当缓存响应不为空(cacheResponse != null)且网络请求返回 304 Not Modified 时,才会更新缓存,同时构建新的响应。这样可以保证缓存的新鲜度,避免下载相同的内容
  4. 如果没有缓存响应,或者网络请求返回了新的内容,就会使用网络请求的结果构建新的响应

2.2.2 缓存拦截器缓存用的是什么?

image.png

  1. url根据md5生成key(图上写错了)
  2. 判断磁盘缓存是否有数据
  3. 有缓存数据创建缓存Response

2.2.3 缓存拦截器什么时候进行磁盘缓存?

image.png 进入到这一步的时候,说明networkRequest不为空,并且没有缓存,这时候需要将数据进行缓存

  1. cache 对象需要在创建 OkHttpClient 时设置。这个缓存对象负责管理缓存条目的存储和访问。
fun createOkHttpClient(): OkHttpClient {
    return OkHttpClient.Builder().cache(Cache(webViewResourceCacheDir, 500L * 1024 * 1024))
        .followRedirects(false)
        .followSslRedirects(false)
        .addNetworkInterceptor(getWebViewCacheInterceptor())
        .build()
}
  1. 在代码中,首先检查 cache 对象是否存在,然后检查响应是否可缓存。如果响应是可缓存的,并且请求方法是 GET,那么它被视为可以缓存的。然后,尝试将响应放入缓存中
  2. 对于非 GET 请求方法,比如 POSTPUT,由于这些请求的语义通常不会被缓存,因此不会将响应写入磁盘缓存。这是因为这些请求可能会包含随时间变化的数据,或者对服务器状态产生影响,因此不适合被缓存。因此,非 GET 请求被视为不可缓存的,直接返回 null

2.2.4 缓存策略又是什么呢?

首先我们要认识几个请求头和响应头

请求头

响应头说明例子
Date消息发送的时间Date:Sat,18 Now 2024 11:11:11 GMT
Expires资源过期的时间Expires:Sat,18 Now 2024 11:11:11 GMT
Cache-Control控制缓存行为的指令Cache-Control: max-age=3600, must-revalidate
Last-Modified资源最后修改的时间Last-Modified: Sat, 18 Nov 2024 10:30:00 GMT
Etag资源的唯一标识Etag: "abc123"
If-Modified-Since表示如果资源在指定的时间后被修改过,服务器将返回实体内容;如果未被修改,将返回 304 Not ModifiedIf-Modified-Since: Sat, 18 Nov 2024 10:30:00 GMT
If-None-Match表示只有在资源的 Etag 不匹配指定的值时,服务器才会返回实体内容;如果匹配,则返回 304 Not ModifiedIf-None-Match: "abc123"

cache-control

常见的cache-control

指令描述
max-age=[秒]资源最大有效时间爱你
no-cache请求不使用缓存。
no-store资源不允许被缓存。
max-age=<seconds>指定缓存的最大有效时间,单位为秒。例如,max-age=3600 表示资源在缓存中可以被存储和重用的最长时间为 3600 秒。
public响应可以被任何缓存(包括私有和共享缓存)存储。
private响应只能被客户端(浏览器)缓存,而不能被共享缓存存储。默认是private
min-fresh=[秒](请求)缓存最小新鲜度(用户认为缓存仍然有效的时长)
must-revalidate(响应)不允许使用过期缓存。
max-stale=[秒](请求)缓存过期后多久内仍然有效
immutable响应的内容不会随时间而变化,一旦缓存,就不会被更改。
  • 如果没有 max-age,那么 min-fresh=20 就表示缓存的有效期是 20 秒
  • 如果有 max-age=100,并且同时设置了 min-fresh=20,那么在缓存的 max-age 还有 80 秒(100 - 20 = 80)的时间内,缓存仍然被认为是新鲜的,用户也期望继续使用这个缓存而不去重新请求服务器
  • 如果 max-age 大于等于 min-fresh,则 min-fresh 不会对有效期产生影响。在这种情况下,有效期是 max-age 的值。
  • 如果 max-age 小于 min-fresh,则有效期是 min-fresh 的值
compute源码分析
fun compute(): CacheStrategy {
  //①
  val candidate = computeCandidate()
  //②
  if (candidate.networkRequest != null && request.cacheControl.onlyIfCached) {
    return CacheStrategy(null, null)
  }

  return candidate
}
  • 我们先看②,onlyIfCached表示只可以使用缓存,只可以使用缓存那么networkRequest必定为null,但是此时不为空,两者冲突,okHttp此时会返回504

image.png

computeCandidate源码分析

image.png 回到注释①的源码,由于代码长,我分解解析

1、判断缓存是否存在
if (cacheResponse == null) {
  return CacheStrategy(request, null)
}
  • cacheResponse == null表示没有缓存,只存在networkRequest,代表需要发起网络请求
2、Https网络请求
if (request.isHttps && cacheResponse.handshake == null) {
  return CacheStrategy(request, null)
}
  • 此时cacheResponse必定存在,即不为null
  • 如果是https请求,但是缓存中没有握手信息,则只能网络请求
3、响应码和响应头
if (!isCacheable(cacheResponse, request)) {
  return CacheStrategy(request, null)
}
fun isCacheable(response: Response, request: Request): Boolean {
  //响应码为 200, 203, 204, 300, 301, 404, 405, 410, 414, 501
  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 -> {
      // These codes can be cached unless headers forbid it.
    }

    HTTP_MOVED_TEMP,
    StatusLine.HTTP_TEMP_REDIRECT -> {
      // These codes can only be cached with the right response headers.
      // http://tools.ietf.org/html/rfc7234#section-3
      // s-maxage is not checked because OkHttp is a private cache that should ignore s-maxage.
      if (response.header("Expires") == null &&
          response.cacheControl.maxAgeSeconds == -1 &&
          !response.cacheControl.isPublic &&
          !response.cacheControl.isPrivate) {
        return false
      }
    }

    else -> {
      // All other codes cannot be cached.
      return false
    }
  }
  return !response.cacheControl.noStore && !request.cacheControl.noStore
}
  • 当响应码为 200, 203, 204, 300, 301, 404, 405, 410, 414, 501,308的情况下,只需要判断网络请求和缓存的Cache-Control是否是no-store(资源不能被缓存),如果为false,则代表此缓存不可用
  • 当响应码为302或者307(重定向),如果不包含Expiresmax-ageisPublicisPrivate则表示该缓存不可用
    • max-age=[秒]:资源最大有效时间
    • public:表明该资源可以被任何用户缓存,比如客户端、代理服务器等
    • private:表明该资源只能被单个用户缓存,默认是private
4、网络请求配置
val requestCaching = request.cacheControl
if (requestCaching.noCache || hasConditions(request)) {
  return CacheStrategy(request, null)
}
private fun hasConditions(request: Request): Boolean =
    request.header("If-Modified-Since") != null || request.header("If-None-Match") != null

对网络请求的Request的cache-control进行判断

  • 如果用户指定了no-cache(不使用缓存)的请求头,不允许使用网络缓存
request.newBuilder().cacheControl(CacheControl.Builder().noCache().build())
  • 请求头包含 If-Modified-Since 或 If-None-Match (请求验证),不允许使用网络缓存
请求头说明
cache-control忽略网络缓存
If-Modified-Since值一般为Date或lastModified,服务器没有在指定时间后修改请求对应资源,返回304(无修改)
If-None-Match:标记值一般为 Etag ,将其与请求对应资源的 Etag 值进行比较;如果匹配,返回304
  • 上述包含这些请求头,都必须与网络进行请求,然后进行匹配比较,所以缓存不可用
5、缓存资源的有效期

根据缓存响应中的一些信息判定缓存是否处于有效期内,满足以下条件表示可以使用缓存:

缓存存活时间<缓存新鲜度-缓存最小新鲜度+过期后继续使用时长

val responseCaching = cacheResponse.cacheControl
//1、 获得缓存的响应从创建到现在的时间
val ageMillis = cacheResponseAge()
//2、获取这个响应有效缓存的时长,主要是获取max-age
var freshMillis = computeFreshnessLifetime()
if (requestCaching.maxAgeSeconds != -1) {
  //如果请求中包含了max-age表示指定了能拿的缓存有效时长,max-age和fresh中最小值便是响应缓存的时长
  freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))
}
//3、获得cache-control中的min-fresh
var minFreshMillis: Long = 0
if (requestCaching.minFreshSeconds != -1) {
  minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds.toLong())
}
//4、Cache-Control:must-revalidate 不可使用缓存
//Cache-Control:max-stale=[秒] 缓存过期后还能使用指定的时长
var maxStaleMillis: Long = 0
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
  if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
    builder.addHeader("Warning", "113 HttpURLConnection "Heuristic expiration"")
  }
  return CacheStrategy(null, builder.build())
}

5.1、缓存到现在存活的时间:ageMillis

private fun cacheResponseAge(): Long {
  val servedDate = this.servedDate
  //代表了客户端收到响应到服务器发出响应的一个时间差
  val apparentReceivedAge = if (servedDate != null) {
    maxOf(0, receivedResponseMillis - servedDate.time)
  } else {
    0
  }

  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
}

举个例子: 假设以下时间点:

  1. 服务器发出响应时间(servedDate): 12:00 PM
  2. 客户端收到响应时间(receivedResponseMillis): 12:05 PM
  3. 缓存的 Age 响应头对应的秒数(ageSeconds): 300秒(5分钟)

现在开始计算每个部分的值:

  1. apparentReceivedAge:客户端收到响应到服务器发出响应的时间差,即12:05-12:00=300秒
  2. receivedAge:客户端缓存在收到时就已经存在多久了。这里是300秒(Age 响应头对应的秒数)
  3. responseDuration:缓存对应的请求,在发送请求到接受响应的时间差。这个时间差可以通过收到响应的时间减去发送请求的时间得到。假设发送请求的时间为11:55 AM ,那么responseDuration就是12:05-11:55=600ms
  4. residentDuration:这个缓存接受到的时间到现在的时间差,即从缓存相应接受时间到当前时间的差值。假设现在是12:30,就是12:30-12:05=1500秒
  5. receivedAge + responseDuration + residentDuration 的和表示了缓存在客户端实际存活的时间。这里缓存在客户端实际存活了2400ms(300+600+1500)

总结

缓存存活时间+缓存最小新鲜度 < 缓存新鲜度+过期后继续使用时长,代表可以使用缓存。只要不忽略缓存并且缓存未过期,则使用缓存

6、缓存过期处理
val conditionName: String
val conditionValue: String?
when {
  etag != null -> {
    conditionName = "If-None-Match"
    conditionValue = etag
  }

  lastModified != null -> {
    conditionName = "If-Modified-Since"
    conditionValue = lastModifiedString
  }

  servedDate != null -> {
    conditionName = "If-Modified-Since"
    conditionValue = servedDateString
  }

  else -> return CacheStrategy(request, null) // No condition! Make a regular request.
}

val conditionalRequestHeaders = request.headers.newBuilder()
conditionalRequestHeaders.addLenient(conditionName, conditionValue!!)

val conditionalRequest = request.newBuilder()
    .headers(conditionalRequestHeaders.build())
    .build()
return CacheStrategy(conditionalRequest, cacheResponse)
  • 此时代表缓存已经过期无法使用
  • 此时我们判定缓存的响应中如果存在 Etag 字段,则使用 If-None-Match 请求头,将 Etag的值传递给服务器进行验证
  • 如果存在 Last-Modified 或者 Date 字段,则使用 If-Modified-Since 请求头,将相应的时间戳传递给服务器进行验证
  • 根据 conditionNameconditionValue 构建新的请求头,然后使用这些请求头构建一个新的请求 conditionalRequest,最后返回一个 CacheStrategy 对象,其中包含了新的条件请求和缓存响应
缓存总结

我们对缓存策略进行一个总结

  1. 如果缓存获取的Response为空,则网络请求获取响应
  2. 如果是https但是没有握手信息,则网络请求获取响应
  3. 如果请求头有no-cache或者If-Modified-Since/If-None-Match,则网络请求获取响应
  4. 如果响应头没有 no-cache 标识,且缓存时间没有超过极限时间,那么可以使用缓存,不需要进行网络请求;
  5. 如果缓存过期了,判断响应头是否设置了Etag/Last-Modified/Date,没有那就需要使用网络请求否则需要考虑服务器返回304

总结

  • OkHttp通过利用磁盘缓存存储和管理已获取的响应数据
  • 在缓存策略方面,OkHttp依赖于请求头和缓存的响应头来决定是否使用缓存数据、重新请求数据或添加条件进行验证
  • 因此,OkHttp更倾向于优化网络请求的缓存,为移动应用提供了高效的响应处理机制。然而,相对于资源缓存,OkHttp可能不是最理想的选择。