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

1、前言

上一章節主要對緩存進行了重構,使其更具擴展性。本章節將對網絡加載部分進行重構,並增長進度回調和取消加載功能。git

2、進度加載

對於一些size較大的圖片(特別是GIF圖片),從網絡中下載下來須要一段時間。爲了不這段加載時間顯示空白,每每會經過設置placeholder或顯示加載進度。github

在此以前,咱們是經過NSURLSessionblock回調來直接獲取到網絡所獲取的內容緩存

NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    // 對data進行處理
}];
複製代碼

顯然,這麼處理咱們只能獲取到最終的結果,沒辦法獲取到進度。爲了獲取到下載的實時進度,咱們就須要本身去實現NSURLSession的協議NSURLSessionDelegate網絡

NSURLSession的協議比較多,具體能夠查看官網。這裏只列舉當前所須要用到的協議方法:session

#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暫存起來,並在下載過程和下載完成以後回調blockapp

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來區分progressBlockcompletionBlock。這麼作的目的是統一管理回調,減小數據成員變量,不然咱們須要使用兩個NSArray來分別保存progressBlockcompletionBlock。此外,咱們還可使用NSArrayvalueForKey方法便捷地根據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的添加和移除的調用可能來自不一樣線程,咱們這裏使用鎖來避免因爲時序問題而致使數據錯誤。async

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方法來實現。ide

#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

3、取消加載

對於一些較大的圖片,可能存在加載到一半以後,用戶不想看了,點擊返回。此時,咱們應該取消正在加載的任務,以免沒必要要的消耗。圖片的加載耗時主要來自於網絡下載和磁盤加載兩方面,因此這兩個過程咱們都須要支持取消操做。

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,並分別複寫了其startcancel方法來控制網絡下載的啓動和取消。這裏要注意的一點是咱們須要「告訴」NSOperation什麼時候完成任務,不然任務完成以後會一直存在,不會被移除,它的completionBlock方法也不會被調用。因此咱們這裏經過KVO方式重寫finished變量,來通知NSOperation任務是否完成。

2. 什麼時候取消網絡下載

咱們知道取消網絡下載,只須要調用咱們自定義JImageDownloadOperationcancel方法便可,但什麼時候應該取消網絡下載呢?因爲一個網絡任務對應多個監聽者,有可能部分監聽者取消了下載,而另外一部分沒有取消,那麼此時則不能取消網絡下載。

- (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;
}
複製代碼

如上所示,若咱們須要取消加載任務時,只需調用返回的NSOperationcancel方法便可。

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];
});
複製代碼

以下所示,咱們能夠看到圖片加載到一部分以後,就被取消掉了。

4、網絡層優化

以前咱們在實現網絡請求時,通常是一個外部請求對應一個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
複製代碼

5、總結

本章節主要實現了網絡層的進度回調和取消下載的功能,並對網絡層進行了優化,避免相同url的額外請求。

相關文章
相關標籤/搜索