iOS 緩存機制詳解

Untold numbers of developers have hacked together an awkward, fragile system for network caching functionality, all because they weren’t aware that NSURLCache could be setup in two lines and do it 100× better. Even more developers have never known the benefits of network caching, and never attempted a solution, causing their apps to make untold numbers of unnecessary requests to the server.
無數開發(fā)者嘗試自己做一個丑陋而脆弱的系統(tǒng)來實現(xiàn)網絡緩存的功能,殊不知NSURLCache只要兩行代碼就能搞定,并且好上100倍。甚至更多的開發(fā)者根本不知道網絡緩存的好處,從來沒有嘗試過解決方案,導致他們的App向服務器發(fā)出無數不必要的請求。

iOS系統(tǒng)的緩存策略

????上面是引用Mattt大神在NSHipster介紹NSURLCache時的原話。

服務端的緩存策略

????先看看服務端的緩存策略。當第一次請求后,客戶端會緩存數據,當有第二次請求的時候,客戶端會額外在請求頭加上If-Modified-Since或者If-None-MatchIf-Modified-Since會攜帶緩存的最后修改時間,服務端會把這個時間和實際文件的最后修改時間進行比較。

  • 相同就返回狀態(tài)碼304,且不返回數據,客戶端拿出緩存數據,渲染頁面
  • 不同就返回狀態(tài)碼200,并且返回數據,客戶端渲染頁面,并且更新緩存

????當然類似的還有Cache-ControlExpiresEtag,都是為了校驗本地緩存文件和服務端是否一致,這里就帶過了。

NSURLCache

????NSURLCache是iOS系統(tǒng)提供的內存以及磁盤的綜合緩存機制。NSURLCache對象被存儲沙盒中Library/cache目錄下。在我們只需要在didFinishLaunchingWithOptions函數里面加上下面的代碼,就可以滿足一般的緩存要求。(是的,搞定NSURLCache就是這么簡單)

NSURLCache * sharedCache = [[NSURLCache alloc] initWithMemoryCapacity:20 * 1024 * 1024
                                                            diskCapacity:100 * 1024 * 1024
                                                                diskPath:nil];
[NSURLCache setSharedURLCache:sharedCache];

????下面是幾個常用的API

 //設置內存緩存的最大容量
[cache setMemoryCapacity:1024 * 1024 * 20];
    
//設置磁盤緩存的最大容量
[cache setDiskCapacity:1024 * 1024 * 100];
    
//獲取某個請求的緩存
[cache cachedResponseForRequest:request];
    
//清除某個請求的緩存
[cache removeCachedResponseForRequest:request];
    
//請求策略,設置了系統(tǒng)會自動用NSURLCache進行數據緩存
request.cachePolicy = NSURLRequestReturnCacheDataElseLoad;

iOS常用的緩存策略

????NSURLRequestCachePolicy是個枚舉,指的是不同的緩存策略,一共有7種,但是能用的只有4種。

typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
    //如果有協(xié)議,對于特定的URL請求,使用協(xié)議實現(xiàn)定義的緩存邏輯。(默認的緩存策略)
    NSURLRequestUseProtocolCachePolicy = 0,
    
    //請求僅從原始資源加載URL,不使用任何緩存
    NSURLRequestReloadIgnoringLocalCacheData = 1,
    
    //不僅忽略本地緩存,還要忽略協(xié)議緩存和其他緩存 (未實現(xiàn))
    NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4,
    
    //被NSURLRequestReloadIgnoringLocalCacheData替代
    NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,
    
    //無視緩存的有效期,有緩存就取緩存,沒有緩存就會從原始地址加載
    NSURLRequestReturnCacheDataElseLoad = 2,
    
    //無視緩存的有效期,有緩存就取緩存,沒有緩存就視為失敗 (可以用于離線模式)
    NSURLRequestReturnCacheDataDontLoad = 3,
    
    //會從初始地址校驗緩存的合法性,合法就用緩存數據,不合法從原始地址加載數據 (未實現(xiàn))
    NSURLRequestReloadRevalidatingCacheData = 5, // Unimplemented
};

AFNetworking的緩存策略

????之前寫了SDWebImage的源碼解析 里面介紹過SDWebImage的緩存策略,有兩條線根據時間和空間來管理緩存和AFNetworking很相似。AFNetworkingAFImageDownloader使用AFAutoPurgingImageCacheNSURLCache管理圖片緩存。

AFNetworking中的NSURLCache

????AFImageDownloader中設置NSURLCache,低版本iOS版本中設置內存容量和磁盤容量會閃退(這個我沒有考證,iOS 7的手機還真沒有)

if ([[[UIDevice currentDevice] systemVersion] compare:@"8.2" options:NSNumericSearch] == NSOrderedAscending) {
    return [NSURLCache sharedURLCache];
}
return [[NSURLCache alloc] initWithMemoryCapacity:20 * 1024 * 1024
                                     diskCapacity:150 * 1024 * 1024
                                         diskPath:@"com.alamofire.imagedownloader"];

AFNetworking中的AFAutoPurgingImageCache

????AFAutoPurgingImageCache是專門用來圖片緩存的。可以看到內部有三個屬性,一個是用來裝載AFImageCache對象的字典容器,一個是可以用內存空間大小、一個同步隊列。AFAutoPurgingImageCache在初始化的時候,會注冊UIApplicationDidReceiveMemoryWarningNotification通知,收到內存警告的時候會清除所有緩存。

 @interface AFAutoPurgingImageCache ()
 @property (nonatomic, strong) NSMutableDictionary <NSString* , AFCachedImage*> *cachedImages;
 @property (nonatomic, assign) UInt64 currentMemoryUsage;
 @property (nonatomic, strong) dispatch_queue_t synchronizationQueue;
 @end

????AFCachedImage是單個圖片緩存對象

 @property (nonatomic, strong) UIImage *image;
 
 //標志符(這個值就是圖片的請路徑 request.URL.absoluteString)
 @property (nonatomic, strong) NSString *identifier;
 
 //圖片大小
 @property (nonatomic, assign) UInt64 totalBytes;
 
 //緩存日期
 @property (nonatomic, strong) NSDate *lastAccessDate;
 
 //當前可用內存空間大小
 @property (nonatomic, assign) UInt64 currentMemoryUsage;

????來看看AFCachedImage初始化的時候。iOS使用圖標標準是ARGB_8888,即一像素占位4個字節(jié)。內存大小 = 寬 * 高 * 每像素字節(jié)數。

-(instancetype)initWithImage:(UIImage *)image identifier:(NSString *)identifier {
    if (self = [self init]) {
        self.image = image;
        self.identifier = identifier;

        CGSize imageSize = CGSizeMake(image.size.width * image.scale, image.size.height * image.scale);
        CGFloat bytesPerPixel = 4.0;
        CGFloat bytesPerSize = imageSize.width * imageSize.height;
        self.totalBytes = (UInt64)bytesPerPixel * (UInt64)bytesPerSize;
        self.lastAccessDate = [NSDate date];
    }
    return self;
}

????來看看添加緩存的代碼,用了dispatch_barrier_async柵欄函數將添加操作和刪除緩存操作分割開來。每添加一個緩存對象,都重新計算當前緩存大小和可用空間大小。當內存超過設定值時,會按照日期的倒序來遍歷緩存圖片,刪除最早日期的緩存,一直到滿足緩存空間為止。

- (void)addImage:(UIImage *)image withIdentifier:(NSString *)identifier {
    dispatch_barrier_async(self.synchronizationQueue, ^{
        AFCachedImage *cacheImage = [[AFCachedImage alloc] initWithImage:image identifier:identifier];

        AFCachedImage *previousCachedImage = self.cachedImages[identifier];
        if (previousCachedImage != nil) {
            self.currentMemoryUsage -= previousCachedImage.totalBytes;
        }

        self.cachedImages[identifier] = cacheImage;
        self.currentMemoryUsage += cacheImage.totalBytes;
    });

    dispatch_barrier_async(self.synchronizationQueue, ^{
        if (self.currentMemoryUsage > self.memoryCapacity) {
            UInt64 bytesToPurge = self.currentMemoryUsage - self.preferredMemoryUsageAfterPurge;
            NSMutableArray <AFCachedImage*> *sortedImages = [NSMutableArray arrayWithArray:self.cachedImages.allValues];
            NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"lastAccessDate"
                                                                           ascending:YES];
            [sortedImages sortUsingDescriptors:@[sortDescriptor]];

            UInt64 bytesPurged = 0;

            for (AFCachedImage *cachedImage in sortedImages) {
                [self.cachedImages removeObjectForKey:cachedImage.identifier];
                bytesPurged += cachedImage.totalBytes;
                if (bytesPurged >= bytesToPurge) {
                    break ;
                }
            }
            self.currentMemoryUsage -= bytesPurged;
        }
    });
}

YTKNetwork的緩存策略

????YTKNetwork是猿題庫技術團隊開源的一個網絡請求框架,內部封裝了AFNetworking。它把每個請求實例化,管理它的生命周期,也可以管理多個請求。筆者在一個電商的PaaS項目中就是使用YTKNetwork,它的特點還有支持請求結果緩存,支持批量請求,支持多請求依賴等。

準備請求之前

????先來看看請求基類YTKRequest在請求之前做了什么

- (void)start {
    
    //忽略緩存的標志 手動設置 是否利用緩存
    if (self.ignoreCache) {
        [self startWithoutCache];
        return;
    }

    // 還有未完成的請求 是否還有未完成的請求
    if (self.resumableDownloadPath) {
        [self startWithoutCache];
        return;
    }

    //加載緩存是否成功
    if (![self loadCacheWithError:nil]) {
        [self startWithoutCache];
        return;
    }

    _dataFromCache = YES;

    dispatch_async(dispatch_get_main_queue(), ^{
        
        //將請求數據寫入文件
        [self requestCompletePreprocessor];
        [self requestCompleteFilter];
        
        //這個時候直接去相應 請求成功的delegate和block ,沒有發(fā)送請求
        YTKRequest *strongSelf = self;
        [strongSelf.delegate requestFinished:strongSelf];
        if (strongSelf.successCompletionBlock) {
            strongSelf.successCompletionBlock(strongSelf);
        }
        
        //將block置空
        [strongSelf clearCompletionBlock];
    });
}

緩存數據寫入文件

- (void)requestCompletePreprocessor {
    [super requestCompletePreprocessor];

    if (self.writeCacheAsynchronously) {
        dispatch_async(ytkrequest_cache_writing_queue(), ^{
            [self saveResponseDataToCacheFile:[super responseData]];
        });
    } else {
        [self saveResponseDataToCacheFile:[super responseData]];
    }
}

????ytkrequest_cache_writing_queue是一個優(yōu)先級比較低的串行隊列,當標志dataFromCacheYES的時候,確定能拿到數據,在這個串行隊列中異步的寫入文件。來看看寫入緩存的具體操作。

- (void)saveResponseDataToCacheFile:(NSData *)data {
    if ([self cacheTimeInSeconds] > 0 && ![self isDataFromCache]) {
        if (data != nil) {
            @try {
                // New data will always overwrite old data.
                [data writeToFile:[self cacheFilePath] atomically:YES];

                YTKCacheMetadata *metadata = [[YTKCacheMetadata alloc] init];
                metadata.version = [self cacheVersion];
                metadata.sensitiveDataString = ((NSObject *)[self cacheSensitiveData]).description;
                metadata.stringEncoding = [YTKNetworkUtils stringEncodingWithRequest:self];
                metadata.creationDate = [NSDate date];
                metadata.appVersionString = [YTKNetworkUtils appVersionString];
                [NSKeyedArchiver archiveRootObject:metadata toFile:[self cacheMetadataFilePath]];
            } @catch (NSException *exception) {
                YTKLog(@"Save cache failed, reason = %@", exception.reason);
            }
        }
    }
}

????除了請求數據文件,YTK還會生成一個記錄緩存數據信息的元數據YTKCacheMetadata對象。YTKCacheMetadata記錄了緩存的版本號、敏感信息、緩存日期和App的版本號。

@property (nonatomic, assign) long long version;
@property (nonatomic, strong) NSString *sensitiveDataString;
@property (nonatomic, assign) NSStringEncoding stringEncoding;
@property (nonatomic, strong) NSDate *creationDate;
@property (nonatomic, strong) NSString *appVersionString;

????然后把請求方法、請求域名、請求URL和請求參數組成的字符串進行一次MD5加密,作為緩存文件的名稱。YTKCacheMetadata和緩存文件同名,多了一個.metadata的后綴作為區(qū)分。文件寫入的路徑是沙盒中Library/LazyRequestCache目錄下。

- (NSString *)cacheFileName {
    NSString *requestUrl = [self requestUrl];
    NSString *baseUrl = [YTKNetworkConfig sharedConfig].baseUrl;
    id argument = [self cacheFileNameFilterForRequestArgument:[self requestArgument]];
    NSString *requestInfo = [NSString stringWithFormat:@"Method:%ld Host:%@ Url:%@ Argument:%@",
                             (long)[self requestMethod], baseUrl, requestUrl, argument];
    NSString *cacheFileName = [YTKNetworkUtils md5StringFromString:requestInfo];
    return cacheFileName;
}
YTKNetwork緩存文件路徑.png

校驗緩存

????回到start方法中,loadCacheWithError是校驗緩存能不能成功加載出來,loadCacheWithError中會調用validateCacheWithError來檢驗緩存的合法性,校驗的依據正是YTKCacheMetadatacacheTimeInSeconds。要想使用緩存數據,請求實例要重寫cacheTimeInSeconds設置一個大于0的值,而且緩存還支持版本、App的版本。在實際項目上應用,get請求實例設置一個cacheTimeInSeconds就夠用了。

- (BOOL)validateCacheWithError:(NSError * _Nullable __autoreleasing *)error {
    // Date
    NSDate *creationDate = self.cacheMetadata.creationDate;
    NSTimeInterval duration = -[creationDate timeIntervalSinceNow];
    if (duration < 0 || duration > [self cacheTimeInSeconds]) {
        if (error) {
            *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorExpired userInfo:@{ NSLocalizedDescriptionKey:@"Cache expired"}];
        }
        return NO;
    }
    // Version
    long long cacheVersionFileContent = self.cacheMetadata.version;
    if (cacheVersionFileContent != [self cacheVersion]) {
        if (error) {
            *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorVersionMismatch userInfo:@{ NSLocalizedDescriptionKey:@"Cache version mismatch"}];
        }
        return NO;
    }
    // Sensitive data
    NSString *sensitiveDataString = self.cacheMetadata.sensitiveDataString;
    NSString *currentSensitiveDataString = ((NSObject *)[self cacheSensitiveData]).description;
    if (sensitiveDataString || currentSensitiveDataString) {
        // If one of the strings is nil, short-circuit evaluation will trigger
        if (sensitiveDataString.length != currentSensitiveDataString.length || ![sensitiveDataString isEqualToString:currentSensitiveDataString]) {
            if (error) {
                *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorSensitiveDataMismatch userInfo:@{ NSLocalizedDescriptionKey:@"Cache sensitive data mismatch"}];
            }
            return NO;
        }
    }
    // App version
    NSString *appVersionString = self.cacheMetadata.appVersionString;
    NSString *currentAppVersionString = [YTKNetworkUtils appVersionString];
    if (appVersionString || currentAppVersionString) {
        if (appVersionString.length != currentAppVersionString.length || ![appVersionString isEqualToString:currentAppVersionString]) {
            if (error) {
                *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorAppVersionMismatch userInfo:@{ NSLocalizedDescriptionKey:@"App version mismatch"}];
            }
            return NO;
        }
    }
    return YES;
}

清除緩存

????因為緩存的目錄是Library/LazyRequestCache,清除緩存就直接清空目錄下所有文件就可以了。調用[[YTKNetworkConfig sharedConfig] clearCacheDirPathFilter]就行。

結語

????緩存的本質是用空間換取時間。大學里面學過的《計算機組成原理》中就有介紹cache,除了磁盤和內存,還有L1和L2,對于iOS開發(fā)者來說,一般關注diskmemory就夠了。閱讀SDWebImage、AFNetworking、YTKNetwork的源碼后,可以看出他們都非常重視數據的多線程的讀寫安全,在做深度優(yōu)化時候,因地制宜,及時清理緩存文件。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容