在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請求的資料統一,讓兩種圖片請求公用一套快取,進一步重用。
思路大致如此,其他的問題,就需要靠程式碼能力了。
一口氣寫完,有不完善的地方,可能日後會有部分修改。肯定有很多大神的程式碼寫得更好,或者寫了更好的方案,也希望多多交流,共同進步。