iOS 客戶端基於 WebP 圖片格式的流量優化(下)

Curiosity發表於2016-08-20

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請求的資料統一,讓兩種圖片請求公用一套快取,進一步重用。

思路大致如此,其他的問題,就需要靠程式碼能力了。


一口氣寫完,有不完善的地方,可能日後會有部分修改。肯定有很多大神的程式碼寫得更好,或者寫了更好的方案,也希望多多交流,共同進步。

相關文章