基于NSURLCache的缓存实现
概览
缓存设计应该是每个客户端程序开发所必须考虑的问题,如果同一个功能需要多次访问,而每次访问都重新请求的话势必降低用户体验。但是如何处理客户端缓存貌似并没有统一的解决方案,多数开发者选择自行创建数据库直接将服务器端请求的JSON(或Model)缓存起来,下次请求则查询数据库检查缓存是否存在。事实上iOS系统自身也提供了一套缓存机制,本文将结合URL Loading System简单介绍一下如何利用系统自身缓存设计来实现一套缓存机制来平滑的扩展图虫客户端的缓存处理。
URL Loading System
URL Loading System是类和协议的集合,使用URL Loading System iOS系统和服务器端进行网络交互。URL作为其中的核心,能够让app和资源进行轻松的交互。为了增强URL的功能Foundation提供了丰富的类集合,能够让你根据地址加载资源、上传资源到服务器、管理cookie、控制响应缓存(这也是本文重点)、处理证书和认证、扩展用户协议等,因此了解URL缓存之前熟悉URL Loading System是必要的。下图是这一系列集合的关系:
imageNSURLProtocol
URL Loading System默认支持http、https、ftp、file和data协议,但是它同样也支持注册自己的类来支持更多应用层网络协议。具体而言NSURLProtocl可以实现以下需求(包含但不限):
-
重定向网络请求(或进行域名转化、拦截等,例如:netfox)
-
忽略某些请求,使用本地缓存数据
-
自定义网络请求的返回结果 (比如:GYHttpMocking)
-
进行网络全局配置
NSURLProtocol类似中间人设计,将网络求细节提供给开发者,而又以一种优雅的方式暴漏出来。NSURLProtocol的定义更像是一个URL协议,尽管它继承自NSObject却不能直接使用,使用时需要自定义一个协议继承NSURLProto
解决DNS劫持
随着互联网的发展,运营商劫持这些年逐渐被大家所提及,常见的劫持包括HTTP劫持和DNS劫持。对于HTTP劫持更多的是篡改网络响应加入一些脚本广告之类的内容,解决这个问题只要使用https加密请求交互内容;而对于DNS劫持则更加可恶,在DNS解析时让请求重新定向到一个非预期IP从而达到内容篡改。
解决DNS劫持普遍的做法就是将URL从域名替换成IP,这么一来访问内容并不经过运营商的Local DNS到达指定的服务器,因此也就避免了DNS劫持问题。当然,域名和IP的对应通常通过服务器下发保证获取最近的资源节点(当然也可以采用一些收费的HTTPDNS服务),不过这样一来操作却不得不依赖于具体请求,而使用自定义NSURLProtocol的方式则可以彻底解决具体依赖问题,不管是使用NSURLConnection、NSURLSession还是UIWebView(WKWebView有所不同),所有的替换操作都可以统一进行控制。
值得注意的是使用URLSession进行网络请求时如果使用的不是默认会话(URLSession.shared)需要在URLSessionConfiguration中指定protocolClasses,这样自定义URLProtocol才能进行处理。 在MyURLProtocol的startLoading方法内同样发起了URL请求,如果此时使用了URLSession.shared进行网络请求则同样会造成MyURLProtocol调用,如此会引起循环调用。考虑到startLoading方法能可能是NSURLConnnection实现,安全起见在MyURLProtocol内部使用URLProtocol.setProperty(true, forKey: MyCacheURLProtocolTagKey, in: newRequest)来标记一个请求,调用前使用URLProtocol.property(forKey: MyCacheURLProtocolTagKey, in: request)判断当前请求是否已经标记,如果已经标记则视为同一请求不再处理,从而避免同一个请求循环调用。
NSURLProtocol缓存
无论是NSURLConnection、NSURLSession还是UIWebView、WKWebView默认都是有缓存设计的(使用NSURLCache),不过这要配合服务器端response header使用,对于有缓存的页面(或者API接口),当缓存过期后,默认情况下(NSURLRequestUseProtocolCachePolicy)遇到同一个请求通常会发出一个header中包含If-Modified-Since的请求到服务器端验证,如果内容没有过期则返回一个不含有body的响应(Response code为304),客户端使用缓存数据,否则重新返回新的数据。
由于WKWebView默认有一段时间的缓存,在第一次缓存响应后过一段时间才会进行缓存请求检查(缓存过期后才会发送包含If-Modified-Since的请求检查)。不过它做不到完全的离线阅读(尽管在一定时间内不需要检查),而且无法做到缓存细节的控制。
下面简单利用NSURLProtocol来实现WKWebView的离线缓存功能,不过注意WKWebView默认仅仅调用NSURLProtocol的canInitWithRequest:方法,如果要真正利用NSURLProtocol进行缓存还必须使用WKBrowsingContextController的registerSchemeForCustomProtocol进行注册,但它是私有对象,需要动态设置。下面的demo中简单实现了WKWebView的离线缓存功能,这样有遇到访问过的资源即使没有网络也同样可以访问(当然,示例主要用以说明缓存的原理,实际开发中还有很多问题需要思考,比如说缓存过期机制、磁盘缓存保存方式等)。
NSURLCache
事实上,无论是NSURLConnection、URLSession还是UIWebView、WKWebView默认都是会使用缓存的(注意WKWebView的缓存配置是从iOS 9.0开始提供的,但是iOS 8.0中也同样包含缓存设计,不过没有提供缓存配置接口)。而NSURLConnection、NSURLSession和UIWebView默认都会使用NSURLCache,所有经过他们请求的数据都将被NSURLCache处理。NSURLCache不仅提供了内存和磁盘缓存方式,还有完善的缓存策略可配置。比如使用NRURLSession进行网络请求,就可以通过URLSessionConfiguration指定独立的URLCache(如果设置为nil则不再使用缓存缓存策略),通过URLSessionConfiguration的requestCachePolicy属性指定具体的缓存策略。
缓存策略CachePolicy
-
useProtocolCachePolicy:默认缓存策略,对于特定URL使用网络协议中实现的缓存策略。
-
reloadIgnoringLocalCacheData(或者reloadIgnoringCacheData):不使用缓存,直接请求原始数据。
-
returnCacheDataElseLoad:无论缓存是否过期,有缓存则使用缓存,否则重新请求原始数据。
-
returnCacheDataDontLoad:无论缓存是否过期,有缓存则使用缓存,否则视为失败,不会重新请求原始数据。
其实对于多数开发者而言默认缓存策略才是我们最关心的,这就有必要弄清HTTP的请求和响应是如何使用headers来进行元数据交换的(无论是NSURLConnection还是NSURLSession都支持多种协议,这里重点关注HTTP、HTTPS)。
请求头信息 Request cache headers
-
If-Modified-Since:与响应头Last-Modified相对应,其值为最后一次响应头中的Last-Modified。
-
If-None-Match:与响应头Etag相对应,其值为最后一次响应头中的Etag
响应头信息 Response cache headers
-
Last-Modified:资源最近修改时间
-
Etag:(Entity tag缩写)是请求资源的标识符,主要用于动态生成、没有Last-Modified值的资源。
-
Cache-Control:缓存控制,只有包含此设置可能使用默认缓存策略。可能包含如下选项: max-age:缓存时间(单位:秒)。 public:可以被任何区缓存,包括中间经过的代理服务器也可以缓存。通常不会被使用,因为 max-age本身已经表示此响应可以缓存。 private:只能被当前客户端缓存,中间代理无法进行缓存。 no-cache:必须与服务器端确认响应是否发生了变化,如果没有变化则可以使用缓存,否则使用新请求的响应。no-store:禁止使用缓存
-
Vary: 决定请求是否可以使用缓存,通常作为缓存key值是否唯的确定因素,同一个资源不同的Vary设置会被作为两个缓存资源(注意:NSURLCache会忽略Vary请求缓存)。
注意:Expires是HTTP 1.0标准缓存控制,不建议使用,请使用Cache-Control:max-age代替,类似的还有Pragma:no-cache和Cache-Control:no-cache。此外,Request cache headers中也是可以包含Cache-Control的,例如如果设置为no-cache则说明此次请求不要使用缓存数据作为响应。
默认缓存策略下当客户端发起一个请求时首先会检查本地是否包含缓存,如果有缓存则检查缓存是否过期(通过Cache-Control:max-age或者Expires判断),如果没有过期则直接使用缓存数据。如果缓存过期了,则发起一个请求给服务器端,此时服务器端对比资源Last-Modified或者Etags(二者都存在的情况下下如果有一个不同则认为缓存已过期),如果不同则返回新数据,否则返回304 Not Modified继续使用缓存数据(客户端可以继续使用"max-age"秒缓存数据)。这个过程中客户端发送不发送请求主要看max-age是否过期,而过期后是否继续使用缓存则需要重新发起请求,服务器端根据情况通知客户端是否可以继续使用缓存(返回结果可能是200或者304)。
清楚了默认网络协议缓存相关的设置之后要使用默认缓存就比较简单了,通常对于NSURLSession你不做任何设置,只要服务器端响应头部加上Cache-Control:max-age:xxx就可以使用缓存了。下面Demo中演示了如何使用NSURLSession通过max-age进行为期60s的缓存,运行会发现在第一次请求之后60s内不会进行再次请求,60s后才会发起第二次请求。
image服务器端default-cache.php内容如下:
image对应的请求和响应头信息如下(服务器端设置缓存60s):
image当然,配合服务器端使用缓存是一种不错的方案,自然官方设计时也是希望尽可能使用默认缓存策略。但很多时候服务器端出于其他原因考虑,或者说或客户端需要自定义缓存策略时还是有必要进行手动缓存管理的。比如说如果服务器端根本没有设置缓存过期时间或者服务器端根本无法获知用户何时清理缓存、何时使用缓存这些具体逻辑等都需要客户端自行制定缓存策略。
对于NSURLConnnection而言可以通过- (NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse
进行二次缓存设置,如果此方法返回nil则不进行缓存,默认不实现这个代理则会走默认缓存策略。而URLSessionDataDelegate也有一个类似的方法func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Swift.Void)
,使用和NSURLConnection是类似的,不同的是dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Swift.Void)
等一系列带有completionHandler回调的方法并不会走代理方法,所以这种情况下func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Swift.Void)
也是无法使用的。
无论URLSession走缓存相关的代理,还是通过completionHandler进行回调,默认都会使用NSURLCache进行缓存。例如下面Demo3中的示例2、3都打印出了默认的缓存信息,不过如果服务器端不进行缓存设置的话(header中设置Cache-Control),默认情况下NSURLSession是不会使用缓存数据的。如果将缓存策略设置为优先考虑缓存使用(例如使用:.returnCacheDataElseLoad),则可以看到下次请求不会再发送请求,Demo3中的示例4演示了这一情况。不过一旦如此设置之后以后想要更新缓存就变得艰难了,因为只要不清空缓存或超过缓存限制,缓存数据就一直存在,而且在应用中随时换切换缓存策略成本也并不低。因此,要合理利用系统默认缓存的出发点还是应该着眼在默认的基于网络协议的缓存设置上。
不过这样一来缓存的控制逻辑就上升为解决缓存问题的重点,比如说一个API接口设计多数情况下可以缓存,但是一旦用户修改了部分信息则希望及时更新使用最新数据,但是缓存不过期服务器端即使很了解客户端设计也无法做到强制更新缓存,因此客户端就不得不自行控制缓存。那么能不能强制NSURLCache使用网络协议缓存策略呢,其实也是可以的,对于服务器端没有添加cache headers控制的响应只需要添加上相应的缓存控制即可。
缓存设计
从前面对于URL Loading System的分析可以看出利用NSURLProtocol或者NSURLCache都可以做客户端缓存,但NSURLProtocol更多的用于拦截处理。选择URLSession配合NSURLCache的话,则对于接口调用方有更多灵活的控制,而且默认情况下NSURLCache就有缓存,我们只要操作缓存响应的Cache headers即可,因此后者作为我们优先考虑的缓存方案。鉴于图虫客户端使用Alamofire作为网络库,因此下面结合Alamofire实现一种相对简单的缓存方案。
根据前面的思路,最早还是想从URLSessionDataDelegate的缓存设置方法入手,而且Alamofire确实对于每个URLSessionDataTask都留有缓存代理方法的回调入口,但查看源码发现这个入口dataTaskWillCacheResponse并未对外开发,而如果直接在SessionDelegate的回调入口dataTaskWillCacheResponseWithCompletion上进行回调又无法控制每个请求的缓存情况。当然如果沿着这个思路可以再扩展一个DataTaskDelegate对象以暴漏缓存入口,但是这么一来必须实现URLSessionDataDelegate,而且要想办法Swizzle NSURLSession的缓存代理(或者继承SessionDelegate切换代理),在代理中根据不同的NSURLDataTask进行缓存处理,整个过程对于调用方并不是太友好。
另一个思路就是等Response请求结束后获取缓存的响应CachedURLResponse并且修改(事实上只要是同一个NSURLRequest存储进去默认会更新原有缓存),而且NSURLCache本身就是有内存缓存的,过程并不会太耗时。这个方案最重要的是得保证响应已经处理完成,所以这里通过Alamofire链式调用使用response(queue: queue, responseSerializer: responseSerializer, completionHandler: completionHandler重新请求以保证及时掌握回调时机。主要的代码片段如下:
image要完成整个缓存处理自然还包括缓存刷新、缓存清理等操作,关于缓存清理本身NSURLCache是提供了remove方法的,不过缓存清理的调用并不会立即生效,具体参见NSURLCache does not clear stored responses in iOS8。因此,这里借助了上面提到的Cache-Control进行缓存过期控制,一方面可以快速清理缓存,另一方面缓存控制可以更加精准。
AlamofireURLCache
为了更好的配合Alamofire使用,此代码以AlamofireURLCache类库形式放到了github上,所有接口API尽量和原有接口保持一致,便于对Alamofire二次封装。此外代码中还提供了手动清理缓存、出错之后自动清理缓存、覆盖服务器端缓存配置等功能。
AlamofireURLCache在request方法添加了refreshCache参数用于缓存刷新,设为false或者不提供此参数则不会刷新缓存,只有等到上次缓存数据过了有效期才会再次发起请求。
服务器端缓存headers设置并不都是最优选择,某些情况下客户端必须自行控制缓存策略,可以使用AlamofireURLCache的ignoreServer参数忽略服务器端配置,通过maxAge参数自行控制缓存时长。
另外,有些情况下未必需要刷新缓存而是要清空缓存保证下次访问时再使用最新数据,可以使用AlamofireURLCache提供的缓存清理API来完成。不过对于请求出错、序列化出错等情况如果调用了cache(maxAge)方法进行缓存后,那么下次请求会使用错误的缓存数据,需要开发者根据返回情况自行调用API清理缓存,因此在AlamofireURLCache中提供了autoClearCache参数来自动处理这种情况。