Kingfisher学习笔记

Kingfisher

Kingfisher是一个使用Swift编写的用于下载和缓存图片的iOS库,是作者王巍受SDWebImage的启发开发了这个纯Swift的库。Kingfisher的完整特性可以从下面的链接中获取,本文主要是学习项目源码的一些心得。
https://github.com/onevcat/Kingfisher

kf_xxx >> kf.xxx

参考 [RxCocoa] Move from rx_ prefix to a rx. proxy (for Swift 3 update ?

传统的Objective-C语法对于扩展的method,推荐使用kf_xxx方式命名,但是这种命名不太符合Swift的风格,且看起来很丑。因此越来越多的Swift项目开始参考LazySequence模式,使用类似下面的风格:

myArray.map { ... }
myArray.lazy.map { ... }

Kingfisher也由kf_xxx转向了kf.xxx风格,如imageView.kf.indicatorType = .activity。要实现这个机制,需要以下几步:

  • 实现一个作为Proxy的class/struct

    这个Proxy仅将真实的对象包裹起来,不做任何实际的操作。Kingfisher使用的Kingfisher这个class,定义如下:

    public final class Kingfisher<Base> {
        public let base: Base
        public init(_ base: Base) {
            self.base = base
        }
    }
    
  • 定义一个Protocol用于提供.kf的方法

    Kingfisher是使用了KingfisherCompatible,它有两个关键点:

    • 定义一个名为kf的property。这不是必须的,实际上KingfisherCompatible可以为一个空protocol,只要我们实现了下一步即可。
    • 提供一个kf的默认实现,返回一个新建的Proxy对象,即Kingfisher对象

    定义如下:

     /**
     A type that has Kingfisher extensions.
     */
    public protocol KingfisherCompatible {
        associatedtype CompatibleType
        var kf: CompatibleType { get }
    }
    
    public extension KingfisherCompatible {
        public var kf: Kingfisher<Self> {
            get { return Kingfisher(self) }
        }
    }
    

    讨论

    个人觉得,KingfisherCompatible采用下面的定义方式更好,即删除Protocol中kf的声明,仅在Extension中提供一个默认的Implementation。因为若将kf的声明留在Protocol,那么其他Type继承这个Protocol后,是可以修改kf的Implementation!从作者的意图上讲,他应该不希望这种事情发生。

    public protocol KingfisherCompatible {
    }
    
    public extension KingfisherCompatible {
        public var kf: Kingfisher<Self> {
            get { return Kingfisher(self) }
        }
    }
    
  • 将Protocol加载到所需的Base类上
    定义一个Base类的extension即可。比如使用下面的代码后,我们就可以通过使用诸如imageView.kf的代码了。

    extension Image: KingfisherCompatible { }
    extension ImageView: KingfisherCompatible { }
    extension Button: KingfisherCompatible { }
    
  • 通过Extension + where Base实现Base类的特定代码

    比如实现下面的代码后,我们就可以通过调用imageView.kf.webURL获取imageView的webURL了。
    需要注意的是,使用objc_getAssociatedObjectobjc_setAssociatedObject时,一定与base关联,而不是self!因为从.kf的实现中也可以知道,每次调用imageView.kf时,实际上返回的都是一个全新Kingfisher

    extension Kingfisher where Base: ImageView {
        public var webURL: URL? {
            return objc_getAssociatedObject(base, &lastURLKey) as? URL
        }
        
        fileprivate func setWebURL(_ url: URL?) {
            objc_setAssociatedObject(base, &lastURLKey, url, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
    
    

ImageDownloader

ImageDownloader是这个库的两大核心之一,另一个是Cache。

ImageDownloader represents a downloading manager for requesting the image with a URL from server.

ImageFetchLoad

它是ImageDownloader的一个内部class,主要用于避免一个URL的资源被同时下载多次。定义如下:

class ImageFetchLoad {
    var contents = [(callback: CallbackPair, options: KingfisherOptionsInfo)]()
    var responseData = NSMutableData()

    var downloadTaskCount = 0
    var downloadTask: RetrieveImageDownloadTask?
    var cancelSemaphore: DispatchSemaphore?
}

这个类通过ImageDownloader.fetchLoads与某个URL挂钩。对于一个URL,如果用户反复下载,

  • 若正在下载这个URL的资源,则用户试图再次下载时,ImageDownloader不会真的下载,而是令ImageFetchLoad.downloadTaskCount++。

  • 若上一次的下载已经结束,这个URL会被再次下载。因为一次下载结束时会通过processImage >> cleanFetchLoad将这个URL对应的ImageFetchLoad清除。

Properties

fetchLoads

是一个[URL: ImageFetchLoad],如上所说,用于记录URL和ImageFetchLoad的关系。

存在3个不同的DispatchQueue

  1. barrierQueue

    • concurrent
    • 用于thread safe地读写fetchLoads。

    讨论
    concurrent + barrier的组合可以用来解决多线程的读写问题时,通常是如下的代码,对写操作使用barrier,对于读操作

  • processQueue:
    • concurrent
    • 数据下载完成后,用于在后台处理数据,避免阻塞mian thread
  • cancelQueue
    • serial
    • 跟ImageFetchLoad.cancelSemaphore有关。从目前来看,仅当一个URL的fetchLoad被创建 && 还没来得及开始下载(downloadTaskCount == 0)时,若该URL又有一个下载请求过来了,我们会在cancelQueue中让其等待起来。若之前的那个下载请求失败了,才会启动本次的下载。
if let fetchLoad = fetchLoad(for: url), fetchLoad.downloadTaskCount == 0 {
    if fetchLoad.cancelSemaphore == nil {
        fetchLoad.cancelSemaphore = DispatchSemaphore(value: 0)
    }
    cancelQueue.async {
        _ = fetchLoad.cancelSemaphore?.wait(timeout: .distantFuture)
        fetchLoad.cancelSemaphore = nil
        prepareFetchLoad()
    }
} else {
    prepareFetchLoad()
}
private func callCompletionHandlerFailure(error: Error, url: URL) {
    guard let downloader = downloadHolder, let fetchLoad = downloader.fetchLoad(for: url) else {
        return
    }

    // We need to clean the fetch load first, before actually calling completion handler.
    cleanFetchLoad(for: url)

    var leftSignal: Int
    repeat {
        leftSignal = fetchLoad.cancelSemaphore?.signal() ?? 0
    } while leftSignal != 0

    for content in fetchLoad.contents {
        content.options.callbackDispatchQueue.safeAsync {
            content.callback.completionHandler?(nil, error as NSError, url, nil)
        }
    }
}

sessionHandler

用于实现URLSessionDataDelegate,并避免由于session导致的retain cycle。https://github.com/onevcat/Kingfisher/issues/235

APIs

downloadImage()

对外的API主要是这个方法,定义如下:

    /**
     Download an image with a URL and option.
     
     - parameter url:               Target URL.
     - parameter retrieveImageTask: The task to cooporate with cache. Pass `nil` if you are not trying to use downloader and cache.
     - parameter options:           The options could control download behavior. See `KingfisherOptionsInfo`.
     - parameter progressBlock:     Called when the download progress updated.
     - parameter completionHandler: Called when the download progress finishes.
     
     - returns: A downloading task. You could call `cancel` on it to stop the downloading process.
     */
    @discardableResult
    open func downloadImage(with url: URL,
                       retrieveImageTask: RetrieveImageTask? = nil,
                       options: KingfisherOptionsInfo? = nil,
                       progressBlock: ImageDownloaderProgressBlock? = nil,
                       completionHandler: ImageDownloaderCompletionHandler? = nil) -> RetrieveImageDownloadTask?

ImageCache

ImageCache是这个库的两大核心之一,另一个是ImageDownloader。

ImageCache represents both the memory and disk cache system of Kingfisher. While a default image cache object will be used if you prefer the extension methods of Kingfisher, you can create your own cache object and configure it as your need. You could use an ImageCache object to manipulate memory and disk cache for Kingfisher.

ImageCache的实现机制基本上跟SDWebImage的SDImageCache一模一样。

Cache添加和访问机制

ImageCache用到了2个单独的DispatchQueue,以免堵塞主线程。

  • ioQueue
    • serial
    • 用于处理disk读写相关的操作。
  • processQueue
    • concurrent
    • 用于对下载的数据做decode。

Cache的添加机制

对外的API是这个方法,定义如下:

    /**
    Store an image to cache. It will be saved to both memory and disk. It is an async operation.
    
    - parameter image:             The image to be stored.
    - parameter original:          The original data of the image.
                                   Kingfisher will use it to check the format of the image and optimize cache size on disk.
                                   If `nil` is supplied, the image data will be saved as a normalized PNG file.
                                   It is strongly suggested to supply it whenever possible, to get a better performance and disk usage.
    - parameter key:               Key for the image.
    - parameter identifier:        The identifier of processor used. If you are using a processor for the image, pass the identifier of
                                   processor to it.
                                   This identifier will be used to generate a corresponding key for the combination of `key` and processor.
    - parameter toDisk:            Whether this image should be cached to disk or not. If false, the image will be only cached in memory.
    - parameter completionHandler: Called when store operation completes.
    */
    open func store(_ image: Image,
                      original: Data? = nil,
                      forKey key: String,
                      processorIdentifier identifier: String = "",
                      cacheSerializer serializer: CacheSerializer = DefaultCacheSerializer.default,
                      toDisk: Bool = true,
                      completionHandler: (() -> Void)? = nil)

添加步骤如下:

  1. 首先将图片缓存到内存中。Kingfisher使用了系统自带的NSCache作为内存的缓存,添加缓存时,以key(图片的网址)作为key,image作为value。
  2. toDisk为true,则在ioQueue中将图片缓存到硬盘中。步骤如下:
    1. 首先将参数中的imageUIImage/NSImage转化为Data,参数中的original在这个过程中会起到辅助作用,用于判断图片的类型(PNG,GIF,JPG等)。
    2. 然后将上面生成的Data存储到硬盘中,文件名为图片网址的md5值。

Cache的访问机制

对外的API是这个方法,定义如下:

    /**
    Get an image for a key from memory or disk.
    
    - parameter key:               Key for the image.
    - parameter options:           Options of retrieving image. If you need to retrieve an image which was 
                                   stored with a specified `ImageProcessor`, pass the processor in the option too.
    - parameter completionHandler: Called when getting operation completes with image result and cached type of 
                                   this image. If there is no such key cached, the image will be `nil`.
    
    - returns: The retrieving task.
    */
    @discardableResult
    open func retrieveImage(forKey key: String,
                               options: KingfisherOptionsInfo?,
                     completionHandler: ((Image?, CacheType) -> Void)?) -> RetrieveImageDiskTask?

访问步骤如下:

  1. 首先以图片的网址为key,去内存的缓存中搜索,若存在,则直接返回这个值,并退出函数;若不存在,则在硬盘上进行搜索。
  2. 以缓存文件夹的Path + 图片网址的md5值 生成一个文件路径,读取该路径的数据,并生成图片。若成功了,则将这个图片缓存到内存中,以便下次访问,然后返回这个图片;若失败了,则返回nil。

Cache清理机制

清理的时机

ImageCache在init方法中监听了下列事件:

  • UIApplicationDidReceiveMemoryWarning:清空内存的所有缓存,调用clearMemoryCache()
  • UIApplicationWillTerminate:清理硬盘的缓存,调用:cleanExpiredDiskCache()
  • UIApplicationDidEnterBackground:清理硬盘的缓存,调用backgroundCleanExpiredDiskCache()

硬盘缓存的清理机制

  1. 使用FileManager.contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions = [])遍历整个缓存文件夹,获取到每个文件的这些属性值:.isDirectoryKey, .contentAccessDateKey, .totalFileAllocatedSizeKey。需要注意的是,这里获取的是文件的访问时间,而不是修改时间!(SDWebImage是获取修改时间)。
  2. 对比"访问时间"和缓存的有效时间,删除过期的图片。这里可以看出,Kingfisher使用“访问时间”,而不是“修改时间”是更加合理的。毕竟,按SDWebImage的逻辑,若一个图片很早之前就下载了,即便最近一直在被频繁访问,还是会被清除。
  3. 经过上一轮的清理,若缓存图片的总体size依然大于用户设定的上限(如果设置了),那么就将剩下的图片按“访问时间”排序,逐个删除旧的图片,直到总体size小于用户上限的一半为止。

ImageProcessor

An ImageProcessor would be used to convert some downloaded data to an image.

它是一个Protocol,用于将下载的数据转换为image,定义如下:

public protocol ImageProcessor {
    /// Identifier of the processor.
    var identifier: String { get }
    
    /// Process an input `ImageProcessItem` item to an image for this processor.
    ///
    /// - parameter item:    Input item which will be processed by `self`
    /// - parameter options: Options when processing the item.
    ///
    /// - returns: The processed image.
    ///
    /// - Note: The return value will be `nil` if processing failed while converting data to image.
    ///         If input item is already an image and there is any errors in processing, the input 
    ///         image itself will be returned.
    /// - Note: Most processor only supports CG-based images. 
    ///         watchOS is not supported for processers containing filter, the input image will be returned directly on watchOS.
    func process(item: ImageProcessItem, options: KingfisherOptionsInfo) -> Image?
}

Kingfisher提供了以下默认的实现。

DefaultImageProcessor

The default processor. It convert the input data to a valid image. Images of .PNG, .JPEG and .GIF format are supported. If an image is given, DefaultImageProcessor will do nothing on it and just return that image.

public func process(item: ImageProcessItem, options: KingfisherOptionsInfo) -> Image? {
    switch item {
    case .image(let image):
        return image
    case .data(let data):
        return Kingfisher<Image>.image(
            data: data,
            scale: options.scaleFactor,
            preloadAllAnimationData: options.preloadAllAnimationData,
            onlyFirstFrame: options.onlyLoadFirstFrame)
    }
}

GeneralProcessor

private class。主要用于将两个ImageProcessor合并起来。这个合并很精巧,它定义了一个内部block p,初始化时p会hold住两个旧的ImageProcessor,process时会逐一使用它们。

public extension ImageProcessor {
    
    /// Append an `ImageProcessor` to another. The identifier of the new `ImageProcessor` 
    /// will be "\(self.identifier)|>\(another.identifier)".
    ///
    /// - parameter another: An `ImageProcessor` you want to append to `self`.
    ///
    /// - returns: The new `ImageProcessor` will process the image in the order
    ///            of the two processors concatenated.
    public func append(another: ImageProcessor) -> ImageProcessor {
        let newIdentifier = identifier.appending("|>\(another.identifier)")
        return GeneralProcessor(identifier: newIdentifier) {
            item, options in
            if let image = self.process(item: item, options: options) {
                return another.process(item: .image(image), options: options)
            } else {
                return nil
            }
        }
    }
}

typealias ProcessorImp = ((ImageProcessItem, KingfisherOptionsInfo) -> Image?)

fileprivate struct GeneralProcessor: ImageProcessor {
    let identifier: String
    let p: ProcessorImp
    func process(item: ImageProcessItem, options: KingfisherOptionsInfo) -> Image? {
        return p(item, options)
    }
}

其他ImageProcessor

其他的ImageProcessor包括:

  • RoundCornerImageProcessor
  • ResizingImageProcessor
  • BlurImageProcessor
  • OverlayImageProcessor
  • TintImageProcessor
  • ColorControlsProcessor
  • BlackWhiteProcessor
  • CroppingImageProcessor

以上这些Processor,对于输入的ImageProcessItem,如果item

  • 类型为image,则直接调用Image.kf的相关代码
  • 类型为data,则先使用DefaultImageProcessor将其转换为image,再调用Image.kf的相关代码

Tips

Collection

  • Collection可以相加。
    如[1, 2, 3] + [4, 5] >> [1, 2, 3, 4, 5]
  • 通过在Extension中设定Collection的Iterator.Element,可以实现很多便利的方法。
    比如实现下面的extension后,我们就可以直接使用options.targetCache.
public extension Collection where Iterator.Element == KingfisherOptionsInfoItem {
    /// The target `ImageCache` which is used.
    public var targetCache: ImageCache {
        if let item = lastMatchIgnoringAssociatedValue(.targetCache(.default)),
            case .targetCache(let cache) = item
        {
            return cache
        }
        return ImageCache.default
    }
}
  • 巧用ArraySlice取代Array
    ImagePrefetcher的pendingResources用于记录prefetchResources中还有多少没有fetch,因此我们可以将它定义为ArraySlice,而非Array。这么做的好处在与可以避免没必要的复制。
public init(resources: [Resource],
       options: KingfisherOptionsInfo? = nil,
       progressBlock: PrefetcherProgressBlock? = nil,
       completionHandler: PrefetcherCompletionHandler? = nil)
{
    prefetchResources = resources
    pendingResources = ArraySlice(resources)
    。。。。
}


public func start()
{
    。。。
    let initialConcurentDownloads = min(self.prefetchResources.count, self.maxConcurrentDownloads)
    for _ in 0 ..< initialConcurentDownloads {
        if let resource = self.pendingResources.popFirst() {
            self.startPrefetching(resource)
        }
    }
}

DispatchQueue

safyAsync

直接调用DispatchQueue.async(block)时,并不能保证block被执行的时间,我们在主线程上调用async通常是为了更新UI,直接调用block会更及时。

extension DispatchQueue {
    // This method will dispatch the `block` to self.
    // If `self` is the main queue, and current thread is main thread, the block
    // will be invoked immediately instead of being dispatched.
    func safeAsync(_ block: @escaping ()->()) {
        if self === DispatchQueue.main && Thread.isMainThread {
            block()
        } else {
            async { block() }
        }
    }
}

ioQueue && processQueue

对于会消耗比较多时间的操作,比如文件的io和图片的process,我们可以使用单独的Queue来进行操作,这样可以避免阻塞主线程。
在使用这种线程时,可以搭配.barrier来解决Reader-Writer问题。

QA:

  • ImageDownloader.barrierQueue是一个conccurrent queue,但每次使用都用.barrier在修饰,那concurrent的意义在哪里?为什么不直接定义为series queue?
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容