AFNetworking和YTKNetwork的快取策略

Misaki發表於2018-12-04

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. 無數開發者嘗試自己做一個醜陋而脆弱的系統來實現網路快取的功能,殊不知NSURLCache只要兩行程式碼就能搞定,並且好上100倍。甚至更多的開發者根本不知道網路快取的好處,從來沒有嘗試過解決方案,導致他們的App向伺服器發出無數不必要的請求。

iOS系統的快取策略

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

服務端的快取策略

    先看看服務端的快取策略。當第一次請求後,客戶端會快取資料,當有第二次請求的時候,客戶端會額外在請求頭加上If-Modified-Since或者If-None-MatchIf-Modified-Since會攜帶快取的最後修改時間,服務端會把這個時間和實際檔案的最後修改時間進行比較。

  • 相同就返回狀態碼304,且不返回資料,客戶端拿出快取資料,渲染頁面
  • 不同就返回狀態碼200,並且返回資料,客戶端渲染頁面,並且更新快取

    當然類似的還有Cache-ControlExpiresEtag,都是為了校驗本地快取檔案和服務端是否一致,這裡就帶過了。

NSURLCache

    NSURLCache是iOS系統提供的記憶體以及磁碟的綜合快取機制。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];

//請求策略,設定了系統會自動用NSURLCache進行資料快取
request.cachePolicy = NSURLRequestReturnCacheDataElseLoad;
複製程式碼

iOS常用的快取策略

    NSURLRequestCachePolicy是個列舉,指的是不同的快取策略,一共有7種,但是能用的只有4種。

typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
    //如果有協議,對於特定的URL請求,使用協議實現定義的快取邏輯。(預設的快取策略)
    NSURLRequestUseProtocolCachePolicy = 0,

    //請求僅從原始資源載入URL,不使用任何快取
    NSURLRequestReloadIgnoringLocalCacheData = 1,

    //不僅忽略本地快取,還要忽略協議快取和其他快取 (未實現)
    NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4,

    //被NSURLRequestReloadIgnoringLocalCacheData替代
    NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,

    //無視快取的有效期,有快取就取快取,沒有快取就會從原始地址載入
    NSURLRequestReturnCacheDataElseLoad = 2,

    //無視快取的有效期,有快取就取快取,沒有快取就視為失敗 (可以用於離線模式)
    NSURLRequestReturnCacheDataDontLoad = 3,

    //會從初始地址校驗快取的合法性,合法就用快取資料,不合法從原始地址載入資料 (未實現)
    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個位元組。記憶體大小 = 寬×高×每畫素位元組數。

-(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 ,沒有傳送請求
        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是一個優先順序比較低的序列佇列,當標誌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的字尾作為區分。檔案寫入的路徑是沙盒中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開發者來說,一般關注diskmemory就夠了。閱讀SDWebImage、AFNetworking、YTKNetwork的原始碼後,可以看出他們都非常重視資料的多執行緒的讀寫安全,在做深度優化時候,因地制宜,及時清理快取檔案。

相關文章