NSCache和NSURLCache、網絡緩存優化

正文開始

首先要說一件重要的事:
NSCache和NSURLCache一點關係也沒有
NSCache和NSURLCache一點關係也沒有
NSCache和NSURLCache一點關係也沒有git

而後我推薦你們閱讀一下這兩篇文章:
南峯子Foundation:NSCache
matttNSURLCachegithub

須要注意的一點是:
設置NSURLCache的大小時,大多使用下面的代碼web

- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  NSURLCache *URLCache = [[NSURLCache alloc] initWithMemoryCapacity:4 * 1024 * 1024
                                                       diskCapacity:20 * 1024 * 1024
                                                           diskPath:nil];
  [NSURLCache setSharedURLCache:URLCache];
}

可是即便沒有這兩句代碼,iOS也會自動參與緩存的,只不過使用的是系統建立的NSURLCache類,一樣是能夠經過NSURLCache的sharedURLCache方法獲取。apache

在某些狀況下,應用中的系統組件會將緩存的內存容量設爲0MB,這就禁用了緩存。解決這個行爲的一種方式就是經過本身的實現子類化NSURLCache,拒絕將內存緩存大小設爲0。如可使用以下代碼進行設置:編程

@interface MKNonZeroingURLCache : NSURLCache

@end

@implementation MKNonZeroingURLCache

- (void)setMemoryCapacity:(NSUInteger)memoryCapacity {
    if (memoryCapacity == 0) {
        return;
    }
    [super setMemoryCapacity:memoryCapacity];
}

@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    MKNonZeroingURLCache *urlCache = [[MKNonZeroingURLCache alloc] initWithMemoryCapacity:4 * 1024 * 1024 diskCapacity:20 * 1024 * 1024 diskPath:nil];
    [NSURLCache setSharedURLCache:urlCache];
    
    return YES;
}
// ...
@end

另外,在應用沒有運行的狀態下,如系統遇到磁盤空間過小的狀況,系統也會主動清除一些磁盤緩存的。json

說點題外話:setSharedURLCache:這個方法的命名和編程行爲也是能夠學習的。它告訴咱們,單例的建立並不都是一成不變的使用sharedXXX方法,也可使用一個setSharedXXX:傳遞一個自定義的本類對象,雖然單例對象是外部建立而不是預設的,可是這樣建立以後sharedXXX方法依然是獲取單例的方法。瀏覽器

本篇文章主要介紹一種網絡緩存優化的策略,實際上這個優化方案是提高網絡性能的一個小方案。提高網絡性能是一個大的課題,它主要包括如下幾個方面的改善:緩存

網絡請求性能優化的策略
一.減小請求帶寬
1.請求壓縮
2.響應壓縮
二.下降請求延遲
如:爲NSURLReqeust開啓管道支持
三.避免網絡請求
主要是使用緩存優化性能優化

一種緩存優化方案

HTTP協議規格說明定義ETag爲「被請求變量的實體值」。另外一種說法是,ETag是一個能夠與Web資源關聯的記號(token)。Web資源能夠是一個web頁面、json或xml數據、文件等。Etag有點相似於文件hash或者說是信息摘要。服務器

在瀏覽器默認的行爲中,當進行一次URL請求,服務端會返回'Etag'響應頭,下次瀏覽器請求相同的URL時,瀏覽器會自動將它設置爲請求頭'If-None-Match'的值。服務器收到這個請求以後,就開始作信息校驗工做將本身本次產生的Etag與請求傳遞過來的'If-None-Match'對比,若是相同,則返回HTTP狀態碼304,而且response數據體中沒有數據。

進一步剖析這個過程:第二次請求的時候從哪裏獲取到'Etag'的值並賦給請求頭'If-None-Match'的?天然是瀏覽器的緩存中取出的。那麼瀏覽器收到304狀態碼以後又幹了什麼?剛纔說到response數據體中沒有數據,可是瀏覽器仍需加載頁面,它會從緩存中讀取上次緩存的頁面。

上面的瀏覽器和服務器的配合完成了這樣一系列的工做:

if (本地沒有緩存) {
    進行第一次請求
} else {本地有緩存
    取出上次response的Etag,做爲此次請求的'If-None-Match'值
    進行網絡請求
    if (服務器給的HTTP狀態碼 == 304) {
        // response的數據體爲空,減小了一次數據傳輸
        // 緩存存在的先決條件知足,從緩存中取數據
    } else {
        // 不是304,說明請求的內容改變了,服務器給了新的數據,數據體不空
        // 使用最新的數據
    }
}

然而上面說的一大通都只是瀏覽器的行爲,並非iOS請求的默認行爲,對於iOS開發而言,雖然不須要手動地管理緩存,但緩存策略會對上面的行爲有影響。
iOS中定以的URLRequest緩存策略有如下幾種:

typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
    NSURLRequestUseProtocolCachePolicy = 0,

    NSURLRequestReloadIgnoringLocalCacheData = 1, // 從不讀取緩存,但請求後將response緩存起來
    NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4, // Unimplemented
    NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,

    // 如下兩種在取緩存時,可能取到的是過時數據
    NSURLRequestReturnCacheDataElseLoad = 2, // 緩存中沒有才去發起請求加載,有就不進行網絡請求了
    NSURLRequestReturnCacheDataDontLoad = 3, // 緩存中沒有不加載,毫不發起網絡請求,緩存中沒有則返回錯誤

    NSURLRequestReloadRevalidatingCacheData = 5, // Unimplemented
};

咱們着重看一下默認緩存策略UseProtocolCachePolicy和忽略緩存的策略ReloadIgnoringLocalCacheData,

當使用默認的緩存策略時:

第一次請求一個URL時,會將response和數據緩存起來,
再次請求相同的URL時,會使用緩存中的Etag做爲此次請求的request的'If-None-Match'值,這樣服務端會返回304而且response的數據體爲空,此時iOS會幫助讀取緩存中的數據體,修改次請求的response,將HTTP狀態碼改成200,使用修改後的response和緩存中取到的data做爲參數執行完成回調。

以上過程看起來彷佛很完美,除了狀態碼不是304,其餘的過程和瀏覽器幾乎一致。可是他有一個缺陷,在研究這個缺陷以前咱們先弄清一個這麼一個事實:請求內容能夠分爲三種 1.腳本2.用數據渲染的頁面3.靜態文件。

對於腳本請求的處理,服務端是會忽略Etag,而每次都會處理,這樣返回的數據都是新的,返回HTTP狀態碼爲200.
對於用數據渲染的頁面,服務器會按照必定的計算規則,計算渲染以後的Etag,而後對比,再決定返回的是304或者200.
對於靜態文件,有些服務器具備檢測靜態文件改變的能力,一旦文件發生改變,服務器會馬上檢測到,從而返回200給客戶端,而有些服務器檢測文件改變的功能是有延遲的,或者根本沒有這種功能,這樣即便文件的內容改變了,服務器仍然認爲沒有改變,因而對比Etag依然相等,結果返回304.(此次測試使用了apache和Express,默認配置下的apache對文件改變的檢測是有延遲的,Express則是實時檢測的)

根據以上的描述就會暴露出使用默認緩存策略的一點劣勢,若是服務器不能實時檢測文件改變狀態,那麼文件是否改變的比對結果是不許確的。最糟糕的狀況就是:當文件改變了,服務器認爲仍然沒有改變,從而返回了304,而沒有攜帶最新的數據。

ReloadIgnoringLocalCacheData策略時:

每次請求前都會忽略緩存,request的header歷來不會附帶'If-None-Match'值, 服務器每次處理成功後都是返回200,這樣每次都會拿到服務器的數據(每次response的Date頭都是新的值),服務器返回的response帶有完整的數據體。iOS接收到數據以後,將response和數據緩存,並做爲參數執行完成回調。

這裏咱們也可以看到使用ReloadIgnoringLocalCacheData策略暴漏出來的缺點:儘管服務器端的文件確實沒有改變,但iOS依然不使用本地已有的緩存,而每次服務端還要將數據發給客戶端,這樣是多麼浪費帶寬!

用這個很差,用這個也很差,到底該如何

咱們指望的狀態是這樣的:
對於服務端,不管怎麼作的配置,都但願文件是否改變的檢查結果是最準確的。對於iOS客戶端,獲得狀態碼200天然不要多作什麼處理,若是獲得狀態碼304,則從緩存中取到數據。

因而進行了以下的緩存優化方案:

- (void)refreshedRequest:(NSString *)urlString success:(void (^)(NSHTTPURLResponse *httpResponse, id responseData))successs failure:(void (^)(NSError *error))failure {
    NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    
    NSURLCache *urlCache = [NSURLCache sharedURLCache];
    NSCachedURLResponse *cacheURLResponse = [urlCache cachedResponseForRequest:urlRequest];
    if (cacheURLResponse) {
        NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)cacheURLResponse.response;
        NSString *cachedResponseEtag = [httpResponse.allHeaderFields objectForKey:@"Etag"];
        if (cachedResponseEtag) {
            [urlRequest setValue:cachedResponseEtag forHTTPHeaderField:@"If-None-Match"];
        }
    }

    [urlRequest setCachePolicy:NSURLRequestReloadIgnoringCacheData];
    [[[NSURLSession sharedSession] dataTaskWithRequest:urlRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (!error && successs) {
            NSHTTPURLResponse *newHttpResponse = (NSHTTPURLResponse *)response;
            if (newHttpResponse.statusCode == 304) {
                // cached in local
                successs(newHttpResponse, cacheURLResponse.data);
            } else {
                // refreshed from server
                successs(newHttpResponse, data);
            }
        } else {
            if (failure) {
                failure(error);
            }
        }
    }] resume];
}

這樣每次請求都使用忽略緩存的策略,可是要附帶着"If-None-Match"頭,它的值是上次請求的響應頭"Etag"的值,因而服務器會每次都實時檢查文件的修改狀態,獲得一個準確的狀態值,最後決定返回304仍是200。如果200,iOS則直接使用新的response和新的數據;若是是304,則使用新的response和緩存中的data。
這樣既可以獲取到最新的數據有可以節約帶寬。一箭雙鵰。

不可忽視的響應頭'Last-Modified'和請求頭'If-Modified-Since'

在上面說的服務端對文件的驗證只涉及到ETag,而實際上服務端的驗證過程比這個複雜,還須要使用'Last-Modified'值。'Last-Modified'值在服務器處理階段表明着文件的上次修改時間,在處理結束後做爲一個響應頭放到response中。若是在請求中添加了'If-Modified-Since'頭,並將這個值設置爲上次請求時獲得的響應頭'Last-Modified'的值,那麼此次請求,服務器的處理過程以下:

if 計算出的'ETag' != 請求頭中的'If-Non-Match' || 查詢到的'Last-Modified'(上次修改的時間) != 請求頭中的'If-Modified-Since'
    返回的response狀態碼200 和 數據
else
   返回的reponse狀態碼304

'Etag'與'Last-Modified'不一樣的是:
'Etag'更強調的是實體內容,它表明着文件的信息摘要,它是由服務器計算出來的相似於md5的值,使用'Etag'的驗證是基於內容的。
'Last-Modified'實際上就是文件上次修改的時間,僅僅是一個時間戳,是從文件屬性讀取出來的,使用'Last-Modified'的驗證是基於時間的。

瞭解了這些咱們就能夠改造上面的代碼,使用雙重驗證:

- (void)refreshedRequest:(NSString *)urlString success:(void (^)(NSHTTPURLResponse *httpResponse, id responseData))successs failure:(void (^)(NSError *error))failure {
    NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    
    NSURLCache *urlCache = [NSURLCache sharedURLCache];
    NSCachedURLResponse *cacheURLResponse = [urlCache cachedResponseForRequest:urlRequest];
    if (cacheURLResponse) {
        NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)cacheURLResponse.response;
        NSString *cachedResponseEtag = [httpResponse.allHeaderFields objectForKey:@"Etag"];
        if (cachedResponseEtag) {
            [urlRequest setValue:cachedResponseEtag forHTTPHeaderField:@"If-None-Match"];
        }
        // 增長對上次修改時間的驗證
        NSString *cachedResponseModified = [httpResponse.allHeaderFields objectForKey:@"Last-Modified"];
        if (cachedResponseModified) {
            [urlRequest setValue:cachedResponseModified forHTTPHeaderField:@"If-Modified-Since"];
        }
    }

    // ....
}

不過如今比較悲劇的是,各個web容器已經將Etag值的計算方法玩壞了,在計算Etag是依賴的參數不只僅有文件的內容信息,還有文件的修改時間,這樣一來,Etag的功能就至關於最初設計的Etag的功能+Last-Modified的功能。因此說上面的改造代碼沒有什麼實在乎義,只使用Etag就能夠。

爲了Etag確實不只僅是基於內容的驗證值,我作了一下測試:
先進行訪問一次對http://127.0.0.1/blog文件(裏面只有幾個字符的文本文件)的訪問,獲得以下的response:

<NSHTTPURLResponse: 0x7fe143d170d0> { URL: http://127.0.0.1/blog } { status code: 304, headers {
    Connection = "Keep-Alive";
    Date = "Tue, 23 Feb 2016 04:16:36 GMT";
    Etag = "\"14-52c66cf22bd40\"";
    "Keep-Alive" = "timeout=5, max=100";
    Server = "Apache/2.4.16 (Unix) PHP/5.5.29";
} }
<7b0a0922 74657374 223a2268 656c6c6f 222c0a7d>

此時文件的MD5爲:

MD5 (blog) = 35466082cffbc8fe4529a18a55f0260e

而後修改服務端文件的修改時間,但並無修改文件的內容

# 將修改時間更改成2016年1月1日0點0分
touch -mt 201601010000 blog

這時文件的MD5值爲:

MD5 (blog) = 35466082cffbc8fe4529a18a55f0260e # 沒有改變

再次進行訪問,此次訪問使用忽略緩存的協議,而且帶上Etag值,而不帶修改時間值,獲得的response是:

<NSHTTPURLResponse: 0x7fe143c0c870> { URL: http://127.0.0.1/blog } { status code: 200, headers {
    "Accept-Ranges" = bytes;
    Connection = "Keep-Alive";
    "Content-Length" = 20;
    Date = "Tue, 23 Feb 2016 04:20:42 GMT";
    Etag = "\"14-52833bf364000\"";
    "Keep-Alive" = "timeout=5, max=100";
    "Last-Modified" = "Thu, 31 Dec 2015 16:00:00 GMT";
    Server = "Apache/2.4.16 (Unix) PHP/5.5.29";
} }<7b0a0922 74657374 223a2268 656c6c6f 222c0a7d>

數據沒有變,可是Etag仍然改變了。(以上是在apache+PHP的測試結果,使用Express也是這樣)

'Keep-Alive'響應頭和不離線的URLSession

"Keep-Alive"響應頭會控制客戶端進行發起請求的間隔。例如:

"Keep-Alive" = "timeout=5, max=100"

其中timeout值表明着最小間隔,也就是說若是此次發送請求以後,要在5秒以後發起的請求才會進行網絡訪問。
max值表明着最大的嘗試次數,在timeout時間內發起請求會使這個值-1直到變爲0再變爲設定值。
以上兩個值就控制着這樣一個過程:剛剛訪問的一個請求,獲取到了數據並進行了緩存,若是尚未過去5秒再次發起一樣的請求,則不進行網絡訪問,直接讀取緩存而且將響應頭修改成"Keep-Alive" = "timeout=5, max=99",若是此次請求還沒過去5秒又進行請求,一樣不進行網絡訪問,直接讀取緩存,修改響應頭爲"Keep-Alive" = "timeout=5, max=98".....直到max變爲0,再來一次又變回100.

看到這裏咱們又能體會到緩存優化的必要性,服務端當設定了這個響應頭時,也能夠不受影響地拿到實時數據。

這裏還要說的一個問題是Session的在線狀態,例如上面的訪問中,每次訪問須要使用相同的session才能作到max值不斷-1,若是session值改變了,至關於一次新的請求,得到的始終是"Keep-Alive" = "timeout=5, max=100",也就是說,下面的兩種情況是不一樣的。

- (void)onlyUseOneSession {
    NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:kURLString]];
    
    [[[NSURLSession sharedSession] dataTaskWithRequest:urlRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        // ...
    }] resume];
}

- (void)useDifferentSession {
    NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:kURLString]];
    
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
    
    [[session dataTaskWithRequest:urlRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        // ...
    }] resume];
    
    session = nil;
}

iOS的URLSession至關於一個瀏覽器窗口,它們雖然可以共用cookie和NSURLCache,可是每一個session因配置不一樣和狀態不一樣,會對相同url的訪問有差異的。若是訪問使用相同Session,那麼就能公用一套配置和訪問歷史等信息,管理起來也是很是方便的,而若是使用的Session都是一些局部變量,那麼使用以後就會離線,並且再也沒法獲取到這些session。所以在開發中建議使用[NSURLSession sharedSession];

'Expires'響應頭

這個響應頭的值也是一個時間,表明着鏈接過時時間,它容許客戶端在這個時間以前不去髮網絡請求,與'Keep-Alive'功能類似,可是'Keep-Alive'指定的是時間長度,而'Expires'指定的是時刻。

當服務端返返回響應頭有這個值,依然是使用優化過的緩存比較穩妥。

這篇文章的意義

有關針對'Etag'和HTTP304狀態碼進行優化緩存的文章不勝枚舉,那麼爲何還要這樣一篇文章。我想緣由主要有如下幾個: 1.你們都知道這個緩存的原理,但是沒有講得太明白,或者乾脆直接就不講直接上代碼。以致於有人存在這種想法:我每次都用默認緩存策略也好好的,你爲何要讓我優化。我認爲本篇文章對於默認緩存策略和忽略緩存策略兩者的優缺點描述仍是頗有必要的。 2.不少人的代碼並無創建在使用系統的NSURLCache的基礎上,更有甚者,直接使用自定義的屬性存取'Etag'的值,apple看到這樣的代碼會哭的,那麼本地緩存中信息存在的意義是什麼。 3.我我的比較想讓你們讀一下NSCache和NSURLCache的那幾篇文章,對日常的工做確實至關有幫助的。 4.雖然我不是mattt,但我以爲mattt從未諷刺過SDWebImage。請不要將緩存和持久化存儲混爲一談,也不要將文件緩存和URL緩存混爲一談。

相關文章
相關標籤/搜索