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)

如果閱讀本文讓你有所收獲,歡迎推薦點贊,最后再次附上代碼下載!

代碼下載


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM