從零開始打造一個iOS圖片載入框架(三)

醬了裡個醬發表於2019-04-05

一、前言

在上一個章節主要描述瞭如何實現對GIF圖片的支援,這樣圖片的載入功能就大致完成了。但目前框架只是進行了一些簡單的封裝,還有很多功能尚未完成。我們在第一節中,使用了NSCache來作為記憶體快取和NSFileManager來簡單地封裝為磁碟快取,現在我們將對快取進行重構。

二、記憶體快取

iOS系統本身就提供了NSCache來作為記憶體快取,它是執行緒安全的,且能保證在記憶體緊張的情況下,會自動回收一部分記憶體。因此,我們就不必再造輪子來實現一個記憶體快取了。為了提高框架的靈活性,我們可以提供一個介面來支援外部的擴充套件。

@interface JImageManager : NSObject
+ (instancetype)shareManager;
- (void)setMemoryCache:(NSCache *)memoryCache;
@end
複製程式碼

三、磁碟快取

磁碟快取簡單來說就是對檔案增刪查改等操作,再複雜點就是能夠控制檔案儲存的時間,以及檔案的總大小。

1. 針對快取中可配置的屬性,我們獨立開來作為一個配置類

@interface JImageCacheConfig : NSObject
@property (nonatomic, assign) BOOL shouldCacheImagesInMemory; //是否使用記憶體快取
@property (nonatomic, assign) BOOL shouldCacheImagesInDisk; //是否使用磁碟快取
@property (nonatomic, assign) NSInteger maxCacheAge; //檔案最大快取時間
@property (nonatomic, assign) NSInteger maxCacheSize; //檔案快取最大限制
@end

static const NSInteger kDefaultMaxCacheAge = 60 * 60 * 24 * 7;
@implementation JImageCacheConfig
- (instancetype)init {
    if (self = [super init]) {
        self.shouldCacheImagesInDisk = YES;
        self.shouldCacheImagesInMemory = YES;
        self.maxCacheAge = kDefaultMaxCacheAge;
        self.maxCacheSize = NSIntegerMax;
    }
    return self;
}
複製程式碼

2. 對於檔案增刪查改操作,我們先定義一個磁碟快取相關的協議

#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@protocol JDiskCacheDelegate <NSObject>
- (void)storeImageData:(nullable NSData *)imageData
                forKey:(nullable NSString *)key;
- (nullable NSData *)queryImageDataForKey:(nullable NSString *)key;
- (BOOL)removeImageDataForKey:(nullable NSString *)key;
- (BOOL)containImageDataForKey:(nullable NSString *)key;
- (void)clearDiskCache;

@optional
- (void)deleteOldFiles; //後臺更新檔案
@end
NS_ASSUME_NONNULL_END
複製程式碼

關於磁碟的增刪查改操作這裡就不一一複述了,這裡主要講解如何實現maxCacheAgemaxCacheSize屬性

3.maxCacheAgemaxCacheSize屬性

這兩個屬性是針對檔案的儲存時間和總檔案大小的限制,為什麼需要這種限制呢?首先我們來看maxCacheSize屬性,這個很好理解,我們不可能不斷地擴大磁碟快取,否則會導致APP佔用大量手機空間,對使用者的體驗很不好。而maxCacheAge屬性,可以這麼想,假如一個快取的檔案很久沒有被訪問或修改過,那麼大概率它之後也不會被訪問。因此,我們也沒有必要去保留它。

  • maxCacheAge屬性

實現該屬性的大致流程:根據設定的存活時間計算出檔案可保留的最早時間->遍歷檔案,進行時間比對->若檔案被訪問的時間早於最早時間,那麼刪除對應的檔案

NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskPath isDirectory:YES];
//計算出檔案可保留的最早時間
NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentAccessDateKey];
//獲取到所有的檔案以及檔案屬性
NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL includingPropertiesForKeys:resourceKeys options:NSDirectoryEnumerationSkipsHiddenFiles errorHandler:NULL];
NSMutableArray <NSURL *> *deleteURLs = [NSMutableArray array];
for (NSURL *fileURL in fileEnumerator) {
    NSError *error;
    NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
    if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) { //錯誤或不存在檔案屬性或為資料夾的情況忽略
        continue;
    }
    NSDate *accessDate = resourceValues[NSURLContentAccessDateKey]; //獲取到檔案最近被訪問的時間
    if ([accessDate earlierDate:expirationDate]) { //若早於可保留的最早時間,則加入刪除列表中
        [deleteURLs addObject:fileURL];
    }
}

for (NSURL *URL in deleteURLs) {
    NSLog(@"delete old file: %@", URL.absoluteString);
    [self.fileManager removeItemAtURL:URL error:nil]; //刪除過時的檔案
}
複製程式碼
  • maxCacheSize屬性

實現該屬性的流程:遍歷檔案計算檔案總大小->若檔案總大小超過限制的大小,則對檔案按被訪問的時間順序進行排序->逐一刪除檔案,直到小於總限制的一半為止。

NSMutableDictionary<NSURL *, NSDictionary<NSString *, id>*> *cacheFiles = [NSMutableDictionary dictionary];
NSInteger currentCacheSize = 0;
for (NSURL *fileURL in fileEnumerator) {
    NSError *error;
    NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
    if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
        continue;
    }
    //獲取檔案的大小,並儲存檔案相關屬性
    NSNumber *fileSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
    currentCacheSize += fileSize.unsignedIntegerValue;
    [cacheFiles setObject:resourceValues forKey:fileURL];
}

if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) { //超過總限制大小
    NSUInteger desiredCacheSize = self.maxCacheSize / 2;
    NSArray<NSURL *> *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent usingComparator:^NSComparisonResult(id  _Nonnull obj1, id  _Nonnull obj2) {
        return [obj1[NSURLContentAccessDateKey] compare:obj2[NSURLContentAccessDateKey]];
    }]; //對檔案按照被訪問時間的順序來排序
    for (NSURL *fileURL in sortedFiles) {
        if ([self.fileManager removeItemAtURL:fileURL error:nil]) {
            NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
            NSNumber *fileSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
            currentCacheSize -= fileSize.unsignedIntegerValue;
            
            if (currentCacheSize < desiredCacheSize) { //達到總限制大小的一半即可停止刪除
                break;
            }
        }
    }
}
複製程式碼

為什麼是刪除檔案直到總限制大小的一半才停止刪除?由於訪問和刪除檔案是需要消耗一定效能的,若只是達到總限制大小就停止,那麼一旦再存入一小部分檔案,就很快達到限制,就必須再執行該操作了。

如上,我們可以看到maxCacheAgemaxCacheSize屬性的實現中有很多相同的步驟,比如獲取檔案屬性。為了避免重複操作,我們可以將兩者合併起來實現。

- (void)deleteOldFiles {
    NSLog(@"start clean up old files");
    NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskPath isDirectory:YES];
    NSArray<NSString *> *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentAccessDateKey, NSURLTotalFileAllocatedSizeKey];
    NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL includingPropertiesForKeys:resourceKeys options:NSDirectoryEnumerationSkipsHiddenFiles errorHandler:NULL];
    NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
    NSMutableArray <NSURL *> *deleteURLs = [NSMutableArray array];
    NSMutableDictionary<NSURL *, NSDictionary<NSString *, id>*> *cacheFiles = [NSMutableDictionary dictionary];
    NSInteger currentCacheSize = 0;
    for (NSURL *fileURL in fileEnumerator) {
        NSError *error;
        NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
        if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) {
            continue;
        }
        NSDate *accessDate = resourceValues[NSURLContentAccessDateKey];
        if ([accessDate earlierDate:expirationDate]) { 
            [deleteURLs addObject:fileURL];
            continue;
        }
        
        NSNumber *fileSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
        currentCacheSize += fileSize.unsignedIntegerValue;
        [cacheFiles setObject:resourceValues forKey:fileURL];
    }
    //刪除過時檔案
    for (NSURL *URL in deleteURLs) {
        NSLog(@"delete old file: %@", URL.absoluteString);
        [self.fileManager removeItemAtURL:URL error:nil];
    }
    //刪除過時檔案之後,若還是超過檔案總大小限制,則繼續刪除
    if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
        NSUInteger desiredCacheSize = self.maxCacheSize / 2;
        NSArray<NSURL *> *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent usingComparator:^NSComparisonResult(id  _Nonnull obj1, id  _Nonnull obj2) {
            return [obj1[NSURLContentAccessDateKey] compare:obj2[NSURLContentAccessDateKey]];
        }];
        for (NSURL *fileURL in sortedFiles) {
            if ([self.fileManager removeItemAtURL:fileURL error:nil]) {
                NSDictionary<NSString *, id> *resourceValues = cacheFiles[fileURL];
                NSNumber *fileSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
                currentCacheSize -= fileSize.unsignedIntegerValue;
                if (currentCacheSize < desiredCacheSize) {
                    break;
                }
            }
        }
    }
}
複製程式碼
  • 何時觸發deleteOldFiles函式,以保證磁碟快取中的maxCacheAgemaxCacheSize

因為我們並不知道何時磁碟總大小會超過限制或快取的檔案過時,假如使用NSTimer週期性去檢查,會導致不必要的效能消耗,也不好確定輪詢的時間。為了避免這些問題,我們可以考慮在應用進入後臺時,啟動後臺任務去完成檢查和清理工作。

[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onDidEnterBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil];

- (void)onDidEnterBackground:(NSNotification *)notification {
    [self backgroundDeleteOldFiles];
}

- (void)backgroundDeleteOldFiles {
    Class UIApplicationClass = NSClassFromString(@"UIApplication");
    if(!UIApplicationClass || ![UIApplicationClass respondsToSelector:@selector(sharedApplication)]) {
        return;
    }
    UIApplication *application = [UIApplication performSelector:@selector(sharedApplication)];
    __block UIBackgroundTaskIdentifier bgTask = [application beginBackgroundTaskWithExpirationHandler:^{
        [application endBackgroundTask:bgTask];
        bgTask = UIBackgroundTaskInvalid;
    }];
    //交給後臺去完成
    void(^deleteBlock)(void) = ^ {
        if ([self.diskCache respondsToSelector:@selector(deleteOldFiles)]) {
            [self.diskCache deleteOldFiles];
        }
        dispatch_async(dispatch_get_main_queue(), ^{
            [application endBackgroundTask:bgTask];
            bgTask = UIBackgroundTaskInvalid;
        });
    };
    dispatch_async(self.ioQueue, deleteBlock);
}
複製程式碼

四、快取架構

從零開始打造一個iOS圖片載入框架(三)
如上圖所示,JImageManager作為管理類,暴露相關設定介面,可以用於外部自定義快取相關內容;JImageCache為快取管理類,實際上為中介者,統一管理快取配置、記憶體快取和磁碟快取等,並將相關操作交給NSCacheJDiskCache來完成。

這裡以儲存圖片為例:

- (void)storeImage:(UIImage *)image imageData:(NSData *)imageData forKey:(NSString *)key completion:(void (^)(void))completionBlock {
    if (!key || key.length == 0 || (!image && !imageData)) {
        SAFE_CALL_BLOCK(completionBlock);
        return;
    }
    void(^storeBlock)(void) = ^ {
        if (self.cacheConfig.shouldCacheImagesInMemory) {
            if (image) {
                [self.memoryCache setObject:image forKey:key cost:image.memoryCost];
            } else if (imageData) {
                UIImage *decodedImage = [[JImageCoder shareCoder] decodeImageWithData:imageData];
                [self.memoryCache setObject:decodedImage forKey:key cost:decodedImage.memoryCost];
            }
        }
        if (self.cacheConfig.shouldCacheImagesInDisk) {
            if (imageData) {
                [self.diskCache storeImageData:imageData forKey:key];
            } else if (image) {
                NSData *data = [[JImageCoder shareCoder] encodedDataWithImage:image];
                if (data) {
                    [self.diskCache storeImageData:data forKey:key];
                }
            }
        }
        SAFE_CALL_BLOCK(completionBlock);
    };
    dispatch_async(self.ioQueue, storeBlock);
}
複製程式碼

這裡定義了一個關於block的巨集,為了避免引數傳遞的blocknil,需要在使用前對block進行判斷是否為nil

#define SAFE_CALL_BLOCK(blockFunc, ...)    \
    if (blockFunc) {                        \
        blockFunc(__VA_ARGS__);              \
    }
複製程式碼

第二章節中講解了NSData轉換為image的實現,考慮到一種情況,若引數中的imageData為空,但image中包含資料,那麼我們也應該將image儲存下來。若要將資料儲存到磁碟中,這就需要我們將image轉換為NSData了。

五、image轉換為NSData

對於PNG或JPEG格式的圖片,處理起來比較簡單,我們可以分別呼叫UIImagePNGRepresentationUIImageJPEGRepresentation即可轉換為NSData

  • 圖片角度的處理

由於拍攝角度和拍攝裝置的不同,如果不對圖片進行角度處理,那麼很有可能出現圖片倒過來或側過來的情況。為了避免這一情況,那麼我們在對圖片儲存時需要將圖片“擺正”,然後再儲存。具體相關可以看這裡

- (UIImage *)normalizedImage {
    if (self.imageOrientation == UIImageOrientationUp) { //圖片方向是正確的
        return self;
    }
    UIGraphicsBeginImageContextWithOptions(self.size, NO, self.scale);
    [self drawInRect:CGRectMake(0, 0, self.size.width, self.size.height)];
    UIImage *normalizedImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return normalizedImage;
}
複製程式碼

如上所示,當圖片方向不正確是,利用drawInRect方法對影像進行重新繪製,這樣可以保證繪製之後的圖片方向是正確的。

- (NSData *)encodedDataWithImage:(UIImage *)image {
    if (!image) {
        return nil;
    }
    switch (image.imageFormat) {
        case JImageFormatPNG:
        case JImageFormatJPEG:
            return [self encodedDataWithImage:image imageFormat:image.imageFormat];
        case JImageFormatGIF:{
            return [self encodedGIFDataWithImage:image];
        }
        case JImageFormatUndefined:{
            if (JCGImageRefContainsAlpha(image.CGImage)) {
                return [self encodedDataWithImage:image imageFormat:JImageFormatPNG];
            } else {
                return [self encodedDataWithImage:image imageFormat:JImageFormatJPEG];
            }
        }
    }
}
//對PNG和JPEG格式圖片的處理
- (nullable NSData *)encodedDataWithImage:(UIImage *)image imageFormat:(JImageFormat)imageFormat {
    UIImage *fixedImage = [image normalizedImage];
    if (imageFormat == JImageFormatPNG) {
        return UIImagePNGRepresentation(fixedImage);
    } else {
        return UIImageJPEGRepresentation(fixedImage, 1.0);
    }
}
複製程式碼

如上所示,對PNG和JPEG圖片的處理都比較簡單。現在主要來講解下如何將GIF圖片轉換為NSData型別儲存到磁碟中。我們先回顧下GIF圖片中NSData如何轉換為image

NSInteger loopCount = 0;
CFDictionaryRef properties = CGImageSourceCopyProperties(source, NULL);
if (properties) { //獲取loopcount
    CFDictionaryRef gif = CFDictionaryGetValue(properties, kCGImagePropertyGIFDictionary);
    if (gif) {
        CFTypeRef loop = CFDictionaryGetValue(gif, kCGImagePropertyGIFLoopCount);
        if (loop) {
            CFNumberGetValue(loop, kCFNumberNSIntegerType, &loopCount);
        }
    }
    CFRelease(properties);
}
NSMutableArray<NSNumber *> *delayTimeArray = [NSMutableArray array]; //儲存每張圖片對應的展示時間
NSMutableArray<UIImage *> *imageArray = [NSMutableArray array]; //儲存圖片
NSTimeInterval duration = 0;
for (size_t i = 0; i < count; i ++) {
    CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, i, NULL);
    if (!imageRef) {
        continue;
    }
    //獲取圖片
    UIImage *image = [[UIImage alloc] initWithCGImage:imageRef];
    [imageArray addObject:image];
    CGImageRelease(imageRef);
    //獲取delayTime
    float delayTime = kJAnimatedImageDefaultDelayTimeInterval;
    CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(source, i, NULL);
    if (properties) {
        CFDictionaryRef gif = CFDictionaryGetValue(properties, kCGImagePropertyGIFDictionary);
        if (gif) {
            CFTypeRef value = CFDictionaryGetValue(gif, kCGImagePropertyGIFUnclampedDelayTime);
            if (!value) {
                value = CFDictionaryGetValue(gif, kCGImagePropertyGIFDelayTime);
            }
            if (value) {
                CFNumberGetValue(value, kCFNumberFloatType, &delayTime);
                if (delayTime < ((float)kJAnimatedImageDelayTimeIntervalMinimum - FLT_EPSILON)) {
                    delayTime = kJAnimatedImageDefaultDelayTimeInterval;
                }
            }
        }
        CFRelease(properties);
    }
    duration += delayTime;
    [delayTimeArray addObject:@(delayTime)];
}
複製程式碼

我們可以看到,NSData轉換為image主要是獲取loopCount、imagesdelaytimes,那麼我們從image轉換為NSData,即反過來,將這些屬性寫入到資料裡即可。

- (nullable NSData *)encodedGIFDataWithImage:(UIImage *)image {
    NSMutableData *gifData = [NSMutableData data];
    CGImageDestinationRef imageDestination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)gifData, kUTTypeGIF, image.images.count, NULL);
    if (!imageDestination) {
        return nil;
    }
    if (image.images.count == 0) {
        CGImageDestinationAddImage(imageDestination, image.CGImage, nil);
    } else {
        NSUInteger loopCount = image.loopCount;
        NSDictionary *gifProperties = @{(__bridge NSString *)kCGImagePropertyGIFDictionary : @{(__bridge NSString *)kCGImagePropertyGIFLoopCount : @(loopCount)}};
        CGImageDestinationSetProperties(imageDestination, (__bridge CFDictionaryRef)gifProperties);//寫入loopCount
        size_t count = MIN(image.images.count, image.delayTimes.count);
        for (size_t i = 0; i < count; i ++) {
            NSDictionary *properties = @{(__bridge NSString *)kCGImagePropertyGIFDictionary : @{(__bridge NSString *)kCGImagePropertyGIFDelayTime : image.images[i]}};
            CGImageDestinationAddImage(imageDestination, image.images[i].CGImage, (__bridge CFDictionaryRef)properties); //寫入images和delaytimes
        }
    }
    if (CGImageDestinationFinalize(imageDestination) == NO) {
        gifData = nil;
    }
    CFRelease(imageDestination);
    return [gifData copy];
}
複製程式碼

六、總結

本章節主要對快取進行了重構,使其功能更完善,易擴充套件,另外還補充講解了對GIF圖片的儲存。

相關文章