问题描述
今天遇到一个问题,网页加载的页面,服务器数据提交更新了,但是 iOS
这边显示的还是老的内容。页面使用的 WKWebView
来加载数据的。第一时间我想到的是 WebView
的缓存,那既然安卓端能刷新,应该是 iOS
这边出现了问题,是什么原因导致的呢?首先去苹果官方文档查看 WKWebView
缓存策略描述。
WKWebView 的默认缓存策略
先来看下苹果给的枚举文档
WKWebView 支持的缓存策略枚举
typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
NSURLRequestUseProtocolCachePolicy = 0, // 默认策略,具体的缓存逻辑和协议的声明有关,如果协议没有声明,不需要每次重新验证cache
NSURLRequestReloadIgnoringLocalCacheData = 1, // 忽略本地缓存,直接从服务器请求数据
NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4, // iOS 13 才实现,忽略本地缓存数据、代理和其他中介的缓存,直接从从服务器请求数据
NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,
NSURLRequestReturnCacheDataElseLoad = 2, // 优先从本地拿数据,且忽略请求生命时长和过期时间。但是如果没有本地cache,则从服务器请求数据
NSURLRequestReturnCacheDataDontLoad = 3, // 只从本地拿数据
NSURLRequestReloadRevalidatingCacheData = 5, // iOS 13才实现,从原始地址确认缓存数据的合法性后,缓存数据就可以使用,否则从服务器请求数据
};
这里需要注意一下 NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4
和 NSURLRequestReloadRevalidatingCacheData = 5
在 iOS 13
之前并未实现该协议,所以如果使用该协议一定要注意系统版本判断。
NSURLRequestUseProtocolCachePolicy
下面是官方文档提供的默认缓存策略的流程图
官方文档描述
For the HTTP and HTTPS protocols, NSURLRequestUseProtocolCachePolicy >performs the following behavior:
If a cached response does not exist for the request, the URL loading system fetches the data from the originating source.
Otherwise, if the cached response does not indicate that it must be revalidated every time, and if the cached response is not stale (past its expiration date), the URL loading system returns the cached response.
If the cached response is stale or requires revalidation, the URL loading system makes a HEAD request to the originating source to see if the resource has changed. If so, the URL loading system fetches the data from the originating source. Otherwise, it returns the cached response.
This behavior is illustrated in Figure 1.
Figure 1 NSURLRequestUseProtocolCachePolicy decision tree for HTTP and >HTTPS
中文翻译
- 缓存不存在,则直接从服务器请求数据;
- 缓存存在,在
response
头信息中没有指定每次必须进行资源更新,且缓存没有过期,则系统不会从服务器请求拉取数据,而是直接走缓存; - 如果缓存过期了或者要求每次必须校验资源更新,此时会发起一个资源校验更新的请求,如果服务器有更新,则使用服务器返回的新数据,如果没有资源更新则还是使用本地缓存数据
对于上面官方文档的解释只是理解了大概的原理,具体的细节和数据指标还是不清楚,例如:
- 什么情况下会缓存数据?
- 什么情况下每次都需要校验资源更新?
- 缓存过期时间多久?
- 校验资源更新的过程是怎么样的?
对于这些,需要了解 HTTP
的缓存协议,实际上,苹果的 WKWebView
默认的缓存策略完全遵守 HTTP
缓存协议,没有做额外的事情。就是说如果你想详细知道 WKWebView
默认的缓存策略,那么你得先去弄清楚 HTTP
的缓存协议。
HTTP 缓存
客户端
默认缓存行为实际上是由 服务器
控制的,客户端
和 服务器
通过 HTTP
请求头
和 响应头
中的缓存字段来交流,进而影响 客户端
的行为。
Pragma、Expires
在 HTTP 1.0
时代,给客户端设定缓存方式可通过这两个字段。Pragma
是一个通用头,它只有 no-cache
这一个值。
通用头:该字段可以用于请求头,也可用于响应头。(同一个属性在请求头和响应头中意义可能不一样)
作为请求头,表示不使用缓存,直接从服务器获取资源,这是 HTTP 1.0
的用法,HTTP1.1
的用法是 Cache-Control:no-cache
。不过为了兼容 HTTP1.0
,一般 Pragma:no-cache
和 Cache-Control:no-cache
联用:
Cache-Control:no-cache
Pragme:no-cache
作为响应头,Pragma : no-cache
的行为并没有被定义,不能保证它的意义和 Cache-Control:no-cache
一致。
Expires
(响应头)表示缓存过期的时间点(这里的时间是服务器时间):
Expires: Wed, 14 Apr 2021 12:01:33 GMT
Pragma
、Expires
的局限:响应报文中 Expires
所定义的缓存时间是相对于服务器上的时间而言的,如果客户端上用户修改了自己设备上的系统时间,那缓存时间能就没有意义了。
Cache-Control
HTTP 1.1
新增了 Cache-Control
来配置缓存信息,主要包括:能否缓存、缓存过期时间、是否每次校验等。
Cache-Control
是通用头:
作为请求头,可选值有
字段名称 | 说明 |
---|---|
no-cache | 告知(代理)服务器不直接使用缓存,直接向原服务器发起请求 |
no-store | 所有内容都不会保存到缓存或 internet 临时文件中 |
max-age=delta-seconds | 告知服务器客户端希望接收一个存在时间(Age)不大于 delta-seconds 秒的资源 |
max-stale[=delta-seconds] | 告知(代理)服务器客户端愿意接收一个超过缓存时间的资源,若有定义 delta-seconds 则为 delta-seconds 秒,若没有则为任意超出时间 |
min-fresh=delta-seconds | 告知(代理)服务器客户端希望接收一个在小于 delta-seconds 秒内被更新过的资源 |
no-transform | 告知(代理)服务器客户端希望获取实体数据没有被转换(如压缩)过的资源 |
only-if-cached | 告知(代理)服务器客户端希望获取缓存的内容(若有),而不用向原服务器发去请求 |
cache-extension | 自定义扩展值,若服务器不识别该值将被忽略 |
作为响应头,可选值有
字段名称 | 说明 |
---|---|
public | 任何情况下都得缓存该资源 |
Private[=“field-name”] | 返回报文中全部或部分(若指定了 field-name 则为 field-name 的字段数据)仅开放给某些用户(服务器指定的 share-user,如代理服务器)做缓存使用,其他用户不能缓存这些数据 |
no-cache | 不直接使用缓存,要求向服务器发起(新鲜度校验)请求 |
no-store | 所有内容都不会被保存到缓存或 internet 临时文件中 |
no-transform | 告知服务器缓存文件时不得对实体数据做任何改变 |
only-if-cached | 告知(代理)服务器客户端希望获取缓存的内容(若有),而不用向原服务器发去请求 |
must-revalidate | 当前资源一定是向原服务器发去验证请求的,若请求失败会返回 504(而非代理服务器上的缓存) |
proxy-revalidate | 与 must-revalidate 类似,但仅能应用于共享缓存(如代理) |
max-age=delta-seconds | 告知客户端该资源在 delta-seconds 秒内是新鲜的,无需向服务器发请求 |
$-maxage=delta-seconds | 同 max-age,但仅应用于共享缓存(如代理) |
cache-extension | 自定义扩展值,若服务器不识别该值将被忽略掉 |
max-age=delta-seconds | 告知服务器客户端希望接收一个存在时间(age)不大于 delta-seconds 秒的资源 |
max-stale[=delta-seconds] | 告知服务器客户端愿意接收一个超过缓存时间的资源,若有定义 delta-seconds 则为 delta-seconds 秒,若没有则为任意超出时间 |
min-fresh=delta-seconds | 告知服务器客户端希望接收一个在小于 delta-seconds 秒内被更新过的资源 |
no-transform | 告知服务器客户端希望获取实体数据没有被转换(如压缩)过的资源 |
only-if-cached | 告知服务器客户端希望获取缓存的内容(若有),而不用向服务器发去请求 |
cache-extension | 自定义扩展值,若服务器不识别该值将被忽略 |
Cache-Control
允许自由组合可选值,用逗号分隔:
// 缓存过期时间是半小时,半小时后,每次都必须向服务器进行资源更新校验
Cache-Control: max-age=1800, no-cache
must-revalidate
坑点:
在苹果官方文档和流程图中有个判断,缓存存在,则需要判断是否需要每次都校验,用的是 >revalidated
,那我们是不是可以这样写:Cache-Control: max-age=3600, must->revalidate
,设置一个过期时间,但是又希望每次去检验更新。结果是客户端仍然是用的缓存,根本没有网络请求发出去。
HTTP
规范是不允许客户端使用过期缓存的,除了一些特殊情况,比如校验请求发送失败的时候。而 must-revalidate
指令是用来排除这些特殊情况的。带有 must-revalidate
的缓存过期后,在任何情况下,都必须成功 revalidate
后才能使用,没有例外,即使校验请求发送失败也不可以使用过期的缓存。也就是说,有个大前提是缓存过期了,如果缓存没过期客户端会直接使用缓存,并不会发起校验,显然不是字面上每次都校验更新的意思。must-revalidate
命名为 never-return-stale
更合理。而真正每次都校验更新,应该用 no-cache
这个字段。
所以上面错误的写法改成 Cache-Control: max-age=1800, no-cache
这样就可以了,缓存有效期半小时,每次请求都校验更新。
no-cache
作为请求头,告知中间服务器不使用缓存,向源服务器发起请求。
作为响应头,no-cache
并不是字面上的不缓存,而是每次使用前都得先校验一下资源更新。
no-store
作为响应头,带有 no-store
的响应不会被缓存到任意的磁盘或者内存里,no-store
它才是真正的 no-cache
。
max-age
作为请求头,max-age=0
表示不管 response
怎么设置,在重新获取资源之前,先进行资源更新校验。
作为响应头,max-age=x
表示,缓存有效期是 x
秒。
Cache-Control 的局限
很多时候,缓存过期了但是资源并没有修改,会发送多余的请求和数据;或者资源修改了缓存还没过期,客户端仍然在用缓存。Cache-Control
无法及时和客户端同步。
Last-Modified、If-Modified-Since
为了弥补 Cache-Control
无法及时判断资源是否有更新的不足,有了 Last-Modified
、if-Modified-Since
字段。
Last-Modified
响应头,这次命名没有问题了,这个字段的值就是资源在服务器上最后修改时刻。如:
If-Modified-Since: Wed, 14 Apr 2021 14:01:33 GMT
If-Modified-Since
请求头,客户端通过该字段把 Last-Modified
的值回传给服务端;客户端带上这个字段表示这次请求是向服务端做校验资源更新校验。如果资源没有修改,则服务端返回 304
不返回数据,客户端用缓存;资源有修改则返回 200
和数据。如:
If-Modified-Since: Wed, 14 Apr 2021 14:05:33 GMT
Last-Modified 的启发式(heuristic)缓存
有以下响应信息:
HTTP/2 200
Date: Wed, 14 Apr 2021 14:30:00 GMT
Last-Modified: Wed, 14 Apr 2021 14:10:00 GMT
上面这个响应,没有显示地指明需要缓存,没有 Cache-Control
,也没有 Expires
,只有 Last-Modified
修改时间,这种情况会产生启发式缓存。缓存时长= (date_value - last_modified_value) * 0.10
,这是由 HTTP
规范推荐的算法,但规范中仅仅是推荐而已,并没有做强制要求。
如何禁用由 Last-Modified
响应头造成的启发式缓存:正确的做法是在响应头中加上 Cache-Control: no-cache
。
Last-Modified、If-Modified-Since 的缺陷
无法识别内容是否发生实质性的变化,可能只是修改了文件但是内容没有变化;无法识别一秒内进行多次修改的情况。
ETag、If-None-Match
为了弥补 Last-Modified
的无法判断内容实质性变化的缺陷,于是有了 ETag
和 If-None-Match
字段,这对字段的用法和 Last-Modified
、If-Modified-Since
相似,服务器在响应头中返回 ETag
字段,客户端在下次请求时在 If-None-Match
中回传 ETag
对应的值。
ETag
响应头,给资源计算得出一个唯一标志符(比如 md5
标志),加在响应头里一起返给客户端,如:
Etag: "7d6e49djhkd84rh3"
If-None-Match
请求头,客户端在下次请求时回传 ETag
值给服务器。
If-None-Match: "7d6e49djhkd84rh3"
优先级
Pragma > Cache-Control > Expires > Last-Modified > ETag
响应头没有任何缓存字段,每次启动都会发起请求,返回 200
。
第一次启动,响应头添加 Pragma:no-cache
和 Cache-Control:max-age
;第二次启动,会发起请求,返回 304
,说明 Pragma
生效了,Pragma
> Cache-Control
。
第一次启动,响应头没有过期时间,只有 Last-Modified
;第二次启动,使用缓存,没有发起请求,说明启发式缓存(上文中有提到)生效。
第一次启动,响应头没有过期时间,只有 ETag
;第二次启动,会发起请求,返回 304
,说明做了资源更新校验。
第一次启动,响应头没有过期时间,同时有 ETag
和 Last-Modified
;第二次启动,使用缓存,没有发起请求,启发式缓存生效,说明 Last-Modified
> ETag
。
WKWebView 默认缓存策略
针对上文中提到的几个问题
1. 什么情况下会缓存数据?
客户端第一次启动的时候,如果 响应头
中不包含任何缓存控制字段(Expires
、Cache-Control:max-age
、Last-Modified
等),那么不会缓存(仍然可能会有物理缓存,只是不使用),下次直接发起请求。如果响应头包含了缓存控制字段,大多数情况下这次数据会被缓存,下次启动的时候执行缓存逻辑判断。
2. 什么情况下每次都需要校验资源更新?
- 响应头中包含
Cache-Control:no-cache
或Pragma:no-cache
。 - 响应头中缓存控制字段只有
ETag
字段,没有过期时间和修改时间。
3. 缓存过期时间是多久?
- 响应头中
Cache-Control:max-age=1800
,表示缓存半小时。 - 响应头中
Expires
的值表示过期时刻(服务器时间)。 - 响应头中,如果没有上述两个字段,但有
Last-Modified
字段,则触发启发式缓存,缓存时间 =(date_value - last_modified_value) * 0.1
。
4. 校验资源更新的过程是怎么样的?
revalidated
的指标有两个:Last-Modified
(最后修改时刻)、ETag
(资源唯一标识)。服务器返回数据时会在响应头中返回上面两个指标(有可能只有1个,也可以2个都有),客户端再次发起请求时会把这两个指标回传给服务器。If-Modified-Since
(Last-Modified
的值),If-None-Match
(ETag
的值)。服务器进行比对,如果客户端的资源是最新的,则返回 304
,客户端使用缓存数据;如果服务器资源更新了,则返回 200
和新数据。
总结
WKWebView
默认缓存策略流程总结如下:
- 是否有缓存,没有则直接发起请求。有则进行下一步。
- 是否每次都得进行资源更新校验(响应头是否有
Cache-Control:no-cache
或Pragma:no-cache
字段),不需要则进入3
,需要则进入4
。 - 缓存是否过期(响应头,
Cache-Control:max-age
、Expires
、Last-Modified
启发式缓存),没过期则使用缓存,不发起请求。过期了则进入4
。 - 客户端发起资源更新校验请求(请求头,
If-Modified-Since
:Last-Modified值
、If-None-Match
:ETag值
),如果资源没有更新,服务器返回304
,客户端使用缓存;如果资源有更新,服务器返回200
和资源。
服务器数据更新后客户端仍然有缓存
如果我们使用了 WKWebView
默认缓存策略(即 NSURLRequestUseProtocolCachePolicy
),这个时候 App
什么也做不了,需要后台去处理。
方案一:
添加响应头
如果使用了 Cache-Contol: max-age=xxx
而且没有配置资源更新校验字段。但是这个 xxx
设置的很大,缓存时间很长,即使服务器资源更新了客户端也不会请求新的资源,而是直接使用“没有过期”的资源。所以可以将这个字段设置的小一些外加配置资源更新校验字段。
我们也可以设置每次都会先去校验资源更新,就是默认不走缓存,这样也解决了服务器更新数据,客户端仍然不更新的问题。
Cache-Control:no-cache
方案二
这个方案没有亲自体验过,参考自WKWebView默认缓存策略与HTTP缓存协议
链接加后缀
<script src="test.js?ver=113"></script>
https://hm.baidu.com/hm.js?e23800c454aa573c0ccb16b52665ac26
http://tb1.bdstatic.com/tb/_/tbean_safe_ajax_94e7ca2.js
http://img1.gtimg.com/ninja/2/2016/04/ninja145972803357449.jpg
可以在资源文件后面加上版本号,每次更新资源的时候变更版本号;还可以在 URL
后面加上了 md5
参数,甚至还可以将 md5
值作为文件名的一部分。
采用上述方法,你可以把缓存时间设置的特别长,那么在文件没有变动的时候,浏览器直接使用缓存文件;而在文件有变化的时候,由于文件版本号的变更,或 md5
变化导致文件名变化,请求的 url
变了,浏览器会当做新的资源去处理,一定会发起请求,所以不存在更新后仍然有缓存的情况。通过这样的处理,增长了静态资源,特别是图片资源的缓存时间,避免该资源很快过期,客户端频繁向服务端发起资源请求,服务器再返回 304
响应的情况(有 Last-Modified/Etag
)。
参考代码
NSURLRequest
的默认缓存策略是 NSURLRequestUseProtocolCachePolicy
,完全遵循 HTTP
缓存协议。
我们来看以下代码
NSString *url = @"https://xxx";
NSURLRequest *request = [[NSURLRequest alloc] initWithURL:[NSURL URLWithString:url]];
[[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
NSLog(@"httpResponse == %@", httpResponse);
}] resume];
运行项目,打印结果仅供参考
在这个链接的响应头 Cache-Control: max-age=30
,过期时间为 30 秒,但是无论我运行多少次,code
始终返回 200(网段了还是返回数据,code
也是 200)。而且还发现,第一次请求时界面刷新耗费的时间较长,第二次直接显示界面了。这个也验证了默认缓存是有的,但是为什么 code
一直是 200 呢?
苹果系统内部对 304 Not Modified 响应做了特殊处理
-
code
字段,固定返回 200 -
data
字段,因为服务端返回的304
报文是不带data
数据字段的,但是苹果又得把data
通过completionHandler
回调给客户端,苹果会去缓存中取data
数据,返回的data
字段和第一次响应的data
是同一个。 -
response
字段,返回的是第二次请求304
的响应头,而不是第一次请求缓存的响应头。第一次和第二次回调的响应头不一致。
综上,苹果内部帮我们处理了 304 Not Modified
响应。对客户端来说,你只需要知道返回 200
就是没有异常,拿着 data
用就行了。至于,数据来自缓存还是来自服务器,缓存有没有过期,需不需要校验资源更新等,都交给苹果吧。
code 都返回 200,那我怎么知道返回的是缓存数据还是服务器数据呢?
我们可以通过以下测试代码来验证下(注意:这是系统自己实现的,并不需要客户端手动添加)
NSString *url = @"https://xxx";
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url] cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:20];
NSDictionary *cachedHeaders = [[NSUserDefaults standardUserDefaults] objectForKey:url];
//设置request headers (带上上次的请求头下面两参数一种就可以,也可以两个都带上)
if (cachedHeaders) {
NSString *etag = [cachedHeaders objectForKey:@"Etag"];
if (etag) {
[request setValue:etag forHTTPHeaderField:@"If-None-Match"];
}
NSString *lastModified = [cachedHeaders objectForKey:@"Last-Modified"];
if (lastModified) {
[request setValue:lastModified forHTTPHeaderField:@"If-Modified-Since"];
}
}
[self.couponWebView loadRequest:request];
[[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
NSLog(@"httpResponse == %@", httpResponse);
// 根据statusCode设置缓存策略
if (httpResponse.statusCode == 304 || httpResponse.statusCode == 0) {
[request setCachePolicy:NSURLRequestReturnCacheDataElseLoad];
}
else {
[request setCachePolicy:NSURLRequestReloadIgnoringLocalCacheData];
// 保存当前的 NSHTTPURLResponse
[[NSUserDefaults standardUserDefaults] setObject:httpResponse.allHeaderFields forKey:url];
[[NSUserDefaults standardUserDefaults] synchronize];
}
// 重新刷新
dispatch_async(dispatch_get_main_queue(), ^{
[self.couponWebView reload];
});
}] resume];
第一次打印如下:
第二次打印如下:
总结
对于使用 WKWebView
加载的页面,使用默认的缓存策略,客户端不需要关心返回的数据是否来自缓存,只需要缓存字段配置好后即可。