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-Match
,If-Modified-Since
會攜帶緩存的最后修改時間,服務端會把這個時間和實際文件的最后修改時間進行比較。
- 相同就返回狀態(tài)碼304,且不返回數據,客戶端拿出緩存數據,渲染頁面
- 不同就返回狀態(tài)碼200,并且返回數據,客戶端渲染頁面,并且更新緩存
????當然類似的還有Cache-Control
、Expires
和Etag
,都是為了校驗本地緩存文件和服務端是否一致,這里就帶過了。
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
很相似。AFNetworking中AFImageDownloader
使用AFAutoPurgingImageCache
和NSURLCache
管理圖片緩存。
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)先級比較低的串行隊列,當標志dataFromCache
為YES
的時候,確定能拿到數據,在這個串行隊列中異步的寫入文件。來看看寫入緩存的具體操作。
- (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;
}
校驗緩存
????回到start方法中,loadCacheWithError
是校驗緩存能不能成功加載出來,loadCacheWithError
中會調用validateCacheWithError
來檢驗緩存的合法性,校驗的依據正是YTKCacheMetadata
和cacheTimeInSeconds
。要想使用緩存數據,請求實例要重寫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ā)者來說,一般關注disk
和memory
就夠了。閱讀SDWebImage、AFNetworking、YTKNetwork
的源碼后,可以看出他們都非常重視數據的多線程的讀寫安全,在做深度優(yōu)化時候,因地制宜,及時清理緩存文件。