在iOS 客戶端基於 WebP 圖片格式的流量優化(上)這篇文章中,已經介紹了WebP格式圖片的下載使用,僅僅只有這樣還遠遠不夠,還需要對已經下載的圖片數據進行緩存。
曾經有句名言『計算機世界有兩大難題,第一是起名字,第二是寫一個緩存』,鄙人不能同意更多。
在iOS上,重寫一份圖片緩存是不現實的,而直接修改SDWebImage框架也是不太好的。所以,在SDWebImage的基礎上添加一箇中間層CacheManager比較好。
我感覺,緩存的難度在於,如何準確命中。的確在開發的時候,一大半時間都是在測試緩存命中情況,測試本身就挺麻煩,需要在模擬器的沙盒裏面看文件,同時斷網測試,需要一些調試技巧,很多技巧並麼有辦法詳盡表述出來,需要所謂的悟性去理解。
一、SDWebImage緩存處理
這一部分,由於SD下載圖片的方法中,url被替換,所以要看懂SD本身的代碼,是什麼時候給緩存一個確定的key。發現在
- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url
options:(SDWebImageOptions)options
progress:(SDWebImageDownloaderProgressBlock)progressBlock
completed:(SDWebImageCompletionWithFinishedBlock)completedBlock {
if ([url isKindOfClass:NSString.class]) {
url = [NSURL URLWithString:(NSString *)url];
}
if (![url isKindOfClass:NSURL.class]) {
url = nil;
}
url = [url qd_replaceToWebPURLWithScreenWidth];
......
方法中,確定了緩存的key值
NSString *key = [self cacheKeyForURL:url];
operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
這也就是之前,爲什麼要在這個方法的最前面把URL替換掉,這樣,SD的key值已經是保護WebP格式的圖片URL,這一部分的緩存都可以正常使用,不需要修改。
所以,難度還是在WebView的圖片緩存中,因爲之前雖然是用SD託管WebView中WebP圖片的下載,然而WebView讀緩存卻不能自動從SDImageCache中讀取。這樣,需要用NSURLCache來接管WebView的圖片緩存。
二、WebView圖片緩存
關於WebView的緩存,系統提供了一個類,NSURLCache。這個類可以在所有的網絡請求前查看緩存,並且決定是否緩存(注意:是所有請求)。具體的NSURLCache用法,動動勤勞的小手Google一下,很多文章可以參考。
我們自己的實現,直接上代碼
@implementation QDURLCache
/**
* 請求完成決定是否要將response進行存儲
*/
- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request {
NSString* ua = [request valueForHTTPHeaderField:@"User-Agent"];
if (!EmptyString(ua) && [ua lf_containsSubString:@"AppleWebKit"]) {
//判斷本次請求是不是請求圖片
if ([[QDCacheManager defaultManager] isImageRequest:request]) {
[[QDCacheManager defaultManager] storeImageResponse:cachedResponse forRequest:request];
return;
}
//其他請求
if ([[QDCacheManager defaultManager] shouldCacheExceptImageResponseForRequest:request]) {
if (![[QDCacheManager defaultManager] storeCachedResponse:cachedResponse forRequest:request]) {
[super storeCachedResponse:cachedResponse forRequest:request];
return;
} else {
return;
}
}
}
[super storeCachedResponse:cachedResponse forRequest:request];
}
/**
* 每次發請求之前會調此方法,查看本地是否有緩存
*/
- (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request {
NSString* ua = [request valueForHTTPHeaderField:@"User-Agent"];
if (!EmptyString(ua) && [ua lf_containsSubString:@"AppleWebKit"]) {
if ([[QDCacheManager defaultManager] isImageRequest:request]) { //圖片
//從本地取圖片
NSCachedURLResponse *imageCacheResponse = [[QDCacheManager defaultManager] retrieveImageCacheResponseForRequest:request];
if (imageCacheResponse) {
return imageCacheResponse;
} else {
return [super cachedResponseForRequest:request];
}
}
if ([[QDCacheManager defaultManager] shouldCacheExceptImageResponseForRequest:request]) { //其它緩存的東西
//判斷本地自定義緩存目錄是否存在
if (![[QDCacheManager defaultManager] cacheAvaliableForRequest:request]) {
NSCachedURLResponse *response = [super cachedResponseForRequest:request];
//判斷本地系統緩存目錄是否存在
if (response.data) {
BOOL contentLengthValid = [((NSHTTPURLResponse *)response.response) expectedContentLength] == [response.data length];
//判斷是否是有效的文件
if (!contentLengthValid) {
return response;
}
//將系統緩存放到自定義的緩存目錄中
[[QDCacheManager defaultManager] storeCachedResponse:response forRequest:request];
} else {
}
return response;
}
//從本地緩存中取出對應的緩存
NSCachedURLResponse *cachedResponse = [[QDCacheManager defaultManager] retrieveCachedResponseForRequest:request];
if (cachedResponse) {
return cachedResponse;
}
}
}
return [super cachedResponseForRequest:request];
}
- (void)removeCachedResponseForRequest:(NSURLRequest *)request {
if ([[QDCacheManager defaultManager] cacheAvaliableForRequest:request]) {
if (![[QDCacheManager defaultManager] removeCachedResponseForRequest:request]) {
LogI(@"Failed to remove local cache for request: %@", request.URL);
}
} else {
[super removeCachedResponseForRequest:request];
}
}
@end
這段代碼並沒有多麼難以理解的地方,可以看出來,我們是新建了一箇中間層QDCacheManager,來管理WebView的所有緩存。
而且,既然是全局影響,肯定要用UA包起來,防止誤傷其他緩存。
這一段代碼在調試的時候有個技巧,就是所有super方法的調用,在測試階段,全部直接return,防止WebView自身的緩存干擾調試結果。這個方法在很多緩存處理的地方都需要注意,別的地方但凡出現了調用super方法的,調試中也一律是直接return的。
既然已經用QDCacheManager託管了緩存,URLCache類的任務就已經完成,儲存Response由
- (void)storeCachedResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request
而下面:
- (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request
在NSURLProtocol的startLoading方法執行之前,就調用了。很好理解,因爲這個方法就是取緩存的方法,自然是先取,沒有再去Loading。
這裏的邏輯,必須通過大量調試,反覆驗證,不能簡單套用別人的結論,甚至官方文檔也要懷疑的態度來看。因爲,很多第三方框架,會影響NSURLCache類,我在調試時,就發現,JSPatch,React Native還有我們的一個放劫持服務,都有可能影響這個類中方法的調用。
下面就轉入我們自己的緩存管理方法中去,由於現在關注的是WebP圖片問題,所以,其他緩存處理就不再展開。
三、中間層CacheManager處理
關於這個中間層,主要處理的實際就是緩存key的問題,因爲請求的時候,request裏的URL仍然是沒有替換WebP的,所以,需要先用之前qd_defultWebPURLCacheKey方法來獲取真實圖片緩存key值。
思路的關鍵就是換key,再取cache,代碼本身就只能靠功底了。
直接上代碼,沒什麼好解釋的。
- (BOOL)isImageRequest:(NSURLRequest *)request {
if (![request.URL.absoluteString qd_isQdailyHost]) {
return NO;
}
NSArray *extensions = @[@".jpg", @".jpeg", @".png", @".gif"];
for (NSString *extension in extensions) {
if ([request.URL.absoluteString.lowercaseString lf_containsSubString:extension]){
return YES;
}
}
return NO;
}
- (void)storeImageResponse:(NSCachedURLResponse *)cachedResponse forRequest:(NSURLRequest *)request {
NSString *key = [request.URL qd_defultWebPURLCacheKey];
if ([_imageCache imageFromDiskCacheForKey:key]) {
return;
}
dispatch_async([_imageCache currentIOQueue], ^{
// 硬盤緩存直接存data,webp格式;內存緩存爲UIImage,可以直接使用
[_imageCache storeImageDataToDisk:cachedResponse.data forKey:key];
});
}
- (NSCachedURLResponse *)retrieveImageCacheResponseForRequest:(NSURLRequest *)request {
NSString *key = [request.URL qd_defultWebPURLCacheKey];
NSString *defaultPath = [_imageCache defaultCachePathForKey:key];
NSData *data = nil;
if ([_imageCache imageFromMemoryCacheForKey:key]) {
UIImage * image = [_imageCache imageFromMemoryCacheForKey:key];
if ([key lf_containsSubString:@".png"]) {
data = UIImagePNGRepresentation(image);
} else {
data = UIImageJPEGRepresentation(image, 1.0);
}
}
if (data && data.length != 0) {
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:request.URL
MIMEType:[request.URL.absoluteString qd_MIMEType]
expectedContentLength:data.length
textEncodingName:nil];
return [[NSCachedURLResponse alloc] initWithResponse:response data:data];
}
data = [NSData dataWithContentsOfFile:defaultPath];
if (data == nil) {
data = [NSData dataWithContentsOfFile:[defaultPath stringByDeletingPathExtension]];
}
if (data == nil || data.length == 0) {
[_imageCache removeImageForKey:key fromDisk:YES];
return nil;
}
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:request.URL
MIMEType:[request.URL.absoluteString qd_MIMEType]
expectedContentLength:data.length
textEncodingName:nil];
return [[NSCachedURLResponse alloc] initWithResponse:response data:data];
}
其中currentIOQueue方法,是修改了一下SDImageCache,暴露這個IOQueue,原來的框架是沒有這個方法的。
至於爲什麼圖片硬盤緩存直接用data,因爲這裏考慮是性能問題,取緩存的時候,返回的NSURLResponse所攜帶的,肯定還是NSData,如果當時存了UIImage格式,內部一樣是轉碼成了NSData,而取的時候,還是按UIImage格式取,再轉成NSData返回,相當於多了兩次轉碼。
內存緩存卻沒有這個問題,因爲SD的內存緩存,用的NSCache,存的就是UIImage對象,可以直接取出來用。
這裏其實仍然並沒有什麼好講的,還是基本的邏輯問題,需要比較嚴謹地處理。
四、其他情況的特別處理
我們的app是實現了wifi預加載了,然而這一部分也需要與上面完成的緩存體系通用,不然,wifi預加載的意義就不大。
首先,我們的wifi預加載,是自己寫了一個URLSession,所以在下載前替換URL就可以
for (NSString *urlString in resourcesArray) {
if ([urlString isKindOfClass:[NSString class]]) {
NSURL *theURL = [NSURL URLWithString:urlString];
if ([[QDCacheManager defaultManager] isImageRequest:[NSURLRequest requestWithURL:theURL]]) {
theURL = [theURL qd_replaceToWebPURLWithScreenWidth];
}
if(![[QDCacheManager defaultManager] cacheAvaliableForURL:theURL] && ![[SDImageCache sharedImageCache] diskImageExistsWithKey:theURL.absoluteString]) {
__weak QDPrefetcher* weakSelf = self;
[self.session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {
......
部分代碼如上,關鍵也在於替換URL時機和判斷緩存情況。而下載之後的文件存到哪,是需要處理的。
#pragma mark NSURLSession Delegate
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location {
NSError *error = nil;
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *destinationPath = nil;
if ([[QDCacheManager defaultManager] isImageRequest:downloadTask.originalRequest]) {
NSString *key = downloadTask.originalRequest.URL.absoluteString;
destinationPath = [[SDImageCache sharedImageCache] defaultCachePathForKey:key];
} else {
destinationPath = [[QDCacheManager defaultManager] localCachedWebContentPathWithRequest:downloadTask.originalRequest];
}
if ([fileManager fileExistsAtPath:destinationPath]) {
[fileManager removeItemAtPath:destinationPath error:nil];
}
[fileManager copyItemAtPath:location.path toPath:destinationPath error:&error];
}
我是在finish的方法裏面,把圖片下載的目錄直接copy給SDImageCache的緩存目錄。這樣,SD的緩存裏面就有了這些WebP格式的NSData,與之前的代碼邏輯統一,格式統一。
總結
首先有了一個心得,看上去很複雜的功能,可能實際代碼並不需要自己寫多少,學會在前人的基礎上再加工,比如我們現在這套WebP適配,底層仍然是SDWebImage的基本邏輯,我們只不過在上層,加一些判斷和處理,來適應業務層豐富的功能。
而且,代碼是一步步寫出來的,提前設想的方案,並不一定能實現,先實現功能,再優化架構,纔是正確的方向。當時在WebURLProtocol裏面,繞了很大的彎子,甚至還涉及到了多線程問題,不小心發現了iOS8,9,10三個版本的內部實現都在變化,繞開了一個個坑,才逐步清晰了整個邏輯。
總結整個方案的邏輯,其實比較清晰:
-
首先確定是不是需要被替換的圖片URL,然後所有的替換都採用統一方法,與之配套的key,也用這套方法處理得到他被替換後的URL,保證命中。
-
然後,無論Native請求還是WebView請求,都用SD託管,避免兩套處理邏輯造成的種種不確定性;
-
而WebView的緩存,通過一箇中間層處理,再交給SDImageCache,使之與Native請求的數據統一,讓兩種圖片請求公用一套緩存,進一步重用。
思路大致如此,其他的問題,就需要靠代碼能力了。