一、前言
目前比較流行的圖片載入框架主要包括:SDWebImage、YYWebImage和PINRemoteImage等。這裡也有篇文章,很好地介紹了三個框架的優缺點:YYWebImage,SDWebImage和PINRemoteImage比較。
關於打造一個iOS圖片載入框架的原因
- 鍛鍊自己的架構能力
- 雖然讀過上面優秀框架的相關原始碼,但讀的過程中總是容易忽略很多實現細節,以及讀完之後很難獲取到真正自己所需要的內容
- 加深自己對iOS圖片載入相關方面的理解
二、圖片的載入
1. 圖片的簡單載入
我們先從最簡單的角度去看待載入一個網路圖片,無非是經歷:下載圖片->顯示圖片這麼個過程。
- (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
來處理圖片下載和快取邏輯。
@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
來專門負責快取處理。
JImageCacheManager.h
:目前只考慮簡單的存取功能
@interface JImageCacheManager : NSObject
+ (instancetype)shareManager;
- (UIImage *_Nullable)queryImageCacheForKey:(NSString *)key;
- (void)storeImage:(UIImage *_Nullable)image forKey:(NSString *)key;
@end
複製程式碼
實現磁碟快取的話,我們就需要和NSFileManager
打交道,那麼涉及到的問題就遠比記憶體快取要更多些。
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
?系統提供了UIImagePNGRepresentation
和UIImageJPEGRepresentation
兩個方法來分別針對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];
}
複製程式碼
我們可以看到快取相關的操作就只有獲取/儲存兩個操作了,這樣能保證JImageDownloader
和JImageCacheManager
的單一責任。
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];
}];
}
複製程式碼
三、小結
本小節主要描述了實現圖片載入框架的一個簡易流程,包括引入記憶體/磁碟快取。看似這一過程比較簡單,但是需要考慮的細節還是很多。比如磁碟快取中url->path的轉化,以及如何使用佇列來實現磁碟讀寫的非同步執行。