一、前言
上一章節主要對快取進行了重構,使其更具擴充套件性。本章節將對網路載入部分進行重構,並增加進度回撥和取消載入功能。
二、進度載入
對於一些size較大的圖片(特別是GIF圖片),從網路中下載下來需要一段時間。為了避免這段載入時間顯示空白,往往會通過設定placeholder或顯示載入進度。
在此之前,我們是通過NSURLSession
的block
回撥來直接獲取到網路所獲取的內容
NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
// 對data進行處理
}];
複製程式碼
顯然,這麼處理我們只能獲取到最終的結果,沒辦法獲取到進度。為了獲取到下載的實時進度,我們就需要自己去實現NSURLSession
的協議NSURLSessionDelegate
。
NSURLSession
的協議比較多,具體可以檢視官網。這裡只列舉當前所需要用到的協議方法:
#pragma mark - NSURLSessionDataDelegate
//該方法可以獲取到下載資料的大小
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler;
//該方法可以獲取到分段下載的資料
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data;
#pragma mark - NSURLSessionTaskDelgate
//該回撥錶示下載完成
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error;
複製程式碼
1. 回撥block
為了實現進度回撥下載,我們需要定義兩種
block
型別,一種是下載過程中返回進度的block
,另一種是下載完成之後對資料的回撥。
typedef void(^JImageDownloadProgressBlock)(NSInteger receivedSize, NSInteger expectedSize, NSURL *_Nullable targetURL);
typedef void(^JImageDownloadCompletionBlock)(NSData *_Nullable imageData, NSError *_Nullable error, BOOL finished);
複製程式碼
考慮到一個下載物件可能存在多個監聽,比如兩個imageView
的下載地址為同一個url
。我們需要用資料結構將對應的block
暫存起來,並在下載過程和下載完成之後回撥block
。
typedef NSMutableDictionary<NSString *, id> JImageCallbackDictionary;
static NSString *const kImageProgressCallback = @"kImageProgressCallback";
static NSString *const kImageCompletionCallback = @"kImageCompletionCallback";
#pragma mark - callbacks
- (id)addProgressHandler:(JImageDownloadProgressBlock)progressBlock withCompletionBlock:(JImageDownloadCompletionBlock)completionBlock {
JImageCallbackDictionary *callback = [NSMutableDictionary new];
if(progressBlock) [callback setObject:[progressBlock copy] forKey:kImageProgressCallback];
if(completionBlock) [callback setObject:[completionBlock copy] forKey:kImageCompletionCallback];
LOCK(self.callbacksLock);
[self.callbackBlocks addObject:callback];
UNLOCK(self.callbacksLock);
return callback;
}
- (nullable NSArray *)callbacksForKey:(NSString *)key {
LOCK(self.callbacksLock);
NSMutableArray *callbacks = [[self.callbackBlocks valueForKey:key] mutableCopy];
UNLOCK(self.callbacksLock);
[callbacks removeObject:[NSNull null]];
return [callbacks copy];
}
複製程式碼
如上所示,我們用NSArray<NSDictionary>
這樣的資料結構來儲存block
,並用不同的key來區分progressBlock
和completionBlock
。這麼做的目的是統一管理回撥,減少資料成員變數,否則我們需要使用兩個NSArray
來分別儲存progressBlock
和completionBlock
。此外,我們還可以使用NSArray
的valueForKey
方法便捷地根據key來獲取到對應的block
。
#define LOCK(lock) dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
#define UNLOCK(lock) dispatch_semaphore_signal(lock);
@property (nonatomic, strong) dispatch_semaphore_t callbacksLock;
self.callbacksLock = dispatch_semaphore_create(1);
複製程式碼
由於對block
的新增和移除的呼叫可能來自不同執行緒,我們這裡使用鎖來避免由於時序問題而導致資料錯誤。
2. delegate實現
if (!self.session) {
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
self.session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
}
self.dataTask = [self.session dataTaskWithRequest:self.request];
[self.dataTask resume];
for (JImageDownloadProgressBlock progressBlock in [self callbacksForKey:kImageProgressCallback]){
progressBlock(0, NSURLResponseUnknownLength, self.request.URL);
}
複製程式碼
如上所示,我們如果要自己去實現URLSession
的協議的話,不能簡單地使用[NSURLSession sharedSession]
來建立,需要通過sessionWithConfiguration
方法來實現。
#pragma mark - NSURLSessionDataDelegate
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
//獲取到對應的資料總大小
NSInteger expectedSize = (NSInteger)response.expectedContentLength;
self.expectedSize = expectedSize > 0 ? expectedSize : 0;
for (JImageDownloadProgressBlock progressBlock in [self callbacksForKey:kImageProgressCallback]) {
progressBlock(0, self.expectedSize, self.request.URL);
}
completionHandler(NSURLSessionResponseAllow);
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
if (!self.imageData) {
self.imageData = [[NSMutableData alloc] initWithCapacity:self.expectedSize];
}
[self.imageData appendData:data]; //append分段的資料,並回撥下載進度
for (JImageDownloadProgressBlock progressBlock in [self callbacksForKey:kImageProgressCallback]) {
progressBlock(self.imageData.length, self.expectedSize, self.request.URL);
}
}
#pragma mark - NSURLSessionTaskDelgate
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
for (JImageDownloadCompletionBlock completionBlock in [self callbacksForKey:kImageCompletionCallback]) { //下載完成,回撥總資料
completionBlock([self.imageData copy], error, YES);
}
[self done];
}
複製程式碼
這裡要值得注意的是didReceiveResponse
方法,獲取完資料的大小之後,我們要返回一個NSURLSessionResponseDisposition
型別。這麼做的目的是告訴服務端我們接下來的操作是什麼,如果我們不需要下載資料,那麼可以返回NSURLSessionResponseCancel
,反之則傳入NSURLSessionResponseAllow
。
三、取消載入
對於一些較大的圖片,可能存在載入到一半之後,使用者不想看了,點選返回。此時,我們應該取消正在載入的任務,以避免不必要的消耗。圖片的載入耗時主要來自於網路下載和磁碟載入兩方面,所以這兩個過程我們都需要支援取消操作。
1. 取消網路下載
對於任務的取消,系統提供了
NSOperation
物件,通過呼叫cancel
方法來實現取消當前的任務。具體關於NSOperation
的使用可以檢視這裡。
@interface JImageDownloadOperation : NSOperation <JImageOperation>
- (instancetype)initWithRequest:(NSURLRequest *)request;
- (id)addProgressHandler:(JImageDownloadProgressBlock)progressBlock withCompletionBlock:(JImageDownloadCompletionBlock)completionBlock;
- (BOOL)cancelWithToken:(id)token;
@end
@interface JImageDownloadOperation() <NSURLSessionDataDelegate, NSURLSessionTaskDelegate>
@property (nonatomic, assign, getter=isFinished) BOOL finished;
@end
@implementation JImageDownloadOperation
@synthesize finished = _finished;
#pragma mark - NSOperation
- (void)start {
if (self.isCancelled) {
self.finished = YES;
[self reset];
return;
}
if (!self.session) {
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
self.session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
}
self.dataTask = [self.session dataTaskWithRequest:self.request];
[self.dataTask resume]; //開始網路下載
for (JImageDownloadProgressBlock progressBlock in [self callbacksForKey:kImageProgressCallback]){
progressBlock(0, NSURLResponseUnknownLength, self.request.URL);
}
}
- (void)cancel {
if (self.finished) {
return;
}
[super cancel];
if (self.dataTask) {
[self.dataTask cancel]; //取消網路下載
}
[self reset];
}
- (void)reset {
LOCK(self.callbacksLock);
[self.callbackBlocks removeAllObjects];
UNLOCK(self.callbacksLock);
self.dataTask = nil;
if (self.session) {
[self.session invalidateAndCancel];
self.session = nil;
}
}
#pragma mark - setter
- (void)setFinished:(BOOL)finished {
[self willChangeValueForKey:@"isFinished"];
_finished = finished;
[self didChangeValueForKey:@"isFinished"];
}
@end
複製程式碼
如上所示,我們自定義了NSOperation
,並分別複寫了其start
和cancel
方法來控制網路下載的啟動和取消。這裡要注意的一點是我們需要“告訴”NSOperation
何時完成任務,否則任務完成之後會一直存在,不會被移除,它的completionBlock
方法也不會被呼叫。所以我們這裡通過KVO方式重寫finished
變數,來通知NSOperation
任務是否完成。
2. 何時取消網路下載
我們知道取消網路下載,只需要呼叫我們自定義
JImageDownloadOperation
的cancel
方法即可,但何時應該取消網路下載呢?由於一個網路任務對應多個監聽者,有可能部分監聽者取消了下載,而另一部分沒有取消,那麼此時則不能取消網路下載。
- (id)addProgressHandler:(JImageDownloadProgressBlock)progressBlock withCompletionBlock:(JImageDownloadCompletionBlock)completionBlock {
JImageCallbackDictionary *callback = [NSMutableDictionary new];
if(progressBlock) [callback setObject:[progressBlock copy] forKey:kImageProgressCallback];
if(completionBlock) [callback setObject:[completionBlock copy] forKey:kImageCompletionCallback];
LOCK(self.callbacksLock);
[self.callbackBlocks addObject:callback];
UNLOCK(self.callbacksLock);
return callback; //返回監聽對應的一個標識
}
#pragma mark - cancel
- (BOOL)cancelWithToken:(id)token { //根據標誌取消
BOOL shouldCancelTask = NO;
LOCK(self.callbacksLock);
[self.callbackBlocks removeObjectIdenticalTo:token];
if (self.callbackBlocks.count == 0) { //若當前無監聽者,則取消下載任務
shouldCancelTask = YES;
}
UNLOCK(self.callbacksLock);
if (shouldCancelTask) {
[self cancel];
}
return shouldCancelTask;
}
複製程式碼
如上所示,我們在加入監聽時,返回一個標誌,若監聽者需要取消任務,則根據這個標誌取消掉監聽事件,若下載任務監聽數為零時,表示沒人監聽該任務,則可以取消下載任務。
3. 取消快取載入
對於快取載入的取消,我們同樣可以利用
NSOperation
可取消的特性在查詢快取過程中建立一個鉤子,查詢前判斷是否要執行該任務。
- (NSOperation *)queryImageForKey:(NSString *)key cacheType:(JImageCacheType)cacheType completion:(void (^)(UIImage * _Nullable, JImageCacheType))completionBlock {
if (!key || key.length == 0) {
SAFE_CALL_BLOCK(completionBlock, nil, JImageCacheTypeNone);
return nil;
}
NSOperation *operation = [NSOperation new];
void(^queryBlock)(void) = ^ {
if (operation.isCancelled) { //建立鉤子,若任務取消,則不再從快取中載入
NSLog(@"cancel cache query for key: %@", key ? : @"");
return;
}
UIImage *image = nil;
JImageCacheType cacheFrom = cacheType;
if (cacheType == JImageCacheTypeMemory) {
image = [self.memoryCache objectForKey:key];
} else if (cacheType == JImageCacheTypeDisk) {
NSData *data = [self.diskCache queryImageDataForKey:key];
if (data) {
image = [[JImageCoder shareCoder] decodeImageSyncWithData:data];
}
} else if (cacheType == JImageCacheTypeAll) {
image = [self.memoryCache objectForKey:key];
cacheFrom = JImageCacheTypeMemory;
if (!image) {
NSData *data = [self.diskCache queryImageDataForKey:key];
if (data) {
cacheFrom = JImageCacheTypeDisk;
image = [[JImageCoder shareCoder] decodeImageSyncWithData:data];
if (image) {
[self.memoryCache setObject:image forKey:key cost:image.memoryCost];
}
}
}
}
SAFE_CALL_BLOCK(completionBlock, image, cacheFrom);
};
dispatch_async(self.ioQueue, queryBlock);
return operation;
}
複製程式碼
如上所示,若我們需要取消載入任務時,只需呼叫返回的NSOperation
的cancel
方法即可。
4. 取消載入介面
我們要取消載入的物件是UIView,那麼勢必要將UIView和對應的operation進行關聯。
@protocol JImageOperation <NSObject>
- (void)cancelOperation;
@end
複製程式碼
如上,我們定義了一個JImageOperation
的協議,用於取消operation。接下來,我們要將UIView與Operation進行關聯:
static char kJImageOperation;
typedef NSMutableDictionary<NSString *, id<JImageOperation>> JOperationDictionay;
@implementation UIView (JImageOperation)
- (JOperationDictionay *)operationDictionary {
@synchronized (self) {
JOperationDictionay *operationDict = objc_getAssociatedObject(self, &kJImageOperation);
if (operationDict) {
return operationDict;
}
operationDict = [[NSMutableDictionary alloc] init];
objc_setAssociatedObject(self, &kJImageOperation, operationDict, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return operationDict;
}
}
- (void)setOperation:(id<JImageOperation>)operation forKey:(NSString *)key {
if (key) {
[self cancelOperationForKey:key]; //先取消當前任務,再重新設定載入任務
if (operation) {
JOperationDictionay *operationDict = [self operationDictionary];
@synchronized (self) {
[operationDict setObject:operation forKey:key];
}
}
}
}
- (void)cancelOperationForKey:(NSString *)key {
if (key) {
JOperationDictionay *operationDict = [self operationDictionary];
id<JImageOperation> operation;
@synchronized (self) {
operation = [operationDict objectForKey:key];
}
if (operation && [operation conformsToProtocol:@protocol(JImageOperation)]) {//判斷當前operation是否實現了JImageOperation協議
[operation cancelOperation];
}
@synchronized (self) {
[operationDict removeObjectForKey:key];
}
}
}
- (void)removeOperationForKey:(NSString *)key {
if (key) {
JOperationDictionay *operationDict = [self operationDictionary];
@synchronized (self) {
[operationDict removeObjectForKey:key];
}
}
}
@end
複製程式碼
如上所示,我們使用物件關聯的方式將UIView和Operation繫結在一起,這樣就可以直接呼叫cancelOperationForKey
方法取消當前載入任務了。
5. 關聯網路下載和快取載入
由於網路下載和快取載入是分別在不同的
NSOperation
中的,若要取消載入任務,則需要分別呼叫它們的cancel
方法。為此,我們定義一個JImageCombineOperation
將兩者關聯,並實現JImageOpeartion
協議,與UIView
關聯。
@interface JImageCombineOperation : NSObject <JImageOperation>
@property (nonatomic, strong) NSOperation *cacheOperation;
@property (nonatomic, strong) JImageDownloadToken* downloadToken;
@property (nonatomic, copy) NSString *url;
@end
@implementation JImageCombineOperation
- (void)cancelOperation {
NSLog(@"cancel operation for url:%@", self.url ? : @"");
if (self.cacheOperation) { //取消快取載入
[self.cacheOperation cancel];
}
if (self.downloadToken) { //取消網路載入
[[JImageDownloader shareInstance] cancelWithToken:self.downloadToken];
}
}
@end
- (id<JImageOperation>)loadImageWithUrl:(NSString *)url progress:(JImageProgressBlock)progressBlock completion:(JImageCompletionBlock)completionBlock {
__block JImageCombineOperation *combineOperation = [JImageCombineOperation new];
combineOperation.url = url;
combineOperation.cacheOperation = [self.imageCache queryImageForKey:url cacheType:JImageCacheTypeAll completion:^(UIImage * _Nullable image, JImageCacheType cacheType) {
if (image) {
dispatch_async(dispatch_get_main_queue(), ^{
SAFE_CALL_BLOCK(completionBlock, image, nil);
});
NSLog(@"fetch image from %@", (cacheType == JImageCacheTypeMemory) ? @"memory" : @"disk");
return;
}
JImageDownloadToken *downloadToken = [[JImageDownloader shareInstance] fetchImageWithURL:url progressBlock:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
dispatch_async(dispatch_get_main_queue(), ^{
SAFE_CALL_BLOCK(progressBlock, receivedSize, expectedSize, targetURL);
});
} completionBlock:^(NSData * _Nullable imageData, NSError * _Nullable error, BOOL finished) {
if (!imageData || error) {
dispatch_async(dispatch_get_main_queue(), ^{
SAFE_CALL_BLOCK(completionBlock, nil, error);
});
return;
}
[[JImageCoder shareCoder] decodeImageWithData:imageData WithBlock:^(UIImage * _Nullable image) {
[self.imageCache storeImage:image imageData:imageData forKey:url completion:nil];
dispatch_async(dispatch_get_main_queue(), ^{
SAFE_CALL_BLOCK(completionBlock, image, nil);
});
}];
}];
combineOperation.downloadToken = downloadToken;
}];
return combineOperation; //返回一個聯合的operation
}
複製程式碼
我們通過loadImageWithUrl
方法返回一個實現了JImageOperation
協議的operation,這樣就可以將其與UIView
繫結在一起,以便我們可以取消任務的載入。
@implementation UIView (JImage)
- (void)setImageWithURL:(NSString *)url progressBlock:(JImageProgressBlock)progressBlock completionBlock:(JImageCompletionBlock)completionBlock {
id<JImageOperation> operation = [[JImageManager shareManager] loadImageWithUrl:url progress:progressBlock completion:completionBlock];
[self setOperation:operation forKey:NSStringFromClass([self class])]; //將view與operation關聯
}
- (void)cancelLoadImage { //取消載入任務
[self cancelOperationForKey:NSStringFromClass([self class])];
}
@end
複製程式碼
6. 外部介面呼叫
[self.imageView setImageWithURL:gifUrl progressBlock:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
CGFloat progress = (float)receivedSize / expectedSize;
hud.progress = progress;
NSLog(@"expectedSize:%ld, receivedSize:%ld, targetURL:%@", expectedSize, receivedSize, targetURL.absoluteString);
} completionBlock:^(UIImage * _Nullable image, NSError * _Nullable error) {
[hud hideAnimated:YES];
__strong typeof (weakSelf) strongSelf = weakSelf;
if (strongSelf && image) {
if (image.imageFormat == JImageFormatGIF) {
strongSelf.imageView.animationImages = image.images;
strongSelf.imageView.animationDuration = image.totalTimes;
strongSelf.imageView.animationRepeatCount = image.loopCount;
[strongSelf.imageView startAnimating];
} else {
strongSelf.imageView.image = image;
}
}
}];
//模擬2s之後取消載入任務
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.imageView cancelLoadImage];
});
複製程式碼
如下所示,我們可以看到圖片載入到一部分之後,就被取消掉了。
四、網路層優化
之前我們在實現網路請求時,一般是一個外部請求對應一個
request
,這麼處理雖然簡單,但存在一定弊端,比如對於相同url的多個外部請求,我們不能只請求一次。為了解決這個問題,我們對外部請求進行了管理,針對相同的url,共用同一個request
。
#define LOCK(lock) dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
#define UNLOCK(lock) dispatch_semaphore_signal(lock);
@interface JImageDownloader()
@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSOperationQueue *operationQueue;
@property (nonatomic, strong) NSMutableDictionary<NSURL *, JImageDownloadOperation *> *URLOperations;
@property (nonatomic, strong) dispatch_semaphore_t URLsLock;
@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.operationQueue = [[NSOperationQueue alloc] init];
self.URLOperations = [NSMutableDictionary dictionary];
self.URLsLock = dispatch_semaphore_create(1);
}
- (JImageDownloadToken *)fetchImageWithURL:(NSString *)url progressBlock:(JImageDownloadProgressBlock)progressBlock completionBlock:(JImageDownloadCompletionBlock)completionBlock {
if (!url || url.length == 0) {
return nil;
}
NSURL *URL = [NSURL URLWithString:url];
if (!URL) {
return nil;
}
LOCK(self.URLsLock);
JImageDownloadOperation *operation = [self.URLOperations objectForKey:URL];
if (!operation || operation.isCancelled || operation.isFinished) {//若operation不存在或被取消、已完成,則重新建立請求
NSURLRequest *request = [[NSURLRequest alloc] initWithURL:URL];
operation = [[JImageDownloadOperation alloc] initWithRequest:request];
__weak typeof(self) weakSelf = self;
operation.completionBlock = ^{ //請求完成之後,需要將operation移除
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
LOCK(self.URLsLock);
[strongSelf.URLOperations removeObjectForKey:URL];
UNLOCK(self.URLsLock);
};
[self.operationQueue addOperation:operation]; //新增到任務佇列中
[self.URLOperations setObject:operation forKey:URL];
}
UNLOCK(self.URLsLock);
id downloadToken = [operation addProgressHandler:progressBlock withCompletionBlock:completionBlock];
JImageDownloadToken *token = [JImageDownloadToken new];
token.url = URL;
token.downloadToken = downloadToken;
return token; //返回請求對應的標誌,以便取消
}
- (void)cancelWithToken:(JImageDownloadToken *)token {
if (!token || !token.url) {
return;
}
LOCK(self.URLsLock);
JImageDownloadOperation *opertion = [self.URLOperations objectForKey:token.url];
UNLOCK(self.URLsLock);
if (opertion) {
BOOL hasCancelTask = [opertion cancelWithToken:token.downloadToken];
if (hasCancelTask) { //若網路下載被取消,則移除對應的operation
LOCK(self.URLsLock);
[self.URLOperations removeObjectForKey:token.url];
UNLOCK(self.URLsLock);
NSLog(@"cancle download task for url:%@", token.url ? : @"");
}
}
}
@end
複製程式碼
五、總結
本章節主要實現了網路層的進度回撥和取消下載的功能,並對網路層進行了優化,避免相同url的額外請求。