从零开始打造一个iOS图片加载框架(三)

1、前言

在上一个章节主要描述了如何实现对GIF图片的支持,这样图片的加载功能就大体完成了。但目前框架只是进行了一些简单的封装,还有不少功能还没有完成。咱们在第一节中,使用了NSCache来做为内存缓存和NSFileManager来简单地封装为磁盘缓存,如今咱们将对缓存进行重构。git

2、内存缓存

iOS系统自己就提供了NSCache来做为内存缓存,它是线程安全的,且能保证在内存紧张的状况下,会自动回收一部份内存。所以,咱们就没必要再造轮子来实现一个内存缓存了。为了提升框架的灵活性,咱们能够提供一个接口来支持外部的扩展。github

@interface JImageManager : NSObject
+ (instancetype)shareManager;
- (void)setMemoryCache:(NSCache *)memoryCache;
@end
复制代码

3、磁盘缓存

磁盘缓存简单来讲就是对文件增删查改等操做,再复杂点就是可以控制文件保存的时间,以及文件的总大小。缓存

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属性

实现该属性的大体流程:根据设置的存活时间计算出文件可保留的最先时间->遍历文件,进行时间比对->若文件被访问的时间早于最先时间,那么删除对应的文件app

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;
            }
        }
    }
}
复制代码

为何是删除文件直到总限制大小的一半才中止删除?因为访问和删除文件是须要消耗必定性能的,若只是达到总限制大小就中止,那么一旦再存入一小部分文件,就很快达到限制,就必须再执行该操做了。async

如上,咱们能够看到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周期性去检查,会致使没必要要的性能消耗,也很差肯定轮询的时间。为了不这些问题,咱们能够考虑在应用进入后台时,启动后台任务去完成检查和清理工做。oop

[[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);
}
复制代码

4、缓存架构

如上图所示, 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了。

5、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];
}
复制代码

6、总结

本章节主要对缓存进行了重构,使其功能更完善,易扩展,另外还补充讲解了对GIF图片的存储。

相关文章
相关标签/搜索