概覽
緩存設計應該是每個客戶端程序開發所必須考慮的問題,如果同一個功能需要多次訪問,而每次訪問都重新請求的話勢必降低用戶體驗。但是如何處理客戶端緩存貌似并沒有統一的解決方案,多數開發者選擇自行創建數據庫直接將服務器端請求的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是必要的。下圖是這一系列集合的關系:
NSURLProtocol
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:緩存控制,只有包含此設置可能使用默認緩存策略??赡馨缦逻x項: 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后才會發起第二次請求。
服務器端default-cache.php內容如下:
對應的請求和響應頭信息如下(服務器端設置緩存60s):
當然,配合服務器端使用緩存是一種不錯的方案,自然官方設計時也是希望盡可能使用默認緩存策略。但很多時候服務器端出于其他原因考慮,或者說或客戶端需要自定義緩存策略時還是有必要進行手動緩存管理的。比如說如果服務器端根本沒有設置緩存過期時間或者服務器端根本無法獲知用戶何時清理緩存、何時使用緩存這些具體邏輯等都需要客戶端自行制定緩存策略。
對于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重新請求以保證及時掌握回調時機。主要的代碼片段如下:
要完成整個緩存處理自然還包括緩存刷新、緩存清理等操作,關于緩存清理本身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參數來自動處理這種情況。