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

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

IOS小彩虹2021-08-26 17:13:40220A+A-

一、前言

上一章节主要对缓存进行了重构,使其更具扩展性。本章节将对网络加载部分进行重构,并增加进度回调和取消加载功能。

二、进度加载

对于一些size较大的图片(特别是GIF图片),从网络中下载下来需要一段时间。为了避免这段加载时间显示空白,往往会通过设置placeholder或显示加载进度。

在此之前,我们是通过NSURLSessionblock回调来直接获取到网络所获取的内容

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来区分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的添加和移除的调用可能来自不同线程,我们这里使用锁来避免由于时序问题而导致数据错误。

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,并分别复写了其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];
});

如下所示,我们可以看到图片加载到一部分之后,就被取消掉了。

四、网络层优化

之前我们在实现网络请求时,一般是一个外部请求对应一个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的额外请求。

点击这里复制本文地址 以上内容由权冠洲的博客整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!

支持Ctrl+Enter提交

联系我们| 本站介绍| 留言建议 | 交换友链 | 域名展示
本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除

权冠洲的博客 © All Rights Reserved.  Copyright quanguanzhou.top All Rights Reserved
苏公网安备 32030302000848号   苏ICP备20033101号-1
本网站由 提供CDN/云存储服务

联系我们