WebView组件封装系列文章
- WebView组件封装(一)——怎样使用全局缓存池管理提高WebView加载速度
- WebView组件封装(二)——怎样用设计模式封装WebView,轻松实现个性化定制,让你的App网页更加顺畅
- WebView组件封装(三)——PkWebView的使用文档
Github地址:github.com/Peakmain/Pk… 欢迎大家来踩哦
三级缓存实现H5页面秒开效果
三级缓存优化后的效果
一、前言
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请求的缓存
)
具体步骤如下:
- 从缓存中获得对应请求的响应缓存
- 创建缓存侧率CacheStrategy,创建时会判断是否能够使用缓存,在CacheStrategy有两个成员:networkRequest(代表有网络请求)和cacheResponse(代表有缓存)
networkRequest | cacheResponse | 说明 |
---|---|---|
NULL | Not NULL | 直接使用缓存 |
NULL | NULL | okHttp直接返回504 |
NOT NULL | NULL | 向服务器发送请求 |
NOT NULL | NOT NULL | 发起请求,若得到相应为304(无修改),则更新缓存相应并返回 |
- 交给下一个拦截器处理
- 后续如果返回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两个成员判断采用哪种缓存策略。
- 如果
networkRequest
为空但cacheResponse
不为空,直接使用缓存响应 - 如果有网络请求,就会执行网络请求,即使有缓存,不会立即返回缓存,而是发起网络请求
- 当缓存响应不为空(
cacheResponse != null
)且网络请求返回 304 Not Modified 时,才会更新缓存,同时构建新的响应。这样可以保证缓存的新鲜度,避免下载相同的内容 - 如果没有缓存响应,或者网络请求返回了新的内容,就会使用网络请求的结果构建新的响应
2.2.2 缓存拦截器缓存用的是什么?
- url根据
md5
生成key
(图上写错了) - 判断磁盘缓存是否有数据
- 有缓存数据创建缓存Response
2.2.3 缓存拦截器什么时候进行磁盘缓存?
进入到这一步的时候,说明networkRequest不为空,并且没有缓存,这时候需要将数据进行缓存
cache
对象需要在创建OkHttpClient
时设置。这个缓存对象负责管理缓存条目的存储和访问。
fun createOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder().cache(Cache(webViewResourceCacheDir, 500L * 1024 * 1024))
.followRedirects(false)
.followSslRedirects(false)
.addNetworkInterceptor(getWebViewCacheInterceptor())
.build()
}
- 在代码中,首先检查
cache
对象是否存在,然后检查响应是否可缓存。如果响应是可缓存的,并且请求方法是GET
,那么它被视为可以缓存的。然后,尝试将响应放入缓存中 - 对于非
GET
请求方法,比如POST
、PUT
,由于这些请求的语义通常不会被缓存,因此不会将响应写入磁盘缓存。这是因为这些请求可能会包含随时间变化的数据,或者对服务器状态产生影响,因此不适合被缓存。因此,非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 Modified | If-Modified-Since: Sat, 18 Nov 2024 10:30:00 GMT |
If-None-Match | 表示只有在资源的 Etag 不匹配指定的值时,服务器才会返回实体内容;如果匹配,则返回 304 Not Modified | If-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
computeCandidate源码分析
回到注释①的源码,由于代码长,我分解解析
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(重定向),如果不包含
Expires
、max-age
、isPublic
、isPrivate
则表示该缓存不可用- 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
}
举个例子: 假设以下时间点:
- 服务器发出响应时间(servedDate): 12:00 PM
- 客户端收到响应时间(receivedResponseMillis): 12:05 PM
- 缓存的
Age
响应头对应的秒数(ageSeconds): 300秒(5分钟)
现在开始计算每个部分的值:
apparentReceivedAge
:客户端收到响应到服务器发出响应的时间差,即12:05-12:00=300秒receivedAge
:客户端缓存在收到时就已经存在多久了。这里是300秒(Age
响应头对应的秒数)responseDuration
:缓存对应的请求,在发送请求到接受响应的时间差。这个时间差可以通过收到响应的时间减去发送请求的时间得到。假设发送请求的时间为11:55 AM ,那么responseDuration就是12:05-11:55=600msresidentDuration:
这个缓存接受到的时间到现在的时间差,即从缓存相应接受时间到当前时间的差值。假设现在是12:30,就是12:30-12:05=1500秒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
请求头,将相应的时间戳传递给服务器进行验证 - 根据
conditionName
和conditionValue
构建新的请求头,然后使用这些请求头构建一个新的请求conditionalRequest
,最后返回一个CacheStrategy
对象,其中包含了新的条件请求和缓存响应
缓存总结
我们对缓存策略进行一个总结
- 如果缓存获取的Response为空,则网络请求获取响应
- 如果是https但是没有握手信息,则网络请求获取响应
- 如果请求头有no-cache或者If-Modified-Since/If-None-Match,则网络请求获取响应
- 如果响应头没有 no-cache 标识,且缓存时间没有超过极限时间,那么可以使用缓存,不需要进行网络请求;
- 如果缓存过期了,判断响应头是否设置了Etag/Last-Modified/Date,没有那就需要使用网络请求否则需要考虑服务器返回304
总结
- OkHttp通过利用磁盘缓存存储和管理已获取的响应数据
- 在缓存策略方面,OkHttp依赖于请求头和缓存的响应头来决定是否使用缓存数据、重新请求数据或添加条件进行验证
- 因此,OkHttp更倾向于优化网络请求的缓存,为移动应用提供了高效的响应处理机制。然而,相对于资源缓存,OkHttp可能不是最理想的选择。