iOS架构设计-URL缓存

概览

缓存组件应该说是每个客户端程序必备的核心组件,试想对于每个界面的访问都必须重新请求势必降低用户体验。但是如何处理客户端缓存貌似并没有统一的解决方案,多数开发者选择自行创建数据库直接将服务器端请求的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是必要的。下图一系列集合的关系:

URL_Loading_System

本文代码一律使用Swift编写,但是鉴于很多朋友接触URL Loading System都是从Objective-C开始,所以文章中文字部分还是采用OC命名,其区别不大,主要是少了NS前缀。

NSURLProtocol

URL Loading System默认支持http、https、ftp、file和data 协议,但是它同样也支持你注册自己的类来支持更多应用层网络协议,当然你也可以指定其他属性到URL reqeust和URL response上。具体而言NSURLProtocl可以实现以下需求(包含但不限):

  • 重定向网络请求(或进行域名转化、拦截等,例如:netfox
  • 忽略某些请求,使用本地缓存数据
  • 自定义网络请求的返回结果 (比如:GYHttpMocking
  • 进行网络全局配置

NSURLProtocol类似中间人设计,将网络求细节提供给开发者,而又以一种优雅的方式暴漏出来。NSURLProtocol的定义更像是一个URL协议,尽管它继承自NSObject却不能直接使用,使用时自定义协议继承NSURLProtocol,然后在app启动时注册即可,这样一来所有请求细节开发者只需要在自己的类中控制即可(这个设计确实完美👍)。

解决DNS劫持

随着互联网的发展,运营商劫持这些年逐渐被大家所提及,常见的劫持包括HTTP劫持和DNS劫持。对于HTTP劫持更多的是篡改网络响应加入一些脚本广告之类的内容,解决这个问题只需要使用https加密请求交互内容;而对于DNS劫持则更加可恶,在DNS解析时让请求重新定向到一个非预期IP从而达到内容篡改。

解决DNS劫持普遍的做法就是将URL从域名替换成IP,这么一来访问内容并不经过运营商的Local DNS而到达指定的服务器,因此也就避免了DNS劫持问题。当然,域名和IP的对应要通常通过服务器下发保证获取最近的资源节点(当然也可以采用一些收费的HTTPDNS服务),不过这样一来操作却不得不依赖于具体请求,而使用自定义NSURLProtocol的方式则可以彻底解决具体依赖问题,不管是使用NSURLConnection、NSURLSession、AFNetworking还是UIWebView(注意WKWebView有所不同),所有的替换操作都可以统一进行控制。

下面的demo中自定义协议MyURLProtocol实现了将域名转化成IP进行请求的过程:

import UIKit

class MyURLProtocol: URLProtocol{
    // MARK: - URLProtocol虚方法实现
    // 是否处理对应的请求
    override class func canInit(with request: URLRequest) -> Bool {
        if URLProtocol.property(forKey: MyURLProtocol.PropertyKey.tagKey, in: request) != nil {
            return false
        }
        return true
    }

    // 返回请求,在此方法中可以修改请求
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        var newRequest = request
        // 例如这里域名为指定ip,实际开发中应该从服务器下方domain list
        let originHost = request.url?.host
        if "baidu.com" == originHost {
            let originURL = request.url?.absoluteString
            let newURL = originURL?.replacingOccurrences(of: originHost!, with: "61.135.169.121")
            newRequest.url = URL(string: newURL!)
        }
        return newRequest
    }
    // 开始加载
    override func startLoading() {
        guard let newRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else { return}
        URLProtocol.setProperty(true, forKey: MyURLProtocol.PropertyKey.tagKey, in: newRequest)
        let sessionConfig = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
        self.dataTask = urlSession.dataTask(with: self.request)
        self.dataTask?.resume()
    }
    
    // 停止加载
    override func stopLoading() {
        self.dataTask?.cancel()
        self.dataTask       = nil
        self.receivedData   = nil
        self.urlResponse    = nil
    }
    
    // 判断两个请求是否相等,相等则考虑使用缓存,此方法不是必须实现
    override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool {
        return super.requestIsCacheEquivalent(a, to: b)
    }
    

    // MARK: - 私有属性
    private struct MyURLProtocolKey {
        var tagKey = "MyURLProtocolTagKey"
    }
    
    fileprivate var dataTask: URLSessionDataTask?
    fileprivate var urlResponse: URLResponse?
    fileprivate var receivedData: NSMutableData?
    
}

extension MyURLProtocol {
    struct PropertyKey{
        static var tagKey = "MyURLProtocolTagKey"
    }
}

// 注意实际开发中应该尽可能处理所有self.client?.urlProtocol回传方法,以免客户端有些方法无法做出响应
extension MyURLProtocol:URLSessionTaskDelegate,URLSessionDataDelegate {
    // MARK: - URLSessionDataDelegate方法
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        
        self.urlResponse = response
        self.receivedData = NSMutableData()
        
        completionHandler(.allow)
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        self.client?.urlProtocol(self, didLoad: data as Data)
        
        self.receivedData?.append(data as Data)
    }
    
    // URLSessionTaskDelegate
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if error != nil {
            self.client?.urlProtocol(self, didFailWithError: error!)
        } else {
            //saveCachedResponse()
            self.client?.urlProtocolDidFinishLoading(self)
        }
    }
}

注意使用URLSession进行网络请求时如果使用的不是默认会话(URLSession.shared)则需要在URLSessionConfiguration中指定protocolClasses自定义URLProtocol才能进行处理(即使使用URLProtocol.registerClass进行注册),URLSession.shared默认则可以响应已注册URLProtocol。
在MyURLProtocol的startLoading方法内同样发起了URL请求,如果此时使用了URLSession.shared进行网络请求则同样会造成MyURLProtocol调用,如此会引起循环调用。考虑到startLoading方法能可能是NSURLConnnection实现,安全起见在MyURLProtocol内部使用URLProtocol.setProperty(true, forKey: MyCacheURLProtocolTagKey, in: newRequest)来标记一个请求,调用前使用URLProtocol.property(forKey: MyCacheURLProtocolTagKey, in: request)判断当前请求是否已经标记,如果已经标记则视为同一请求,MyURLProtocol则不再处理,从而避免同一个请求循环调用。

如果你的网络请求使用的NSURLConnection,上面的代码需要做相应修改,但相信现在NSURLConnection使用应该越来越少了,很多第三方网络库也不支持了。

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的离线缓存功能,有了它之后遇到访问过的资源即使没有网络也同样可以访问。当然,示例主要用以说明缓存的原理,实际开发中还有很多问题需要思考,比如说缓存过期机制、磁盘缓存保存方式等等。

import UIKit

class MyCacheURLProtocol: URLProtocol{
    
    // MARK: - URLProtocol虚方法实现
    // 是否处理对应的请求
    override class func canInit(with request: URLRequest) -> Bool {
        if URLProtocol.property(forKey: MyCacheURLProtocol.PropertyKey.tagKey, in: request) != nil {
            return false
        }
        return true
    }
    
    // 返回请求,在此方法中可以修改请求
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    // 开始加载
    override func startLoading() {
        func sendRequest() {
            guard let newRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else { return}
            URLProtocol.setProperty(true, forKey: MyCacheURLProtocol.PropertyKey.tagKey, in: newRequest)
            let sessionConfig = URLSessionConfiguration.default
            let urlSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
            self.dataTask = urlSession.dataTask(with: self.request)
            self.dataTask?.resume()
        }
        
        if let cacheResponse = self.getResponse() {
            self.client?.urlProtocol(self, didReceive: cacheResponse.response, cacheStoragePolicy: .notAllowed)
            self.client?.urlProtocol(self, didLoad: cacheResponse.data)
            self.client?.urlProtocolDidFinishLoading(self)
        } else {
            sendRequest()
        }
        
    }
    
    // 停止加载
    override func stopLoading() {
        self.dataTask?.cancel()
        self.dataTask       = nil
        self.receivedData   = nil
        self.urlResponse    = nil
    }
    
    // 判断两个请求是否相等,相等则考虑使用缓存,此方法不是必须实现
    override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool {
        return super.requestIsCacheEquivalent(a, to: b)
    }
    
    
    // MARK: - 私有方法
    fileprivate func saveResponse(_ response:URLResponse,_ data:Data) {
        if let key = self.request.url?.absoluteString {
            let tempDic = NSTemporaryDirectory() as NSString
            let filePath = tempDic.appendingPathComponent(key.md5())
            
            let cacheResponse = CachedURLResponse(response: response, data: data, userInfo: nil, storagePolicy: URLCache.StoragePolicy.notAllowed)
            NSKeyedArchiver.archiveRootObject(cacheResponse, toFile: filePath)
        }
    }
    
    fileprivate func getResponse() -> CachedURLResponse? {
        if let key = self.request.url?.absoluteString {
            let tempDic = NSTemporaryDirectory() as NSString
            let filePath = tempDic.appendingPathComponent(key.md5())
            if FileManager.default.fileExists(atPath: filePath) {
                return NSKeyedUnarchiver.unarchiveObject(withFile: filePath) as? CachedURLResponse
            }
            return nil
        }
        return nil
    }
    
    // MARK: - 私有属性
    fileprivate var dataTask: URLSessionDataTask?
    fileprivate var urlResponse: URLResponse?
    fileprivate var receivedData: NSMutableData?
    
}

extension MyCacheURLProtocol {
    struct PropertyKey{
        static var tagKey = "MyURLProtocolTagKey"
    }
}

// 注意实际开发中应该尽可能处理所有self.client?.urlProtocol回传方法,以免客户端有些方法无法做出响应
extension MyCacheURLProtocol:URLSessionTaskDelegate,URLSessionDataDelegate {
    // MARK: - URLSessionDataDelegate方法
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        
        self.urlResponse = response
        self.receivedData = NSMutableData()
        
        completionHandler(.allow)
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        self.client?.urlProtocol(self, didLoad: data as Data)
        
        self.receivedData?.append(data as Data)
    }
    
    // URLSessionTaskDelegate
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if error != nil {
            self.client?.urlProtocol(self, didFailWithError: error!)
        } else {
            self.client?.urlProtocolDidFinishLoading(self)
            //save cache
            if self.urlResponse != nil && self.receivedData != nil {
                self.saveResponse(self.urlResponse!, self.receivedData?.copy() as! Data)
            }
        }
    }
    
}

NSURLCache

事实上无论是NSURLConnection、URLSession还是UIWebView、WKWebView默认都是包含缓存的(注意WKWebView的缓存配置是从iOS 9.0开始提供的,但是其实iOS 8.0中也同样包含缓存设计,只是没有提供缓存配置接口)。对于多数开发者而言缓存设计考虑更多的是磁盘缓存(如果需要做内存缓存建议使用NSCache,提供了缓存过高自动移除功能 or YYCache),而磁盘缓存设计大致可以分为API访问返回的JSON缓存(通过NSURLConnection或者NSURLSession请求标准的JSON数据)和客户端web页面缓存(UIWebView、WKWebView)。

NSURLConnection和UIWebView来说,默认都会使用NSURLCache,通常在应用启动中会进行NSURLCache配置,当然即使不进行配置也是有默认配置的。但二者并不是今天介绍的重点,我们重点关注NSURLSession和WKWebView。对于NSURLSession而言默认仍然会使用全局的NSURLCache(可以在启动时自己初始化,例如URLCache.shared = URLCache(memoryCapacity: 5*1024*1024, diskCapacity: 20*1024*1024, diskPath: nil)),但是相比于默认NSURLConnection而言NSURLSession更加灵活,因为每个URLSessionConfiguration都可以指定独立的URLCache,默认情况下是使用一个私有内存缓存,如果设置为nil则不再使用缓存。而且还可以通过URLSessionConfiguration的requestCachePolicy属性指定缓存策略。

缓存策略CachePolicy

  • useProtocolCachePolicy:默认缓存策略,对于特定URL使用网络协议中实现的缓存策略。
  • reloadIgnoringLocalCacheData(或者reloadIgnoringCacheData):不使用缓存,直接请求原始数据。
  • returnCacheDataElseLoad:无论缓存是否过期,有缓存则使用缓存,否则重新请求原始数据。
  • returnCacheDataDontLoad:无论缓存是否过期,有缓存则使用缓存,否则视为失败,不会重新请求原始数据。

其实对于多数开发者而言,第二种根本不缓存,其他两种也存在着很大的使用风险,所以默认缓存策略才是我们最关心的,它使用网络协议中实现的缓存策略,那我们就应该首先弄清网络协议中的缓存策略是如何来控制的(注意:无论是NSURLConnection还是NSURLSession都支持多种协议,这里重点关注HTTP、HTTPS)。

HTTP的请求和响应使用headers来进行元数据交换,例如MIME、Encoding,当然也包括缓存执行,下面会着重介绍相关缓存配置。

请求头信息 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就可以使用缓存了。下面Demo3中演示了如何使用使用NSURLSession通过max-age进行为期60s的缓存,运行会发现在第一次请求之后60s内不会进行再次请求,60s后才会发起第二次请求。

let config = URLSessionConfiguration.default
// urlCache默认使用私有内存缓存
// config.urlCache = URLCache(memoryCapacity: 5*1024*1024, diskCapacity: 20*1024*1024, diskPath: nil)
// config.requestCachePolicy = .useProtocolCachePolicy
let urlSession = URLSession(configuration: config)
        if let url = URL(string: "http://myapi.applinzi.com/url-cache/default-cache.php") {
            let dataTask = urlSession.dataTask(with: url, completionHandler: { (data, response, error) in
                if let tempError = error {
                    debugPrint(tempError)
                } else {
                    guard let tempData = data else { return }
                    let responseText = String(data: tempData, encoding: String.Encoding.utf8)
                    debugPrint(responseText ?? "no text")
                }
            })
            
            dataTask.resume()
        }

服务器端default-cache.php内容如下:

<?php
	$time=time();
	$interval=60;
	header('Last-Modified: '.gmdate('r',$time));
	header('Expires: '.gmdate('r',($time+$interval)));
	header('Cache-Control: max-age='.$interval);
	header('Content-type: text/json');

	$arr = array('a'=>1,'b'=>2);
 	echo json_encode($arr);
?>

对应的请求和相应头信息如下,服务器端设置缓存60s:

URLSession_DefaultCache_Headers

当然,配合服务器端使用缓存是一种不错的方案,自然官方设计时也是希望尽可能使用默认缓存策略。但是有些时候服务器端出于其他原因考虑,或者说或客户端需要自定义缓存策略时还是有必要进行手动缓存管理的。比如说如果服务器端根本没有设置缓存过期时间或者服务器端根本无法获知用户何时清理缓存、何时使用缓存这些具体逻辑等都需要服务器端自行制定缓存策略。有不少朋友选择自建数据库直接缓存JSON模型(通常是NSArray或者NSDictionary)或者缓存成归档文件等,其实使用NSURLCache默认的缓存策略依然可行,只是需要使用相关的代理方法、控制缓存逻辑:

对于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控制的响应只需要添加上响应的缓存控制即可。Demo3的示例5说明了这一点。

import UIKit

class DemoViewController3: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func requestWithServerCache1() {
        let config = URLSessionConfiguration.default
        // urlCache默认使用私有内存缓存
        // config.urlCache = URLCache(memoryCapacity: 5*1024*1024, diskCapacity: 20*1024*1024, diskPath: nil)
        // config.requestCachePolicy = .useProtocolCachePolicy
        let urlSession = URLSession(configuration: config)
        if let url = URL(string: "http://myapi.applinzi.com/url-cache/default-cache.php") {
            let dataTask = urlSession.dataTask(with: url, completionHandler: { (data, response, error) in
                if let tempError = error {
                    debugPrint(tempError)
                } else {
                    guard let tempData = data else { return }
                    let responseText = String(data: tempData, encoding: String.Encoding.utf8)
                    debugPrint(responseText ?? "no text")
                }
            })
            
            dataTask.resume()
        }
    }
    
    @IBAction func requestWithoutServerCache2() {
        let config = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: config, delegate: self.delegate, delegateQueue: nil)
        if let url = URL(string: "http://myapi.applinzi.com/url-cache/no-cache.php") {
            let dataTask = urlSession.dataTask(with: url)
            dataTask.resume()
        }
    }
    
    @IBAction func requestWithoutServerCache3() {
        let config = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
        if let url = URL(string: "http://myapi.applinzi.com/url-cache/no-cache.php") {
            let urlRequest = URLRequest(url: url)
            let dataTask = urlSession.dataTask(with: urlRequest, completionHandler: { (data, response, error) in
                if let tempError = error {
                    debugPrint(tempError)
                } else {
                    guard let tempData = data else { return }
                    let responseText = String(data: tempData, encoding: String.Encoding.utf8)
                    let cacheResponse = URLCache.shared.cachedResponse(for: urlRequest)
                    debugPrint(cacheResponse)
                    debugPrint(responseText ?? "no text")
                }
            })
            
            dataTask.resume()
        }
    }
    
    @IBAction func requestWithoutServerCache4() {
        let config = URLSessionConfiguration.default
        // 使用缓存数据
        config.requestCachePolicy = .returnCacheDataDontLoad
        let urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
        if let url = URL(string: "http://myapi.applinzi.com/url-cache/no-cache.php") {
            let urlRequest = URLRequest(url: url)
            let dataTask = urlSession.dataTask(with: urlRequest, completionHandler: { (data, response, error) in
                if let tempError = error {
                    debugPrint(tempError)
                } else {
                    guard let tempData = data else { return }
                    let responseText = String(data: tempData, encoding: String.Encoding.utf8)
                    let cacheResponse = URLCache.shared.cachedResponse(for: urlRequest)
                    debugPrint(cacheResponse)
                    debugPrint(responseText ?? "no text")
                }
            })
            
            dataTask.resume()
        }
    }
    
    @IBAction func requestWithoutServerCache5() {
        let config = URLSessionConfiguration.default
        let urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
        if let url = URL(string: "http://myapi.applinzi.com/url-cache/no-cache.php") {
            let dataTask = urlSession.dataTask(with: url)
            dataTask.resume()
        }
    }
    
    private var delegate =  DemoViewController3Delegate()
}

extension DemoViewController3:URLSessionDelegate, URLSessionDataDelegate {
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        let responseText = String(data: data, encoding: String.Encoding.utf8)
        debugPrint(responseText ?? "no text")
    }
    
    public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Swift.Void) {
        if let httpResponse = proposedResponse.response as? HTTPURLResponse {
            if httpResponse.allHeaderFields["Cache-Control"] == nil {
                
                let newHeaders = (httpResponse.allHeaderFields as NSDictionary).mutableCopy() as? NSDictionary
                newHeaders?.setValue("max-age=60", forKey: "Cache-Control")
                let newResponse = HTTPURLResponse(url: httpResponse.url!, statusCode: httpResponse.statusCode, httpVersion: "HTTP/1.1", headerFields: newHeaders as? [String : String])
                
                let newCacheResponse = CachedURLResponse(response: newResponse!, data: proposedResponse.data)
                completionHandler(newCacheResponse)
                return
            }
        }
        completionHandler(proposedResponse)
    }
}

// for requestWithoutServerCache2
class DemoViewController3Delegate:NSObject,URLSessionDelegate, URLSessionDataDelegate {
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        let responseText = String(data: data, encoding: String.Encoding.utf8)
        debugPrint(responseText ?? "no text")
    }
    
    public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Swift.Void) {
        completionHandler(proposedResponse)
        debugPrint(proposedResponse)
    }
}

缓存设计

从前面对于URL Loading System的分析可以看出利用NSURLProtocol或者NSURLCache都可以做客户端缓存,但是NSURLProtocol更多的用于拦截处理,而且如果使用它来做缓存的话需要自己发起请求。而选择URLSession配合NSURLCache的话,则对于接口调用方有更多灵活的控制,而且默认情况下NSURLCache就有缓存,我们只要操作缓存响应的Cache headers即可,因此后者作为我们优先考虑的设计方案。鉴于本文代码使用Swift编写,因此结合目前Swift中流行的网络库Alamofire实现一种相对简单的缓存方案。

根据前面的思路,最早还是想从URLSessionDataDelegate的缓存设置方法入手,而且Alamofire确实对于每个URLSessionDataTask都留有缓存代理方法的回调入口,但查看源码发现这个入口dataTaskWillCacheResponse并未对外开发,而如果直接在SessionDelegate的回调入口dataTaskWillCacheResponseWithCompletion上进行回调又无法控制每个请求的缓存情况(NSURLSession是多个请求共用的)。当然如果沿着这个思路可以再扩展一个DataTaskDelegate对象以暴漏缓存入口,但是这么一来必须实现URLSessionDataDelegate,而且要想办法Swizzle NSURLSession的缓存代理(或者继承SessionDelegate切换代理),在代理中根据不同的NSURLDataTask进行缓存处理,整个过程对于调用方并不是太友好。

另一个思路就是等Response请求结束后获取缓存的响应CachedURLResponse并且修改(事实上只要是同一个NSURLRequest存储进去默认会更新原有缓存),而且NSURLCache本身就是有内存缓存的,过程并不会太耗时。当然这个方案最重要的是得保证响应完成,所以这里通过Alamofire链式调用使用response(queue: queue, responseSerializer: responseSerializer, completionHandler: completionHandler重新请求以保证及时掌握回调时机。主要的代码片段如下:

public func cache(maxAge:Int,isPrivate:Bool = false,ignoreServer:Bool = true)
    -> Self
{
    var useServerButRefresh = false
    if let newRequest = self.request {
        if !ignoreServer {
            if newRequest.allHTTPHeaderFields?[AlamofireURLCache.refreshCacheKey] == AlamofireURLCache.RefreshCacheValue.refreshCache.rawValue {
                useServerButRefresh = true
            }
        }
        
        if newRequest.allHTTPHeaderFields?[AlamofireURLCache.refreshCacheKey] != AlamofireURLCache.RefreshCacheValue.refreshCache.rawValue {
            if let urlCache = self.session.configuration.urlCache {
                if let value = (urlCache.cachedResponse(for: newRequest)?.response as? HTTPURLResponse)?.allHeaderFields[AlamofireURLCache.refreshCacheKey] as? String {
                    if value == AlamofireURLCache.RefreshCacheValue.useCache.rawValue {
                        return self
                    }
                }
            }
        }
        
    }
    
    return response { [unowned self](defaultResponse) in
        
        if defaultResponse.request?.httpMethod != "GET" {
            debugPrint("Non-GET requests do not support caching!")
            return
        }
        

        if defaultResponse.error != nil {
            debugPrint(defaultResponse.error!.localizedDescription)
            return
        }

        if let httpResponse = defaultResponse.response {
            guard let newRequest = defaultResponse.request else { return }
            guard let newData = defaultResponse.data else { return }
            guard let newURL = httpResponse.url else { return }
            guard let urlCache = self.session.configuration.urlCache else { return }
            guard let newHeaders = (httpResponse.allHeaderFields as NSDictionary).mutableCopy() as? NSMutableDictionary else { return }
            
            if AlamofireURLCache.isCanUseCacheControl {
                if httpResponse.allHeaderFields["Cache-Control"] == nil || httpResponse.allHeaderFields.keys.contains("no-cache") || httpResponse.allHeaderFields.keys.contains("no-store") || ignoreServer || useServerButRefresh {
                    DataRequest.addCacheControlHeaderField(headers: newHeaders, maxAge: maxAge, isPrivate: isPrivate)
                } else {
                    return
                }
            } else {
                if httpResponse.allHeaderFields["Expires"] == nil || ignoreServer || useServerButRefresh {
                    DataRequest.addExpiresHeaderField(headers: newHeaders, maxAge: maxAge)
                    if ignoreServer && httpResponse.allHeaderFields["Pragma"] != nil {
                        newHeaders["Pragma"] = "cache"
                    }
                } else {
                    return
                }
            }
            newHeaders[AlamofireURLCache.refreshCacheKey] = AlamofireURLCache.RefreshCacheValue.useCache.rawValue
            if let newResponse = HTTPURLResponse(url: newURL, statusCode: httpResponse.statusCode, httpVersion: AlamofireURLCache.HTTPVersion, headerFields: newHeaders as? [String : String]) {
                
                let newCacheResponse = CachedURLResponse(response: newResponse, data: newData, userInfo: ["framework":AlamofireURLCache.frameworkName], storagePolicy: URLCache.StoragePolicy.allowed)
                
                urlCache.storeCachedResponse(newCacheResponse, for: newRequest)
            }
        }
        
    }
    
}

要完成整个缓存处理自然还包括缓存刷新、缓存清理等操作,关于缓存清理本身NSURLCache是提供了remove方法的,不过缓存清理并不及时,调用并不会立即生效,具体参见NSURLCache does not clear stored responses in iOS8。因此,这里借助了上面提到的Cache-Control进行缓存过期控制,一方面可以快速清理缓存,另一方面缓存控制可以更加精确。

AlamofireURLCache

AlamofireURLCache

为了更好的配合Alamofire使用,此代码以AlamofireURLCache类库形式在github开源,所有接口API尽量和原有接口保持一致,便于对Alamofire二次封装。此外还提供了手动清理缓存、出错之后自动清理缓存、覆盖服务器端缓存配置等方便的功能,可以满足多数情况下缓存需求细节。

AlamofireURLCache在request方法添加了refreshCache参数用于缓存刷新,设为false或者不提供此参数则不会刷新缓存,只有等到上次缓存数据过了有效期才会再次发起请求。

Alamofire.request("https://myapi.applinzi.com/url-cache/no-cache.php",refreshCache:false).responseJSON(completionHandler: { response in
    if response.value != nil {
        self.textView.text = (response.value as! [String:Any]).debugDescription
    } else {
        self.textView.text = "Error!"
    }
    
}).cache(maxAge: 10)

服务器端缓存headers设置并不都是最优选择,某些情况下客户端必须自行控制缓存策略,此时可以使用AlamofireURLCache的ignoreServer参数忽略服务器端配置,通过maxAge参数自行控制缓存时长。

Alamofire.request("https://myapi.applinzi.com/url-cache/default-cache.php",refreshCache:false).responseJSON(completionHandler: { response in
    if response.value != nil {
        self.textView.text = (response.value as! [String:Any]).debugDescription
    } else {
        self.textView.text = "Error!"
    }
    
}).cache(maxAge: 10,isPrivate: false,ignoreServer: true)

另外,有些情况下未必需要刷新缓存而是要清空缓存保证下次访问时再使用最新数据,此时就需要使用AlamofireURLCache提供的缓存清理API来完成。需要特别说明的是,对于请求出错、序列化出错等情况如果调用了cache(maxAge)方法进行缓存后,那么下次请求会使用错误的缓存数据,需要开发人员根据返回情况自行调用API清理缓存。但更好的选择是使用AlamofireURLCache提供的autoClearCache参数来自动处理此种情况,所以任何时候都推荐将autoClearCache参数设为true以保证不会缓存出错数据。

Alamofire.clearCache(dataRequest: dataRequest) // clear cache by DataRequest
Alamofire.clearCache(request: urlRequest) // clear cache by URLRequest

// ignore data cache when request error
Alamofire.request("https://myapi.applinzi.com/url-cache/no-cache.php",refreshCache:false).responseJSON(completionHandler: { response in
    if response.value != nil {
        self.textView.text = (response.value as! [String:Any]).debugDescription
    } else {
        self.textView.text = "Error!"
    }
    
},autoClearCache:true).cache(maxAge: 10)

如果阅读本文让你有所收获,欢迎推荐点赞,最后再次附上代码下载!

代码下载

posted @ 2017-06-05 14:37  KenshinCui  阅读(11862)  评论(5编辑  收藏  举报