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

1、前言

目前比較流行的圖片加載框架主要包括:SDWebImageYYWebImagePINRemoteImage等。這裏也有篇文章,很好地介紹了三個框架的優缺點:YYWebImage,SDWebImage和PINRemoteImage比較git

關於打造一個iOS圖片加載框架的緣由

  • 鍛鍊本身的架構能力
  • 雖然讀過上面優秀框架的相關源碼,但讀的過程當中老是容易忽略不少實現細節,以及讀完以後很難獲取到真正本身所須要的內容
  • 加深本身對iOS圖片加載相關方面的理解

2、圖片的加載

1. 圖片的簡單加載

咱們先從最簡單的角度去看待加載一個網絡圖片,無非是經歷:下載圖片->顯示圖片這麼個過程。github

- (void)downloadImage {
    NSString *imageUrl = @"https://user-gold-cdn.xitu.io/2019/3/25/169b406dfc5fe46e";
    NSURL *url = [NSURL URLWithString:imageUrl];
    NSURLSession *session = [NSURLSession sharedSession];
    __weak typeof (self) weakSelf = self;
    NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (!error && data) {
            UIImage *image = [UIImage imageWithData:data];
            __strong typeof(weakSelf) strongSelf = weakSelf;
            if (strongSelf) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    strongSelf.imageView.image = image;
                });
            }
        }
    }];
    [task resume];
}
複製代碼

2. 內存緩存

這樣的實現方式很是簡單,但有個致命的問題就是每次從新加載該圖片時,都須要從新下載。所以,須要引入緩存來保存該圖片,避免圖片的屢次下載。這裏使用的是咱們經常使用的NSCache類。 爲了不將全部相關邏輯都放在viewcontroller中,咱們這裏建立一個JImageDownloader來處理圖片下載和緩存邏輯。segmentfault

@interface JImageDownloader : NSObject
+ (instancetype)shareInstance;
- (void)fetchImageWithURL:(NSString *)url completion:(void(^)(UIImage * _Nullable image, NSError * _Nullable error))completionBlock;
@end

@interface JImageDownloader()
@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSCache *imageCache;
@end
@implementation JImageDownloader
+ (instancetype)shareInstance {
    static JImageDownloader *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[JImageDownloader alloc] init];
        [instance setup];
    });
    return instance;
}
- (void)setup {
    self.session = [NSURLSession sharedSession];
    self.imageCache = [[NSCache alloc] init];
}
- (void)fetchImageWithURL:(NSString *)url completion:(void (^)(UIImage * _Nullable, NSError * _Nullable))completionBlock {
    if (!url || url.length == 0) {
        return;
    }
    //從緩存中獲取
    UIImage *cacheImage = [self.imageCache objectForKey:url];
    if (cacheImage) {
        completionBlock(cacheImage, nil);
        [MBProgressHUD showGlobalHUDWithTitle:@"image from memory cache"];
        return;
    }
    __weak typeof (self) weakSelf = self;
    NSURLSessionDataTask *dataTask = [self.session dataTaskWithURL:[NSURL URLWithString:url] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        UIImage *image = nil;
        if (!error && data) {
            image = [UIImage imageWithData:data];
            __strong typeof (weakSelf) strongSelf = weakSelf;
            if (strongSelf && image) { //將圖片放置在緩存中
                [strongSelf.imageCache setObject:image forKey:url];
            }
        }
        dispatch_async(dispatch_get_main_queue(), ^{
            if (error) {
                [MBProgressHUD showGlobalHUDWithTitle:error.description];
            } else {
                [MBProgressHUD showGlobalHUDWithTitle:@"image from network"];
            }
            completionBlock(image, error);
        });
    }];
    [dataTask resume];
}
@end
複製代碼

那麼咱們就能夠在viewcontroller中直接調用該方法便可:緩存

- (void)downloadImage {
    NSString *imageUrl = @"https://user-gold-cdn.xitu.io/2019/3/25/169b406dfc5fe46e";
    __weak typeof(self) weakSelf = self;
    [[JImageDownloader shareInstance] fetchImageWithURL:imageUrl completion:^(UIImage * _Nullable image, NSError * _Nullable error) {
        __strong typeof (weakSelf) strongSelf = weakSelf;
        if (strongSelf && image) {
            strongSelf.imageView.image = image;
        }
    }];
}
複製代碼

3. 磁盤緩存

上面咱們引入來內存緩存來避免屢次下載同一張圖片,但內存緩存只能侷限於App存活期。當App退出時,對應的圖片緩存就會被銷燬。這樣咱們下一次進入到App,請求圖片時,仍是要從網絡中下載。爲此,咱們再引入磁盤緩存來保證App下一次啓動時,能夠從磁盤中獲取,而不用從網絡中獲取。考慮到若是在原來的JImageDownloader中去增長磁盤緩存的話,那麼將增大它的複雜性。所以,新建一個JImageCacheManager來專門負責緩存處理。bash

JImageCacheManager.h:目前只考慮簡單的存取功能網絡

@interface JImageCacheManager : NSObject
+ (instancetype)shareManager;
- (UIImage *_Nullable)queryImageCacheForKey:(NSString *)key;
- (void)storeImage:(UIImage *_Nullable)image forKey:(NSString *)key;
@end
複製代碼

實現磁盤緩存的話,咱們就須要和NSFileManager打交道,那麼涉及到的問題就遠比內存緩存要更多些。session

a. 磁盤緩存應該放在哪裏?架構

NSArray<NSString *> *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
self.diskCachePath = [paths[0] stringByAppendingPathComponent:@"com.jimage.cache"];
複製代碼

b. 緩存的key是否可使用url-string?框架

固然不能,由於url-string中格式大體爲https://xxx/xx/xxx.png,若是使用這種方式會致使文件沒法存取(親測)。因此咱們須要對url-string進行MD5加密處理,這裏參考的SDWebImage的實現方式。異步

- (nullable NSString *)cachedFileNameForKey:(nullable NSString *)key {
    const char *str = key.UTF8String;
    if (str == NULL) {
        str = "";
    }
    unsigned char r[16];
    CC_MD5(str, (CC_LONG)strlen(str), r);
    NSURL *keyURL = [NSURL URLWithString:key];
    NSString *ext = keyURL ? keyURL.pathExtension : key.pathExtension;
    NSString *filename = [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%@",
                          r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10],
                          r[11], r[12], r[13], r[14], r[15], ext.length == 0 ? @"" : [NSString stringWithFormat:@".%@", ext]];
    return filename;
}
複製代碼

c. 如何對圖片進行存取?

圖片獲取的方式比較容易,直接使用imageWithData:方法便可將NSData轉化爲image,主要是如何將image轉化爲NSData?系統提供了UIImagePNGRepresentationUIImageJPEGRepresentation兩個方法來分別針對png、jpeg格式進行不一樣的處理。那麼這裏就須要咱們在轉換前,對image的格式進行判斷。咱們知道png格式是帶alpha通道的,而jpeg沒有。所以,咱們能夠根據是否含有alpha通道來判斷.

- (BOOL)containsAlphaWithCGImage:(CGImageRef)imageRef {
    if (!imageRef) {
        return NO;
    }
    CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef);
    BOOL hasAlpha = !(alphaInfo == kCGImageAlphaNone || alphaInfo == kCGImageAlphaNoneSkipFirst || alphaInfo == kCGImageAlphaNoneSkipLast);
    return hasAlpha;
}

if ([self containsAlphaWithCGImage:image.CGImage]) {
    data = UIImagePNGRepresentation(image);
} else {
    data = UIImageJPEGRepresentation(image, 1.0);
}
複製代碼

解決了以上問題以後,咱們就能夠增長磁盤緩存功能了。具體以下:

#import "JImageCacheManager.h"
#import <CommonCrypto/CommonDigest.h>

@interface JImageCacheManager ()
@property (nonatomic, strong) NSCache *imageMemoryCache;
@property (nonatomic, copy) NSString *diskCachePath;
@property (nonatomic, strong) NSFileManager *fileManager;
@end
@implementation JImageCacheManager
+ (instancetype)shareManager {
    static JImageCacheManager *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[JImageCacheManager alloc] init];
        [instance setup];
    });
    return instance;
}
- (void)setup {
    self.imageMemoryCache = [[NSCache alloc] init];
    self.fileManager = [NSFileManager new];
    NSArray<NSString *> *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
    self.diskCachePath = [paths[0] stringByAppendingPathComponent:@"com.jimage.cache"];
}
- (UIImage *)queryImageCacheForKey:(NSString *)key {
    if (!key || key.length == 0) {
        return nil;
    }
    UIImage *memoryCache = [self.imageMemoryCache objectForKey:key];
    if (memoryCache) { //從內存緩存中獲取
        NSLog(@"image from memory cache");
        return memoryCache;
    }
    NSString *filepath = [self.diskCachePath stringByAppendingPathComponent:[self cachedFileNameForKey:key]];
    NSData *data = [NSData dataWithContentsOfFile:filepath];
    if (data) {
        UIImage *diskCache = [UIImage imageWithData:data];
        NSLog(@"image from disk cache");
        if (diskCache) { //從磁盤緩存中獲取
            [self.imageMemoryCache setObject:diskCache forKey:key];
        }
        return diskCache;
    }
    return nil;
}

- (void)storeImage:(UIImage *)image forKey:(NSString *)key {
    if (!image || !key || key.length == 0) {
        return;
    }
    [self.imageMemoryCache setObject:image forKey:key]; //存儲到內存中
    NSData *data = nil;
    if ([self containsAlphaWithCGImage:image.CGImage]) {
        data = UIImagePNGRepresentation(image);
    } else {
        data = UIImageJPEGRepresentation(image, 1.0);
    }
    if (!data) {
        return;
    }
    if (![self.fileManager fileExistsAtPath:self.diskCachePath]) {
        [self.fileManager createDirectoryAtPath:self.diskCachePath withIntermediateDirectories:YES attributes:nil error:nil];
    }
    NSString *cachePath = [self.diskCachePath stringByAppendingPathComponent:[self cachedFileNameForKey:key]];
    NSURL *fileURL = [NSURL fileURLWithPath:cachePath];
    [data writeToURL:fileURL atomically:YES]; //存儲到磁盤中
}

#pragma mark - util methods
- (BOOL)containsAlphaWithCGImage:(CGImageRef)imageRef {
    if (!imageRef) {
        return NO;
    }
    CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef);
    BOOL hasAlpha = !(alphaInfo == kCGImageAlphaNone || alphaInfo == kCGImageAlphaNoneSkipFirst || alphaInfo == kCGImageAlphaNoneSkipLast);
    return hasAlpha;
}

- (nullable NSString *)cachedFileNameForKey:(nullable NSString *)key {
    const char *str = key.UTF8String;
    if (str == NULL) {
        str = "";
    }
    unsigned char r[16];
    CC_MD5(str, (CC_LONG)strlen(str), r);
    NSURL *keyURL = [NSURL URLWithString:key];
    NSString *ext = keyURL ? keyURL.pathExtension : key.pathExtension;
    NSString *filename = [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%@",
                          r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10],
                          r[11], r[12], r[13], r[14], r[15], ext.length == 0 ? @"" : [NSString stringWithFormat:@".%@", ext]];
    return filename;
}
@end
複製代碼

增長完JImageCacheManager以後,獲取圖片的方法就能夠改爲以下:

- (void)fetchImageWithURL:(NSString *)url completion:(void (^)(UIImage * _Nullable, NSError * _Nullable))completionBlock {
    if (!url || url.length == 0) {
        return;
    }
    NSURL *URL = [NSURL URLWithString:url];
    if (!URL) {
        return;
    }
    UIImage *cacheImage = [[JImageCacheManager shareManager] queryImageCacheForKey:url]; //獲取緩存數據
    if (cacheImage) {
        completionBlock(cacheImage, nil);
        return;
    }
    __weak typeof (self) weakSelf = self;
    NSURLSessionDataTask *dataTask = [self.session dataTaskWithURL:URL completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        UIImage *image = nil;
        if (!error && data) {
            image = [UIImage imageWithData:data];
            __strong typeof (weakSelf) strongSelf = weakSelf;
            if (strongSelf && image) {
                [[JImageCacheManager shareManager] storeImage:image forKey:url]; //寫入緩存中
            }
        }
        dispatch_async(dispatch_get_main_queue(), ^{
            if (error) {
                NSLog(@"fetch image from net fail:%@", error.description);
            } else {
                NSLog(@"image from network");
            }
            completionBlock(image, error);
        });
    }];
    [dataTask resume];
}
複製代碼

咱們能夠看到緩存相關的操做就只有獲取/存儲兩個操做了,這樣能保證JImageDownloaderJImageCacheManager的單一責任。

4.異步處理

雖然上面增長了內存和磁盤緩存,但存在一個問題,咱們知道對磁盤的讀/寫是很是耗時的,若是直接放在主線程中進行處理,那麼勢必會影響到性能,致使卡頓。爲此,咱們應該將對磁盤的讀寫操做放在子線程中進行處理。

JImageCacheManager.h:爲了實現異步處理,咱們須要將接口改爲block返回

typedef NS_ENUM(NSUInteger, JImageCacheType) {
    JImageCacheTypeNone,
    JImageCacheTypeMemory,
    JImageCacheTypeDisk
};
@interface JImageCacheManager : NSObject
+ (instancetype)shareManager;
- (void)queryImageCacheForKey:(NSString *)key completionBlock:(void(^)(UIImage *_Nullable image, JImageCacheType cacheType))completionBlock;
- (void)storeImage:(UIImage *_Nullable)image forKey:(NSString *)key;
@end
複製代碼

引入隊列來實現異步處理操做

@interface JImageCacheManager ()
...
@property (nonatomic, strong) dispatch_queue_t ioQueue;
@end
- (void)setup {
    ...
    self.ioQueue = dispatch_queue_create("com.jimage.cache", DISPATCH_QUEUE_SERIAL);
}
複製代碼

將讀取/寫入緩存封裝爲block,加入到隊列中異步處理:

- (void)queryImageCacheForKey:(NSString *)key completionBlock:(void(^)(UIImage * _Nullable, JImageCacheType))completionBlock{
    if (!key || key.length == 0) {
        completionBlock(nil, JImageCacheTypeNone);
        return;
    }
    UIImage *memoryCache = [self.imageMemoryCache objectForKey:key];
    if (memoryCache) {
        NSLog(@"image from memory cache");
        completionBlock(memoryCache, JImageCacheTypeMemory);
        return;
    }
    void(^queryDiskBlock)(void) = ^ {
        NSString *filepath = [self.diskCachePath stringByAppendingPathComponent:[self cachedFileNameForKey:key]];
        NSData *data = [NSData dataWithContentsOfFile:filepath];
        UIImage *diskCache = nil;
        JImageCacheType cacheType = JImageCacheTypeNone;
        if (data) {
            diskCache = [UIImage imageWithData:data];
            if (diskCache) {
                cacheType = JImageCacheTypeDisk;
                [self.imageMemoryCache setObject:diskCache forKey:key];
                NSLog(@"image from disk cache");
            }
        }
        dispatch_async(dispatch_get_main_queue(), ^{
            completionBlock(diskCache, cacheType);
        });
    };
    dispatch_async(self.ioQueue, queryDiskBlock);//加入到隊列中異步處理
}

- (void)storeImage:(UIImage *)image forKey:(NSString *)key {
    if (!image || !key || key.length == 0) {
        return;
    }
    [self.imageMemoryCache setObject:image forKey:key];
    void(^storeDiskBlock)(void) = ^ {
        NSData *data = nil;
        if ([self containsAlphaWithCGImage:image.CGImage]) {
            data = UIImagePNGRepresentation(image);
        } else {
            data = UIImageJPEGRepresentation(image, 1.0);
        }
        if (!data) {
            return;
        }
        if (![self.fileManager fileExistsAtPath:self.diskCachePath]) {
            [self.fileManager createDirectoryAtPath:self.diskCachePath withIntermediateDirectories:YES attributes:nil error:nil];
        }
        NSString *cachePath = [self.diskCachePath stringByAppendingPathComponent:[self cachedFileNameForKey:key]];
        NSURL *fileURL = [NSURL fileURLWithPath:cachePath];
        [data writeToURL:fileURL atomically:YES];
    };
    dispatch_async(self.ioQueue, storeDiskBlock);
}
複製代碼

那麼對應的獲取圖片的方法修改以下:

- (void)fetchImageWithURL:(NSString *)url completion:(void (^)(UIImage * _Nullable, NSError * _Nullable))completionBlock {
    if (!url || url.length == 0) {
        return;
    }
    NSURL *URL = [NSURL URLWithString:url];
    if (!URL) {
        return;
    }
    [[JImageCacheManager shareManager] queryImageCacheForKey:url completionBlock:^(UIImage * _Nullable cacheImage, JImageCacheType cacheType) {
        if (cacheImage) {
            completionBlock(cacheImage, nil);
            return;
        }
        NSURLSessionDataTask *dataTask = [self.session dataTaskWithURL:URL completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
            UIImage *image = nil;
            if (!error && data) {
                image = [UIImage imageWithData:data];
                if (image) {
                    [[JImageCacheManager shareManager] storeImage:image forKey:url];
                }
            }
            if (error) {
                NSLog(@"fetch image from net fail:%@", error.description ? : @"");
            } else {
                NSLog(@"image from network");
            }
            dispatch_async(dispatch_get_main_queue(), ^{
                completionBlock(image, error);
            });
        }];
        [dataTask resume];
    }];
}
複製代碼

3、小結

本小節主要描述了實現圖片加載框架的一個簡易流程,包括引入內存/磁盤緩存。看似這一過程比較簡單,可是須要考慮的細節仍是不少。好比磁盤緩存中url->path的轉化,以及如何使用隊列來實現磁盤讀寫的異步執行。

相關文章
相關標籤/搜索