YYWebImage 源码剖析:线程处理与缓存策略

3,145 阅读12分钟

YYKit 系列源码剖析文章:

引言

在 iOS 开发中,异步网络图片下载框架可以说是很大的解放了生产力,通常情况下开发者只需要简单的代码就能将网络图片异步下载并显示到手机屏幕上,并且还带有缓存优化。

业界名气最高的异步图片下载框架是 SDWebImage,而后 ibireme 前辈开源了 YYWebImage,对性能有所优化。之前有粗略的浏览过 SDWebImage 的源码,对比 YYWebImage 源码过后,实际上笔者更喜欢 YYWebImage,因为其代码风格很简洁、代码结构更清晰。

技术层面来看,两者对线程处理的处理方式有所不同,缓存策略也有细节上的差异,虽然笔者的理解来看 YYWebImage 性能更为优越,但是并没有充分的测试用例来验证。有些遗憾的是,YYWebImage 似乎挺久没有维护了,作者在 这条 issues 说过计划会将NSURLConnection替换为NSURLSession,到现在都没有动作😂。

所以实际开发中为了稳定性可能还是会首选 SDWebImage,但是这丝毫不影响我们学习 YYWebImage 的优秀源码,本文主要是分析 YYWebImage 的核心思路和亮点。

源码版本:1.0.5

一、框架总览

//包含所有文件的头文件
YYWebImage.h
//缓存相关
YYImageCache.h (.m)
//请求任务预处理类
_YYWebImageSetter.h (.m)
//请求任务管理类
YYWebImageManager.h (.m)
//自定义请求类(继承自NSOperation)
YYWebImageOperation.h (.m)
//方便业务调用的分类
CALayer+YYWebImage.h (.m)
MKAnnotationView+YYWebImage.h (.m)
UIButton+YYWebImage.h (.m)
UIImage+YYWebImage.h (.m)
UIImageView+YYWebImage.h (.m)

上面这些方便业务调用的分类,它们的实现大同小异,使用最多的是UIImageView+YYWebImage.h,完全可以以其为入口探究框架的原理。

正如作者框架的简短说明:

Asynchronous image loading framework.

该框架的核心就是异步下载网络图片。

  • 既然是异步下载,就涉及到线程的高效调度问题,由于在业务场景中下载图片的任务可能是繁重的,所以线程处理的性能至关重要。
  • 图片下载成功过后,为了避免显示图片时在主线程解压,框架做了异步解压,对于gif、APNG、WebP等都有支持,这部分功能是基于作者的另一个框架 YYImage,笔者之前写过源码分析:YYImage 源码剖析:图片处理技巧
  • 为了不重复下载和重复解压,框架做了缓存优化,至于是否缓存解压过后的图片,可以由开发者选择,当然,缓存分内存缓存和磁盘缓存,读写速度一般也是内存大于磁盘,这部分功能是基于作者的另一个框架 YYCache,笔者之前也写过源码分析:YYCache 源码剖析:一览亮点

二、重复下载请求处理

该处理主要是基于_YYWebImageSetter.h下的一个属性:

@property (nonatomic, readonly) int32_t sentinel;

UIImageView+YYWebImage.h的一个方法看起:

- (void)yy_setImageWithURL:(NSURL *)imageURL
               placeholder:(UIImage *)placeholder
                   options:(YYWebImageOptions)options
                   manager:(YYWebImageManager *)manager
                  progress:(YYWebImageProgressBlock)progress
                 transform:(YYWebImageTransformBlock)transform
                completion:(YYWebImageCompletionBlock)completion {
    ...
    //第一步:为 UIImageView 绑定一个 _YYWebImageSetter 对象
    _YYWebImageSetter *setter = objc_getAssociatedObject(self, &_YYWebImageSetterKey);
    if (!setter) {
        setter = [_YYWebImageSetter new];
        objc_setAssociatedObject(self, &_YYWebImageSetterKey, setter, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    int32_t sentinel = [setter cancelWithNewURL:imageURL];
    
    _yy_dispatch_sync_on_main_queue(^{
        ...
        __weak typeof(self) _self = self;
        dispatch_async([_YYWebImageSetter setterQueue], ^{
            ...
    //第二步:开始下载任务
            newSentinel = [setter setOperationWithSentinel:sentinel url:imageURL options:options manager:manager progress:_progress transform:transform completion:_completion];
            weakSetter = setter;
        });
    });
}

笔者省略了大部分代码,不用在意这些线程操作,现在只关注重复请求的处理。

第一步 中,利用 runtime 为UIImageView绑定一个_YYWebImageSetter对象,然后调用了一个方法cancelWithNewURL:,该方法实现如下:

- (int32_t)cancelWithNewURL:(NSURL *)imageURL {
    int32_t sentinel;
    dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER);
    if (_operation) {
        [_operation cancel];
        _operation = nil;
    }
    _imageURL = imageURL;
    sentinel = OSAtomicIncrement32(&_sentinel);
    dispatch_semaphore_signal(_lock);
    return sentinel;
}

可以看到作者取消了_operation任务,对于同一个UIImageView的重复请求时,取消_operation任务也就是取消上一次请求的任务。

然后有一句至关重要的代码:sentinel = OSAtomicIncrement32(&_sentinel);,使用原子自增保证全局变量_sentinel的线程安全和读取性能。也就是说,对于同一个UIImageView每次调用yy_setImageWithURL: ...方法都会取消上次的请求并且将其_sentinel加一。

这么做的意义,往下面看。

第二步 中,调用了_YYWebImageSettersetOperationWithSentinel: ...方法:

- (int32_t)setOperationWithSentinel:(int32_t)sentinel
                                url:(NSURL *)imageURL
                            options:(YYWebImageOptions)options
                            manager:(YYWebImageManager *)manager
                           progress:(YYWebImageProgressBlock)progress
                          transform:(YYWebImageTransformBlock)transform
                         completion:(YYWebImageCompletionBlock)completion {
//1、判断当前请求是否是最新请求
    if (sentinel != _sentinel) {
        if (completion) completion(nil, imageURL, YYWebImageFromNone, YYWebImageStageCancelled, nil);
        return _sentinel;
    }
    
    NSOperation *operation = ... //省略实际网络请求逻辑
    
//2、判断当前请求是否是最新请求
    dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER);
    if (sentinel == _sentinel) {
        if (_operation) [_operation cancel];
        _operation = operation;
        sentinel = OSAtomicIncrement32(&_sentinel);
    } else {
        [operation cancel];
    }
    dispatch_semaphore_signal(_lock);
    return sentinel;
}

可以看到两个地方都有 判断当前请求是否是最新请求 的逻辑。对于第 1 个地方,因为在该方法入栈的时候,可能该UIImageView的下一次yy_setImageWithURL: ...又一次入栈,也就是说_sentinel可能已经加一了,那么这里就没有必要继续下面的网络请求逻辑了(代码已省略);对于第 2 个地方,也是同样的考虑,若此刻_sentinel已经加一了,就取消掉当前已经创建好的NSOperation,若此刻_sentinel没变,就取消掉上一次的_operation,然后_sentinel自增。

值得注意的是,这里的信号量使用是为了保证_operation读写安全,而不是为了保护_sentinel(因为原子自增本身就是线程安全的)。

大致重复请求的处理就是如此,若看得有些费解建议多看几遍源码里面完整的代码。

三、线程的处理

1、下载任务的预处理

同样是在UIImageView+YYWebImage.h下的入口方法:

- (void)yy_setImageWithURL:(NSURL *)imageURL
               placeholder:(UIImage *)placeholder
                   options:(YYWebImageOptions)options
                   manager:(YYWebImageManager *)manager
                  progress:(YYWebImageProgressBlock)progress
                 transform:(YYWebImageTransformBlock)transform
                completion:(YYWebImageCompletionBlock)completion {
    ...
    
    _yy_dispatch_sync_on_main_queue(^{
        ...
//第一步:在主线程读取内存缓存
        // get the image from memory as quickly as possible
        UIImage *imageFromMemory = nil;
        if (manager.cache &&
            !(options & YYWebImageOptionUseNSURLCache) &&
            !(options & YYWebImageOptionRefreshImageCache)) {
            imageFromMemory = [manager.cache getImageForKey:[manager cacheKeyForURL:imageURL] withType:YYImageCacheTypeMemory];
        }
        if (imageFromMemory) {
            if (!(options & YYWebImageOptionAvoidSetImage)) {
                self.image = imageFromMemory;
            }
            if(completion) completion(imageFromMemory, imageURL, YYWebImageFromMemoryCacheFast, YYWebImageStageFinished, nil);
            return;
        }
        ...
        __weak typeof(self) _self = self;
//第二步:在异步线程做下载任务的预处理
        dispatch_async([_YYWebImageSetter setterQueue], ^{
            ...
            newSentinel = [setter setOperationWithSentinel:sentinel url:imageURL options:options manager:manager progress:_progress transform:transform completion:_completion];
            weakSetter = setter;
        });
    });
}

第一步

可以看到作者的一句英文注释,也就是尽可能快的从内存读取缓存 (如果有),这里是一个很有意思的优化点。了解 YYCache 框架的读者应该知道,作者是使用 双向链表+hash 的方式实现的内存缓存,直接查找的开销比切换后台线程查找而后返回主线程的开销要小。

第二步

下载任务的预处理是在一个[_YYWebImageSetter setterQueue]队列,代码如下:

+ (dispatch_queue_t)setterQueue {
    static dispatch_queue_t queue;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        queue = dispatch_queue_create("com.ibireme.webimage.setter", DISPATCH_QUEUE_SERIAL);
        dispatch_set_target_queue(queue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
    });
    return queue;
}

可以看到这是一个串行的队列,优先级为DISPATCH_QUEUE_PRIORITY_DEFAULT,小于主队列。

可能有朋友会疑问,下载任务在异步队列?那岂不是同一时刻只有一个下载任务执行?

哈哈,注意看清笔者的描述:下载任务的预处理。这里面包含了任务的创建、重复请求处理等逻辑,并没有耗时过多的操作,使用一个异步的线程来处理也是为了减轻主线程的压力。下载任务的线程处理后面会讲到,并不是此处的串行队列。

2、下载任务的处理

该框架使用了NSURLConnection处理下载任务,姑且不谈它的用法,毕竟已经淘汰了。它的代理线程是如此创建的:

/// Network thread entry point.
+ (void)_networkThreadMain:(id)object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"com.ibireme.webimage.request"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}
/// Global image request network thread, used by NSURLConnection delegate.
+ (NSThread *)_networkThread {
    static NSThread *thread = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        thread = [[NSThread alloc] initWithTarget:self selector:@selector(_networkThreadMain:) object:nil];
        if ([thread respondsToSelector:@selector(setQualityOfService:)]) {
            thread.qualityOfService = NSQualityOfServiceBackground;
        }
        [thread start];
    });
    return thread;
}

这段代码在老版本的 AFNetwork 和 SDWebImage 里面都出现过,创建一个常驻线程来处理下载任务的回调,通过添加一个 NSMachPort 端口保证该线程的 runloop 的正常运行不退出,由于手动创建的线程不包含自动释放池,所以作者加了一个。

这里的亮点其实是这么一句方法:thread.qualityOfService = NSQualityOfServiceBackground;

作者很细心的将线程的优先级设置为NSQualityOfServiceBackground,这是一个比较低的优先级,作者希望图片的下载回调相关处理不会和其他线程竞争 CPU 的资源(比如操作 UI 的主线程等)。

3、图片读取和解压处理

图片从磁盘中读取、写入、解压等操作都是在下面这个队列处理的(图片处理具体原理可看YYImage 源码剖析:图片处理技巧

+ (dispatch_queue_t)_imageQueue {
    #define MAX_QUEUE_COUNT 16
    static int queueCount;
    static dispatch_queue_t queues[MAX_QUEUE_COUNT];
    static dispatch_once_t onceToken;
    static int32_t counter = 0;
    dispatch_once(&onceToken, ^{
        queueCount = (int)[NSProcessInfo processInfo].activeProcessorCount;
        queueCount = queueCount < 1 ? 1 : queueCount > MAX_QUEUE_COUNT ? MAX_QUEUE_COUNT : queueCount;
        if ([UIDevice currentDevice].systemVersion.floatValue >= 8.0) {
            for (NSUInteger i = 0; i < queueCount; i++) {
                dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, 0);
                queues[i] = dispatch_queue_create("com.ibireme.image.decode", attr);
            }
        } else {
            for (NSUInteger i = 0; i < queueCount; i++) {
                queues[i] = dispatch_queue_create("com.ibireme.image.decode", DISPATCH_QUEUE_SERIAL);
                dispatch_set_target_queue(queues[i], dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0));
            }
        }
    });
    int32_t cur = OSAtomicIncrement32(&counter);
    if (cur < 0) cur = -cur;
    return queues[(cur) % queueCount];
    #undef MAX_QUEUE_COUNT
}

创建与处理器相同的串行队列模拟并发控制,具体的原理分析可以看笔者的一篇文章:YYAsyncLayer 源码剖析:异步绘制 中对线程的讨论,这种并发线程的处理是作者的一个常规思路,不多说。

四、缓存策略

在该框架中的体现,上层的业务逻辑是这样的:

  1. 优先查找内存缓存,若找到则返回
  2. 若内存缓存未找到,会异步从磁盘查找缓存,若找到则返回,并且写入内存缓存方便下次查找
  3. 若磁盘缓存仍然未找到,发起网络请求
  4. 网络请求成功,同时写入磁盘缓存和内存缓存

实际上这个逻辑和 SDWebImage 基本一致。值得注意的是,是否查找内存或磁盘缓存、是否需要缓存、缓存的大小限制等都有自定义的方法。

上层的核心逻辑就是如此,关于内存缓存和磁盘缓存的底层实现,可以查看YYCache 源码剖析:一览亮点

五、加载指示器的处理

加载指示器是在YYWebImageManager.m中处理的,其他代码就不贴出来了

@interface _YYWebImageApplicationNetworkIndicatorInfo : NSObject
@property (nonatomic, assign) NSInteger count;
@property (nonatomic, strong) NSTimer *timer;
@end

+ (_YYWebImageApplicationNetworkIndicatorInfo *)_networkIndicatorInfo {
    return objc_getAssociatedObject(self, @selector(_networkIndicatorInfo));
}
+ (void)_setNetworkIndicatorInfo:(_YYWebImageApplicationNetworkIndicatorInfo *)info {
    objc_setAssociatedObject(self, @selector(_networkIndicatorInfo), info, OBJC_ASSOCIATION_RETAIN);
}
...

绑定到YYWebImageManager的一个类变量_YYWebImageApplicationNetworkIndicatorInfo,也就是说变量的timercount都是全局的。

。处理指示器本质是容易的,但是作者的思路挺有意思。

一是作者通过一个NSTimer来延时 1/30 秒开启或者关闭加载指示器。

二是作者通过“计数”来控制指示器是否显示,也就是上面的count,当有网络任务开始的时候计数加一,当有网络任务结束或者异常取消时计数减一,那么,只要count大于零就显示指示器,否则就隐藏。

这思路确实挺巧妙。

六、框架的性能瓶颈

YYWebImageOperation.m下的-connectionDidFinishLoading:代理方法中可以看到图片的解压逻辑,它是在_imageQueue中执行的,解压完成就缓存起来方便显示。

虽然解压的过程是在异步线程,通常情况下不会影响到主线程,但是当解压的图片过多或者图片分辨率过大时,解压和缓存会占用大量的内存,导致内存峰值飙升。

所以,需要开发者做一些性能上的优化,不过可喜的是可以通过YYWebImageOptionsYYWebImageOptionIgnoreImageDecoding值禁止下载成功后的解压和缓存逻辑,以此降低内存峰值。

七、框架中的一些小 tips

1、自动释放池

可以看到框架中使用了大量的自动释放池来避免内存峰值,可能有开发者感觉如此频繁的使用自动释放池是否会造成性能问题,实际上影响不大。了解自动释放池的底层原理的朋友都知道,添加一个自动释放池不过是添加一个标识(哨兵),需要管理对象加入自动释放池可以看做是入栈操作,当栈顶的这个自动释放池结束,会自动给池内对象发送release消息(这里池内就是栈顶到“哨兵”的范围)。

2、锁的使用

YYWebImageOperation.m中使用了递归锁NSRecursiveLock避免多次获取锁而导致死锁,当然,笔者认为这里使用pthread_mutex_t互斥锁的递归实现处理性能应该更好。

在操作少量的、耗时少的代码时,使用dispatch_semaphore_t信号量保证线程安全,有性能优势。

在对int32_t类型变量进行安全保护时,使用OSAtomicIncrement32()原子方法无疑是很好的选择。

3、避免循环引用

框架中通过一个中间类的消息转发来达到避免循环引用的目的:

@interface _YYWebImageWeakProxy : NSProxy
@property (nonatomic, weak, readonly) id target;
- (instancetype)initWithTarget:(id)target;
+ (instancetype)proxyWithTarget:(id)target;
@end
@implementation _YYWebImageWeakProxy
- (instancetype)initWithTarget:(id)target {
    _target = target;
    return self;
}
+ (instancetype)proxyWithTarget:(id)target {
    return [[_YYWebImageWeakProxy alloc] initWithTarget:target];
}
- (id)forwardingTargetForSelector:(SEL)selector {
    return _target;
}
- (void)forwardInvocation:(NSInvocation *)invocation {
    void *null = NULL;
    [invocation setReturnValue:&null];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}

关于具体的分析可以看笔者的文章YYImage 源码剖析:图片处理技巧有相应的解析。

结语

不得不说,框架都是有套路的。在阅读 YYKit 系列的代码中,也懂了作者的套路,所以笔者在阅读 YYWebImage 源码时非常快,几乎没有卡壳,可能这就是“厚积薄发”的小小体现吧。

考虑到篇幅和码字太累,笔者的分析文章都是剥茧抽丝的,若读者朋友阅读有障碍,请沉下心来,多结合源码,多思考😁。