深入理解 Kingfisher

4,633 阅读23分钟
原文链接: www.jianshu.com

序言

Kingfisher 是由 @onevcat 编写的用于下载和缓存网络图片的轻量级Swift工具库,其中涉及到了包括GCD、Swift高级语法、缓存、硬盘读写、网络编程、图像编码、图形绘制、Gif数据生成和处理、MD5、Associated Objects的使用等大量iOS开发知识。

本文将详尽的对所涉及到的知识点进行讲解,但由于笔者水平有限,失误和遗漏之处在所难免,恳请前辈们批评指正。

一、Kingfisher的架构


Kingfisher.png

Kingfisher 源码中所包含的12个文件及其关系如上图所示,从左至右,由深及浅。
UIImage+Extension 文件内部对 UIImage 以及 NSData 进行了拓展, 包含判定图片类型、图片解码以及Gif数据处理等操作。
String+MD5 负责图片缓存时对文件名进行MD5加密操作。
ImageCache 主要负责将加载过的图片缓存至本地。
ImageDownloader 负责下载网络图片。
KingfisherOptions 内含配置 Kingfisher 行为的部分参数,包括是否设置下载低优先级、是否强制刷新、是否仅缓存至内存、是否允许图像后台解码等设置。
Resource 中的 Resource 结构体记录了图片的下载地址和缓存Key。
ImageTransition 文件中的动画效果将在使用 UIImageView 的拓展 API 时被采用,其底层为UIViewAnimationOptions,此外你也可以自己传入相应地动画操作、完成闭包来配置自己的动画效果。
ThreadHelper 中的 dispatch_async_safely_main_queue 函数接受一个闭包,利用 NSThread.isMainThread 判定并将其放置在主线程中执行。
KingfisherManager 是 Kingfisher 的主控制类,整合了图片下载及缓存操作。
KingfisherOptionsInfoItem 被提供给开发者对 Kingfisher 的各种行为进行控制,包含下载设置、缓存设置、动画设置以及 KingfisherOptions 中的全部配置参数。
UIImage+Kingfisher 以及 UIButton+Kingfisher 对 UIImageView 和 UIButton 进行了拓展,即主要用于提供 Kingfisher 的外部接口。

二、UIImage+Extension

图片格式识别

Magic Number 是用于区分不同文件格式,被放置于文件首的标记数据。Kingfisher 中用它来区分不同的图片格式,如PNG、JPG、GIF。代码如下:

private let pngHeader: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
private let jpgHeaderSOI: [UInt8] = [0xFF, 0xD8]
private let jpgHeaderIF: [UInt8] = [0xFF]
private let gifHeader: [UInt8] = [0x47, 0x49, 0x46]

// MARK: - Image format
enum ImageFormat {
    case Unknown, PNG, JPEG, GIF
}

extension NSData {
    var kf_imageFormat: ImageFormat {
        var buffer = [UInt8](count: 8, repeatedValue: 0)
        self.getBytes(&buffer, length: 8)
        if buffer == pngHeader {
            return .PNG
        } else if buffer[0] == jpgHeaderSOI[0] &&
            buffer[1] == jpgHeaderSOI[1] &&
            buffer[2] == jpgHeaderIF[0]
        {
            return .JPEG
        }else if buffer[0] == gifHeader[0] &&
            buffer[1] == gifHeader[1] &&
            buffer[2] == gifHeader[2]
        {
            return .GIF
        }

        return .Unknown
    }
}

代码上部定义的 imageHeader,就是不同格式图片放置在文件首的对应 Magic Number 数据,我们通过 NSData 的 getBytes: 方法得到图片数据的 Magic Number,通过比对确定图片格式。

图片解码

我们知道 PNG 以及 JPEG 等格式的图片对原图进行了压缩,必须要将其图片数据解码成位图之后才能使用,这是原因,Kingfisher 里提供了用于解码的函数,代码如下:

// MARK: - Decode
extension UIImage {
    func kf_decodedImage() -> UIImage? {
        return self.kf_decodedImage(scale: self.scale)
    }

    func kf_decodedImage(scale scale: CGFloat) -> UIImage? {
        let imageRef = self.CGImage
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.PremultipliedLast.rawValue).rawValue
        let contextHolder = UnsafeMutablePointer()
        let context = CGBitmapContextCreate(contextHolder, CGImageGetWidth(imageRef), CGImageGetHeight(imageRef), 8, 0, colorSpace, bitmapInfo)
        if let context = context {
            let rect = CGRectMake(0, 0, CGFloat(CGImageGetWidth(imageRef)), CGFloat(CGImageGetHeight(imageRef)))
            CGContextDrawImage(context, rect, imageRef)
            let decompressedImageRef = CGBitmapContextCreateImage(context)
            return UIImage(CGImage: decompressedImageRef!, scale: scale, orientation: self.imageOrientation)
        } else {
            return nil
        }
    }
}

这段代码的主要含义是通过 CGBitmapContextCreate 以及 CGContextDrawImage 函数,将被压缩的图片画在 context 上,再通过调用 CGBitmapContextCreateImage 函数,即可完成对被压缩图片的解码。但通过测试后续代码发现,包含 decode 函数的分支从来没被调用过,据本人推测,UIImage 在接收 NSData 数据进行初始化的时候,其本身很可能包含有通过 Magic Number 获知图片格式后,解码并展示的功能,并不需要外部解码。

图片正立

使用 Core Graphics 绘制图片时,图片会倒立显示,Kingfisher 中使用了这个特性创建了一个工具函数来确保图片的正立,虽然该函数并未在后续的文件中使用到,代码如下:

// MARK: - Normalization
extension UIImage {
    public func kf_normalizedImage() -> UIImage {
        if imageOrientation == .Up {
            return self
        }

        UIGraphicsBeginImageContextWithOptions(size, false, scale)
        drawInRect(CGRect(origin: CGPointZero, size: size))
        let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        return normalizedImage;
    }
}

如果该图片方向为正立,返回自身;否则将其用 Core Graphics 绘制,返回正立后的图片。

GIF数据的保存

我们知道,UIImage 并不能直接保存,需要先将其转化为 NSData 才能写入硬盘以及内存中缓存起来,UIKit 提供了两个 C 语言函数:UIImageJPEGRepresentation 和 UIImagePNGRepresentation,以便于将 JPG 及 PNG 格式的图片转化为 NSData 数据,但却并没有提供相应的 UIImageGIFRepresentation,所以我们需要自己编写这个函数以完成对Gif数据的保存,代码如下:

import ImageIO
import MobileCoreServices

// MARK: - GIF
func UIImageGIFRepresentation(image: UIImage) -> NSData? {
    return UIImageGIFRepresentation(image, duration: 0.0, repeatCount: 0)
}

func UIImageGIFRepresentation(image: UIImage, duration: NSTimeInterval, repeatCount: Int) -> NSData? {
    guard let images = image.images else {
        return nil
    }

    let frameCount = images.count
    let gifDuration = duration <= 0.0 ? image.duration / Double(frameCount) : duration / Double(frameCount)

    let frameProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFDelayTime as String: gifDuration]]
    let imageProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: repeatCount]]

    let data = NSMutableData()

    guard let destination = CGImageDestinationCreateWithData(data, kUTTypeGIF, frameCount, nil) else {
        return nil
    }
    CGImageDestinationSetProperties(destination, imageProperties)

    for image in images {
        CGImageDestinationAddImage(destination, image.CGImage!, frameProperties)
    }

    return CGImageDestinationFinalize(destination) ? NSData(data: data) : nil
}

为实现这个功能,我们首先需要在文件头部添加 ImageIOMobileCoreServices 这两个系统库。
第一部分 guard 语句用于确保GIF数据存在,images 即GIF动图中每一帧的静态图片,类型为 [UIImage]? ;第二部分取得图片总张数以及每一帧的持续时间。
CGImageDestination 对象是对数据写入操作的抽象,Kingfisher 用其实现对GIF数据的保存。
CGImageDestinationCreateWithData 指定了图片数据的保存位置、数据类型以及图片的总张数,最后一个参数现需传入 nil。
CGImageDestinationSetProperties 用于传入包含静态图的通用配置参数,此处传入了Gif动图的重复播放次数。
CGImageDestinationAddImage 用于添加每一张静态图片的数据以及对应的属性(可选),此处添加了每张图片的持续时间。
CGImageDestinationFinalize 需要在所有数据被写入后调用,成功返回 true,失败则返回 false。

GIF数据的展示

我们并不能像其他格式的图片一样直接传入 NSData 给 UIImage 来创建一个GIF动图,而是需要使用 UIImage 的 animatedImageWithImages 方法,但此函数所需的参数是 [UIImage],所以我们需要首先将 NSData 格式的图片数据拆分为每一帧的静态图片,再将其传入上述函数之中,代码如下:

extension UIImage {
    static func kf_animatedImageWithGIFData(gifData data: NSData) -> UIImage? {
        return kf_animatedImageWithGIFData(gifData: data, scale: UIScreen.mainScreen().scale, duration: 0.0)
    }

    static func kf_animatedImageWithGIFData(gifData data: NSData, scale: CGFloat, duration: NSTimeInterval) -> UIImage? {

        let options: NSDictionary = [kCGImageSourceShouldCache as String: NSNumber(bool: true), kCGImageSourceTypeIdentifierHint as String: kUTTypeGIF]
        guard let imageSource = CGImageSourceCreateWithData(data, options) else {
            return nil
        }

        let frameCount = CGImageSourceGetCount(imageSource)
        var images = [UIImage]()

        var gifDuration = 0.0

        for i in 0 ..< frameCount {
            guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, i, options) else {
                return nil
            }

            guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil),
                         gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
                   frameDuration = (gifInfo[kCGImagePropertyGIFDelayTime as String] as? NSNumber) else
            {
                return nil
            }

            gifDuration += frameDuration.doubleValue
            images.append(UIImage(CGImage: imageRef, scale: scale, orientation: .Up))
        }

        if (frameCount == 1) {
            return images.first
        } else {
            return UIImage.animatedImageWithImages(images, duration: duration <= 0.0 ? gifDuration : duration)
        }
    }
}

与 CGImageDestination 相对应的,CGImageSource 对象是对数据读出操作的抽象,Kingfisher 用其实现GIF数据的读出。
与 CGImageDestination 的写入操作相类似,这里我们通过 CGImageSourceCreateWithData、CGImageSourceGetCount 以及循环执行相应次数的 CGImageSourceCreateImageAtIndex 来得到 [UIImage],并通过 CGImageSourceCopyPropertiesAtIndex 等相关操作取得每张图片的原持续时间,将其求和,最后将两个对应参数传入 UIImage.animatedImageWithImages 中,即可得到所需的GIF动图。

三、String+MD5

MD5加密

MD5加密在 Kingfisher 中被用于缓存时对文件名的加密,由于其内部实现较为复杂,此处仅提供成品代码以备不时之需,代码如下:

import Foundation

extension String {
    func kf_MD5() -> String {
        if let data = dataUsingEncoding(NSUTF8StringEncoding) {
            let MD5Calculator = MD5(data)
            let MD5Data = MD5Calculator.calculate()
            let resultBytes = UnsafeMutablePointer(MD5Data.bytes)
            let resultEnumerator = UnsafeBufferPointer(start: resultBytes, count: MD5Data.length)
            var MD5String = ""
            for c in resultEnumerator {
                MD5String += String(format: "%02x", c)
            }
            return MD5String
        } else {
            return self
        }
    }
}

/** array of bytes, little-endian representation */
func arrayOfBytes(value:T, length:Int? = nil) -> [UInt8] {
    let totalBytes = length ?? (sizeofValue(value) * 8)

    let valuePointer = UnsafeMutablePointer.alloc(1)
    valuePointer.memory = value

    let bytesPointer = UnsafeMutablePointer(valuePointer)
    var bytes = [UInt8](count: totalBytes, repeatedValue: 0)
    for j in 0.. [UInt8] {
        return arrayOfBytes(self, length: totalBytes)
    }

}

extension NSMutableData {

    /** Convenient way to append bytes */
    func appendBytes(arrayOfBytes: [UInt8]) {
        appendBytes(arrayOfBytes, length: arrayOfBytes.count)
    }

}

class HashBase {

    var message: NSData

    init(_ message: NSData) {
        self.message = message
    }

    /** Common part for hash calculation. Prepare header data. */
    func prepare(len:Int = 64) -> NSMutableData {
        let tmpMessage: NSMutableData = NSMutableData(data: self.message)

        // Step 1. Append Padding Bits
        tmpMessage.appendBytes([0x80]) // append one bit (UInt8 with one bit) to message

        // append "0" bit until message length in bits ≡ 448 (mod 512)
        var msgLength = tmpMessage.length;
        var counter = 0;
        while msgLength % len != (len - 8) {
            counter++
            msgLength++
        }
        let bufZeros = UnsafeMutablePointer(calloc(counter, sizeof(UInt8)))
        tmpMessage.appendBytes(bufZeros, length: counter)

        bufZeros.destroy()
        bufZeros.dealloc(1)

        return tmpMessage
    }
}

func rotateLeft(v:UInt32, n:UInt32) -> UInt32 {
    return ((v << n) & 0xFFFFFFFF) | (v >> (32 - n))
}

class MD5 : HashBase {

    /** specifies the per-round shift amounts */
    private let s: [UInt32] = [7, 12, 17, 22,  7, 12, 17, 22,  7, 12, 17, 22,  7, 12, 17, 22,
        5,  9, 14, 20,  5,  9, 14, 20,  5,  9, 14, 20,  5,  9, 14, 20,
        4, 11, 16, 23,  4, 11, 16, 23,  4, 11, 16, 23,  4, 11, 16, 23,
        6, 10, 15, 21,  6, 10, 15, 21,  6, 10, 15, 21,  6, 10, 15, 21]

    /** binary integer part of the sines of integers (Radians) */
    private let k: [UInt32] = [0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,
        0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,
        0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,
        0x6b901122,0xfd987193,0xa679438e,0x49b40821,
        0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,
        0xd62f105d,0x2441453,0xd8a1e681,0xe7d3fbc8,
        0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,
        0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
        0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,
        0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,
        0x289b7ec6,0xeaa127fa,0xd4ef3085,0x4881d05,
        0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
        0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,
        0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,
        0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,
        0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391]

    private let h:[UInt32] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]

    func calculate() -> NSData {
        let tmpMessage = prepare()

        // hash values
        var hh = h

        // Step 2. Append Length a 64-bit representation of lengthInBits
        let lengthInBits = (message.length * 8)
        let lengthBytes = lengthInBits.bytes(64 / 8)
        tmpMessage.appendBytes(Array(lengthBytes.reverse()));

        // Process the message in successive 512-bit chunks:
        let chunkSizeBytes = 512 / 8 // 64
        var leftMessageBytes = tmpMessage.length
        for (var i = 0; i < tmpMessage.length; i = i + chunkSizeBytes, leftMessageBytes -= chunkSizeBytes) {
            let chunk = tmpMessage.subdataWithRange(NSRange(location: i, length: min(chunkSizeBytes,leftMessageBytes)))

            // break chunk into sixteen 32-bit words M[j], 0 ≤ j ≤ 15
            var M:[UInt32] = [UInt32](count: 16, repeatedValue: 0)
            let range = NSRange(location:0, length: M.count * sizeof(UInt32))
            chunk.getBytes(UnsafeMutablePointer(M), range: range)

            // Initialize hash value for this chunk:
            var A:UInt32 = hh[0]
            var B:UInt32 = hh[1]
            var C:UInt32 = hh[2]
            var D:UInt32 = hh[3]

            var dTemp:UInt32 = 0

            // Main loop
            for j in 0.. () in
            var i:UInt32 = item.littleEndian
            buf.appendBytes(&i, length: sizeofValue(i))
        })

        return buf.copy() as! NSData;
    }
}

上述代码为 String 添加了 kf_MD5 拓展方法,返回值也为 String,只需对需要加密的 String 调用该拓展方法,即可得到对应的加密字符串。

四、ImageCache

缓存功能的架构以及主要属性介绍

缓存功能分为两部分:一是内存缓存,二是硬盘缓存。
我们需要实现的主要功能有:

  • 缓存路径管理
  • 缓存的添加与删除
  • 缓存的读取
  • 缓存的清理
  • 缓存状态监控

缓存管理类所包含的主要属性如下所示:

public class ImageCache {

    //Memory
    private let memoryCache = NSCache()

    /// The largest cache cost of memory cache. The total cost is pixel count of all cached images in memory.
    public var maxMemoryCost: UInt = 0 {
        didSet {
            self.memoryCache.totalCostLimit = Int(maxMemoryCost)
        }
    }

    //Disk
    private let ioQueue: dispatch_queue_t
    private let diskCachePath: String
    private var fileManager: NSFileManager!

    /// The longest time duration of the cache being stored in disk. Default is 1 week.
    public var maxCachePeriodInSecond = defaultMaxCachePeriodInSecond

    /// The largest disk size can be taken for the cache. It is the total allocated size of cached files in bytes. Default is 0, which means no limit.
    public var maxDiskCacheSize: UInt = 0

    private let processQueue: dispatch_queue_t

    /// The default cache.
    public class var defaultCache: ImageCache {
        return defaultCacheInstance
    }

    public init(name: String) {

        if name.isEmpty {
            fatalError("[Kingfisher] You should specify a name for the cache. A cache with empty name is not permitted.")
        }

        let cacheName = cacheReverseDNS + name
        memoryCache.name = cacheName

        let paths = NSSearchPathForDirectoriesInDomains(.CachesDirectory, NSSearchPathDomainMask.UserDomainMask, true)
        diskCachePath = (paths.first! as NSString).stringByAppendingPathComponent(cacheName)

        ioQueue = dispatch_queue_create(ioQueueName + name, DISPATCH_QUEUE_SERIAL)
        processQueue = dispatch_queue_create(processQueueName + name, DISPATCH_QUEUE_CONCURRENT)

        dispatch_sync(ioQueue, { () -> Void in
            self.fileManager = NSFileManager()
        })

        NSNotificationCenter.defaultCenter().addObserver(self, selector: "clearMemoryCache", name: UIApplicationDidReceiveMemoryWarningNotification, object: nil)
        NSNotificationCenter.defaultCenter().addObserver(self, selector: "cleanExpiredDiskCache", name: UIApplicationWillTerminateNotification, object: nil)
        NSNotificationCenter.defaultCenter().addObserver(self, selector: "backgroundCleanExpiredDiskCache", name: UIApplicationDidEnterBackgroundNotification, object: nil)
    }

    deinit {
        NSNotificationCenter.defaultCenter().removeObserver(self)
    }
}

其中,memoryCache: NSCache 用于管理内存缓存。
ioQueue: dispatch_queue_t 为单独的硬盘操作队列,由于硬盘存取操作极为耗时,使其与主线程并行执行以免造成阻塞。
diskCachePath: String 用于设置文件的存储路径。
fileManager: NSFileManager 用于文件管理。
processQueue: dispatch_queue_t 用于执行图片的 decode 操作,不过并不会被调用。
defaultCache 为 ImageCache 类的单例,Swift 中,采用 static let 即可直接创建一个单例,系统会自动调用 dispatch_once。

缓存路径管理

为了方便对硬盘的存取操作,我们需要这样几个工具函数,来帮我们实现通过缓存Key获得某特定缓存的:

  • 对应 UIImage 图片
  • 对应 NSData 数据
  • 硬盘存储路径
  • 加密后的文件名

代码如下:

extension ImageCache {

    func diskImageForKey(key: String, scale: CGFloat) -> UIImage? {
        if let data = diskImageDataForKey(key) {
            return UIImage.kf_imageWithData(data, scale: scale)
        } else {
            return nil
        }
    }

    func diskImageDataForKey(key: String) -> NSData? {
        let filePath = cachePathForKey(key)
        return NSData(contentsOfFile: filePath)
    }

    func cachePathForKey(key: String) -> String {
        let fileName = cacheFileNameForKey(key)
        return (diskCachePath as NSString).stringByAppendingPathComponent(fileName)
    }

    func cacheFileNameForKey(key: String) -> String {
        return key.kf_MD5()
    }
}

由下及上,由深入浅,每个函数都用到了上一个函数的结果并进行了进一步加工。

字典按值排序

在缓存的管理当中,有时候我们需要依照缓存的修改时间进行排序,以确定缓存是否过期,而缓存时间往往位于字典键值对中值的位置,通常情况下对其排序并不是太容易,这里提供一个工具函数,代码如下:

extension Dictionary {
    func keysSortedByValue(isOrderedBefore:(Value, Value) -> Bool) -> [Key] {
        var array = Array(self)
        array.sortInPlace {
            let (_, lv) = $0
            let (_, rv) = $1
            return isOrderedBefore(lv, rv)
        }
        return array.map {
            let (k, _) = $0
            return k
        }
    }
}

接受排序规则闭包,若返回值为 true,则第一个参数在第二个的前面,函数返回排序过后的Key值数组。
函数体的第一句是亮点,这里直接用 Array 的初始化方法将 Dictionary 转成了一个元组数组,每个元组包含两个值,第一个为原字典Key,第二个为原字典Value。
sortInPlace 为在当前数组内存位置上进行排序,闭包里先用两个 let 取到字典Value,将其送入排序规则中比对并返回比对结果,该函数执行过后,我们就能得到一个按字典Value排好序的元组数组。
接着,我们调用 map 函数,将每个元组的第一个值(即原字典Key),取出并覆盖原元组,最后得到有序的字典Key值数组。

缓存的添加与删除

缓存的添加

缓存的添加分为三步:写入内存、写入硬盘、执行 completionHandler,其中写入硬盘操作略复杂,代码如下:

    public func storeImage(image: UIImage, originalData: NSData? = nil, forKey key: String, toDisk: Bool, completionHandler: (() -> ())?) {
        memoryCache.setObject(image, forKey: key, cost: image.kf_imageCost)

        func callHandlerInMainQueue() {
            if let handler = completionHandler {
                dispatch_async(dispatch_get_main_queue()) {
                    handler()
                }
            }
        }

        if toDisk {
            dispatch_async(ioQueue, { () -> Void in
                let imageFormat: ImageFormat
                if let originalData = originalData {
                    imageFormat = originalData.kf_imageFormat
                } else {
                    imageFormat = .Unknown
                }

                let data: NSData?
                switch imageFormat {
                case .PNG: data = UIImagePNGRepresentation(image)
                case .JPEG: data = UIImageJPEGRepresentation(image, 1.0)
                case .GIF: data = UIImageGIFRepresentation(image)
                case .Unknown: data = originalData
                }

                if let data = data {
                    if !self.fileManager.fileExistsAtPath(self.diskCachePath) {
                        do {
                            try self.fileManager.createDirectoryAtPath(self.diskCachePath, withIntermediateDirectories: true, attributes: nil)
                        } catch _ {}
                    }

                    self.fileManager.createFileAtPath(self.cachePathForKey(key), contents: data, attributes: nil)
                    callHandlerInMainQueue()
                } else {
                    callHandlerInMainQueue()
                }
            })
        } else {
            callHandlerInMainQueue()
        }
    }

写入内存操作非常简单,直接调用 NSCache 实例的 setObject 即可,kf_imageCost 为图片的宽乘高乘比例平方的整数值。
callHandlerInMainQueue 为定义的嵌套函数,调用后在主线程上执行 completionHandler。
我们利用 GCD 将硬盘的读写操作放置于 ioQueue 中执行,这里我要多说两句,其实在这个地方我是很不理解的,我的观点是这样的:

首先,对于 PNG、JPEG 格式的图片:
当需要展示的时候,我们从网络中获取到的数据直接可以被 UIImage 的初始化方法识别,所以上文中所提到的 decode 函数并不必要,取到的数据可以直接用来展示;
当需要保存的时候,我们可以直接保存当初下载到的网络数据,而不是像上述代码一样,先根据网络数据判断图片类型,再调用对应的 UIImageRepresentation 将成品图片拆分成数据再保存,我个人认为这是多此一举的。
在测试中,我截取了同一张图片的 originalData 与拆分成品图片后的 data 作对比,结果如下:


originalData.png


data.png

可以看到内容基本相同,而且我将两个 UIImageRepresentation 函数替换为 originalData 后,结果并无不同;
此外,若 originalData 为 nil,imageFormat = .Unknown,在后续的代码中 case .Unknown: data = originalData 语句直接将 data 置空,说明了在有图片传入的情况下,originalData 不可为空,不存在只有图片没有数据,必须通过解码得到数据的情况。所以我依此认为,这段代码中 UIImagePNGRepresentation 以及 UIImageJPEGRepresentation 的使用是完全没有必要的,如果前辈们有不同意见,本人愿意接受批评指正。
其次,对于 GIF 格式的图片:
当需要展示的时候,我认为 kf_animatedImageWithGIFData(gifData data: NSData, scale: CGFloat, duration: NSTimeInterval) -> UIImage? 函数的存在还是有必要的,因为 UIImage 的初始化方法并不能直接处理GIF数据,而 UIImage.animatedImageWithImages 方法所接受的参数也并不是 NSData 而是 [UIImage]。
但当需要保存的时候,我同样质疑 UIImageGIFRepresentation 函数存在的必要性,我在翻阅 ImageDownloader 源码的时候发现,若所获得的是GIF数据,kf_animatedImageWithGIFData 也可以直接处理将其转换成GIF动图,即表示,得到的网络数据(也就是这里的 originalData),是可以被直接识别的,并不需要调用 UIImageGIFRepresentation 来把依照 originalData 生成的GIF动图拆分成 data 再保存,而是直接保存 originalData 即可。

继续回到代码中,剩下的操作就非常简单了,在取到数据的情况下,若文件目录不存在,先生成目录再保存文件,最后调用 completionHandler。

缓存的删除

删除操作十分简单,同样分三步:删除内存缓存、依照路径删除硬盘缓存、执行 completionHandler,代码如下:

    public func removeImageForKey(key: String, fromDisk: Bool, completionHandler: (() -> ())?) {
        memoryCache.removeObjectForKey(key)

        func callHandlerInMainQueue() {
            if let handler = completionHandler {
                dispatch_async(dispatch_get_main_queue()) {
                    handler()
                }
            }
        }

        if fromDisk {
            dispatch_async(ioQueue, { () -> Void in
                do {
                    try self.fileManager.removeItemAtPath(self.cachePathForKey(key))
                } catch _ {}
                callHandlerInMainQueue()
            })
        } else {
            callHandlerInMainQueue()
        }
    }

memoryCache.removeObjectForKey 删除内存缓存,self.fileManager.removeItemAtPath 删除硬盘缓存。

缓存的读取

缓存的读取所完成的操作也十分简单,首先确保 completionHandler 不为空,之后分别尝试从内存和硬盘中读取缓存,若缓存只存在于硬盘中,读取后,我们将其添加到内存中,代码如下:

extension ImageCache {
    /**
    Get an image for a key from memory or disk.

    - parameter key:               Key for the image.
    - parameter options:           Options of retrieving image.
    - 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.
    */
    public func retrieveImageForKey(key: String, options:KingfisherManager.Options, completionHandler: ((UIImage?, CacheType!) -> ())?) -> RetrieveImageDiskTask? {
        // No completion handler. Not start working and early return.
        guard let completionHandler = completionHandler else {
            return nil
        }

        var block: RetrieveImageDiskTask?
        if let image = self.retrieveImageInMemoryCacheForKey(key) {

            //Found image in memory cache.
            if options.shouldDecode {
                dispatch_async(self.processQueue, { () -> Void in
                    let result = image.kf_decodedImage(scale: options.scale)
                    dispatch_async(options.queue, { () -> Void in
                        completionHandler(result, .Memory)
                    })
                })
            } else {
                completionHandler(image, .Memory)
            }
        } else {
            var sSelf: ImageCache! = self
            block = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS) {

                // Begin to load image from disk
                dispatch_async(sSelf.ioQueue, { () -> Void in
                    if let image = sSelf.retrieveImageInDiskCacheForKey(key, scale: options.scale) {
                        if options.shouldDecode {
                            dispatch_async(sSelf.processQueue, { () -> Void in
                                let result = image.kf_decodedImage(scale: options.scale)
                                sSelf.storeImage(result!, forKey: key, toDisk: false, completionHandler: nil)

                                dispatch_async(options.queue, { () -> Void in
                                    completionHandler(result, .Memory)
                                    sSelf = nil
                                })
                            })
                        } else {
                            sSelf.storeImage(image, forKey: key, toDisk: false, completionHandler: nil)
                            dispatch_async(options.queue, { () -> Void in
                                completionHandler(image, .Disk)
                                sSelf = nil
                            })
                        }
                    } else {
                        // No image found from either memory or disk
                        dispatch_async(options.queue, { () -> Void in
                            completionHandler(nil, nil)
                            sSelf = nil
                        })
                    }
                })
            }

            dispatch_async(dispatch_get_main_queue(), block!)
        }

        return block
    }

    /**
    Get an image for a key from memory.

    - parameter key: Key for the image.

    - returns: The image object if it is cached, or `nil` if there is no such key in the cache.
    */
    public func retrieveImageInMemoryCacheForKey(key: String) -> UIImage? {
        return memoryCache.objectForKey(key) as? UIImage
    }

    /**
    Get an image for a key from disk.

    - parameter key: Key for the image.
    - param scale: The scale factor to assume when interpreting the image data.

    - returns: The image object if it is cached, or `nil` if there is no such key in the cache.
    */
    public func retrieveImageInDiskCacheForKey(key: String, scale: CGFloat = KingfisherManager.DefaultOptions.scale) -> UIImage? {
        return diskImageForKey(key, scale: scale)
    }
}

这里主要说两点,第一,若提交的 block 将异步执行的话,DISPATCH_BLOCK_INHERIT_QOS_CLASS 需要被传入,同步执行则应传入 DISPATCH_BLOCK_ENFORCE_QOS_CLASS。
第二,dispatch_async(dispatch_get_main_queue(), block!) 之中,若将 async 改为 sync, 而这段代码又执行于主线程上时,必然会导致死锁,或者说,在当前线程上调用 dispatch_sync 方法给自身线程分配任务,则必然会导致死锁。
因为 dispatch_sync 需要等待内部操作执行完成后才会返回,进而释放当前线程,而如果内部操作又分配在自身线程上时,若自身不释放,内部的操作就会一直等待,就会出现不返回不释放,不释放不执行更不会返回的死锁。
而使用 dispatch_async 则不会出现这种问题,因为 dispatch_async 方法不必等待内部操作完成便直接返回,释放当前线程后,block 内部的操作便可以开始执行。

缓存清理

我们在缓存清理方面的需求一般有两个:清理所有硬盘内存缓存、后台自动删除过期超量硬盘缓存。

清理所有缓存

这部分操作相较于下部分简单一些,代码如下:

    @objc public func clearMemoryCache() {
        memoryCache.removeAllObjects()
    }

    /**
    Clear disk cache. This is an async operation.
    */
    public func clearDiskCache() {
        clearDiskCacheWithCompletionHandler(nil)
    }

    /**
    Clear disk cache. This is an async operation.

    - parameter completionHander: Called after the operation completes.
    */
    public func clearDiskCacheWithCompletionHandler(completionHander: (()->())?) {
        dispatch_async(ioQueue, { () -> Void in
            do {
                try self.fileManager.removeItemAtPath(self.diskCachePath)
            } catch _ {
            }
            do {
                try self.fileManager.createDirectoryAtPath(self.diskCachePath, withIntermediateDirectories: true, attributes: nil)
            } catch _ {
            }

            if let completionHander = completionHander {
                dispatch_async(dispatch_get_main_queue(), { () -> Void in
                    completionHander()
                })
            }
        })
    }

这里需要注意的是,我们使用 self.fileManager.removeItemAtPath 删除所有硬盘缓存之后,需要使用 self.fileManager.createDirectoryAtPath 重建缓存目录。

后台自动删除过期超量硬盘缓存

这部分的重点有如下几个:

  • 遍历所有缓存文件
  • 判断缓存文件是否过期
  • 将缓存文件按日期排序,逐步清理直到所占空间小于预定大小
  • 后台自动清理缓存

解决前三个问题,代码如下:

    public func cleanExpiredDiskCacheWithCompletionHander(completionHandler: (()->())?) {
        // Do things in cocurrent io queue
        dispatch_async(ioQueue, { () -> Void in
            let diskCacheURL = NSURL(fileURLWithPath: self.diskCachePath)

                let resourceKeys = [NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey]
                let expiredDate = NSDate(timeIntervalSinceNow: -self.maxCachePeriodInSecond)
                var cachedFiles = [NSURL: [NSObject: AnyObject]]()
                var URLsToDelete = [NSURL]()

                var diskCacheSize: UInt = 0

                if let fileEnumerator = self.fileManager.enumeratorAtURL(diskCacheURL,
                    includingPropertiesForKeys: resourceKeys,
                    options: NSDirectoryEnumerationOptions.SkipsHiddenFiles,
                    errorHandler: nil) {

                    for fileURL in fileEnumerator.allObjects as! [NSURL] {

                        do {
                            let resourceValues = try fileURL.resourceValuesForKeys(resourceKeys)
                            // If it is a Directory. Continue to next file URL.
                            if let isDirectory = resourceValues[NSURLIsDirectoryKey] as? NSNumber {
                                if isDirectory.boolValue {
                                    continue
                                }
                            }

                            // If this file is expired, add it to URLsToDelete
                            if let modificationDate = resourceValues[NSURLContentModificationDateKey] as? NSDate {
                                if modificationDate.laterDate(expiredDate) == expiredDate {
                                    URLsToDelete.append(fileURL)
                                    continue
                                }
                            }

                            if let fileSize = resourceValues[NSURLTotalFileAllocatedSizeKey] as? NSNumber {
                                diskCacheSize += fileSize.unsignedLongValue
                                cachedFiles[fileURL] = resourceValues
                            }
                        } catch _ {
                        }

                    }
                }

                for fileURL in URLsToDelete {
                    do {
                        try self.fileManager.removeItemAtURL(fileURL)
                    } catch _ {
                    }
                }

                if self.maxDiskCacheSize > 0 && diskCacheSize > self.maxDiskCacheSize {
                    let targetSize = self.maxDiskCacheSize / 2

                    // Sort files by last modify date. We want to clean from the oldest files.
                    let sortedFiles = cachedFiles.keysSortedByValue({ (resourceValue1, resourceValue2) -> Bool in

                        if let date1 = resourceValue1[NSURLContentModificationDateKey] as? NSDate {
                            if let date2 = resourceValue2[NSURLContentModificationDateKey] as? NSDate {
                                return date1.compare(date2) == .OrderedAscending
                            }
                        }
                        // Not valid date information. This should not happen. Just in case.
                        return true
                    })

                    for fileURL in sortedFiles {

                        do {
                            try self.fileManager.removeItemAtURL(fileURL)
                        } catch {

                        }

                        URLsToDelete.append(fileURL)

                        if let fileSize = cachedFiles[fileURL]?[NSURLTotalFileAllocatedSizeKey] as? NSNumber {
                            diskCacheSize -= fileSize.unsignedLongValue
                        }

                        if diskCacheSize < targetSize {
                            break
                        }
                    }
                }

                dispatch_async(dispatch_get_main_queue(), { () -> Void in

                    if URLsToDelete.count != 0 {
                        let cleanedHashes = URLsToDelete.map({ (url) -> String in
                            return url.lastPathComponent!
                        })

                        NSNotificationCenter.defaultCenter().postNotificationName(KingfisherDidCleanDiskCacheNotification, object: self, userInfo: [KingfisherDiskCacheCleanedHashKey: cleanedHashes])
                    }

                    if let completionHandler = completionHandler {
                        completionHandler()
                    }
                })
        })
    }

其中,关于第一个问题:我们利用 NSFileManager 的实例方法 enumeratorAtURL,来获得 NSDirectoryEnumerator 的实例 fileEnumerator,再利用 for in 遍历 fileEnumerator.allObjects 来获取每个缓存文件的 fileURL: NSURL。
第二个问题:我们通过 fileURL.resourceValuesForKeys[NSURLContentModificationDateKey] 来得到对应文件的最近修改日期属性,将其与过期时间比较,即可确定其是否过期。
第三个问题:我们通过之前的字典按值排序拓展方法来对缓存文件按最近修改日期进行排序,随即对其遍历,按顺序删除,直到小于预定大小。

除以上三个问题之外,我们还希望,当应用程序在进入后台的时候,可以自动检测过期超量缓存,并在后台完成清理操作,实现代码如下:

    /**
    Clean expired disk cache when app in background. This is an async operation.
    In most cases, you should not call this method explicitly. 
    It will be called automatically when `UIApplicationDidEnterBackgroundNotification` received.
    */
    @objc public func backgroundCleanExpiredDiskCache() {

        func endBackgroundTask(inout task: UIBackgroundTaskIdentifier) {
            UIApplication.sharedApplication().endBackgroundTask(task)
            task = UIBackgroundTaskInvalid
        }

        var backgroundTask: UIBackgroundTaskIdentifier!

        backgroundTask = UIApplication.sharedApplication().beginBackgroundTaskWithExpirationHandler { () -> Void in
            endBackgroundTask(&backgroundTask!)
        }

        cleanExpiredDiskCacheWithCompletionHander { () -> () in
            endBackgroundTask(&backgroundTask!)
        }
    }

该函数会在应用进入运行时自动调用,实现方法是利用 NSNotificationCenter.defaultCenter 来监听系统的 UIApplicationDidEnterBackgroundNotification 广播;
beginBackgroundTaskWithExpirationHandler 以及 endBackgroundTask 之间的操作会在后台执行,不止可以做自动缓存清理,你也可以将一些比较耗时的下载操作放在后台进行;backgroundTask 作为任务开始和结束的标识,endBackgroundTask 函数参数列表中的 inout 是为了使 backgroundTask 在函数体内部的修改有效化,类似于传入指针。

GCD相关

我们在 Kingfisher 的源码中,经常可以看到 completionHandler 的存在,这个闭包将在所有操作完成后调用,多用于做结果的处理;但有时候我们为了调用 completionHandler 所要面对的问题要比 Kingfisher 中所涉及到的要复杂的多,比如,我们需要在一个线程执行完分多次提交的多个异步闭包之后调用某个函数或者执行另一个闭包,这样的话,我们就不能像 Kingfisher 里这样单纯的将 completionHandler 放在闭包尾了事了,GCD 提供了先进的特性来解决我们的这种需求,代码如下:

dispatch_group_t group = dispatch_group_create();

dispatch_group_async(group,dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^ {
    // block1
    NSLog(@"Block1");
    [NSThread sleepForTimeInterval:5.0];
    NSLog(@"Block1 End");
});

dispatch_group_async(group,dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^ {
    // block2
    NSLog(@"Block2");
    [NSThread sleepForTimeInterval:8.0];
    NSLog(@"Block2 End");
});

dispatch_group_notify(group,dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^ {
    // block3
    NSLog(@"Block3");
});

dispatch_release(group);

这是其中一种比较简洁的实现方法,我们先创建一个 dispatch_group_t 实例,通过使用 dispatch_group_async 为其提交异步闭包任务,当这个 group 处理完所有的闭包任务之后,dispatch_group_notify 才会被调用,你就可以把你需要最后执行的 completionHandler 放置在这个地方。

缓存状态监控

缓存状态监控是缓存管理当中很有用的工具,主要包含:

  • 查询某图片是否存在于缓存中
  • 查询某图片的缓存文件名
  • 查询当前缓存所占硬盘空间大小

代码如下:

// MARK: - Check cache status
public extension ImageCache {

    /**
    *  Cache result for checking whether an image is cached for a key.
    */
    public struct CacheCheckResult {
        public let cached: Bool
        public let cacheType: CacheType?
    }

    /**
    Check whether an image is cached for a key.

    - parameter key: Key for the image.

    - returns: The check result.
    */
    public func isImageCachedForKey(key: String) -> CacheCheckResult {

        if memoryCache.objectForKey(key) != nil {
            return CacheCheckResult(cached: true, cacheType: .Memory)
        }

        let filePath = cachePathForKey(key)

        if fileManager.fileExistsAtPath(filePath) {
            return CacheCheckResult(cached: true, cacheType: .Disk)
        }

        return CacheCheckResult(cached: false, cacheType: nil)
    }

    /**
    Get the hash for the key. This could be used for matching files.

    - parameter key: The key which is used for caching.

    - returns: Corresponding hash.
    */
    public func hashForKey(key: String) -> String {
        return cacheFileNameForKey(key)
    }

    /**
    Calculate the disk size taken by cache. 
    It is the total allocated size of the cached files in bytes.

    - parameter completionHandler: Called with the calculated size when finishes.
    */
    public func calculateDiskCacheSizeWithCompletionHandler(completionHandler: ((size: UInt) -> ())?) {
        dispatch_async(ioQueue, { () -> Void in
            let diskCacheURL = NSURL(fileURLWithPath: self.diskCachePath)

            let resourceKeys = [NSURLIsDirectoryKey, NSURLTotalFileAllocatedSizeKey]
            var diskCacheSize: UInt = 0

            if let fileEnumerator = self.fileManager.enumeratorAtURL(diskCacheURL,
                includingPropertiesForKeys: resourceKeys,
                options: NSDirectoryEnumerationOptions.SkipsHiddenFiles,
                errorHandler: nil) {

                    for fileURL in fileEnumerator.allObjects as! [NSURL] {

                        do {
                            let resourceValues = try fileURL.resourceValuesForKeys(resourceKeys)
                            // If it is a Directory. Continue to next file URL.
                            if let isDirectory = resourceValues[NSURLIsDirectoryKey]?.boolValue {
                                if isDirectory {
                                    continue
                                }
                            }

                            if let fileSize = resourceValues[NSURLTotalFileAllocatedSizeKey] as? NSNumber {
                                diskCacheSize += fileSize.unsignedLongValue
                            }
                        } catch _ {
                        }

                    }
            }

            dispatch_async(dispatch_get_main_queue(), { () -> Void in
                if let completionHandler = completionHandler {
                    completionHandler(size: diskCacheSize)
                }
            })
        })
    }
}

前两个都是简单的方法调用,不需赘述;计算当前缓存所占空间与前面删除过期超量缓存的相关操作极为相似,基本流程为:一、切换线程,二、遍历文件,三、累加文件大小,四、切回主线程执行 completionHandler。

五、KingfisherOptions

KingfisherOptions 文件包含了对 Kingfisher 操作部分设置参数,其本身并没有太多可讲的,但其代码内用到了 Swift2.0 中所引入的一个新类型 OptionSetType,这段代码可以看做是 OptionSetType 的基本用法引导,具体内容如下:

public struct KingfisherOptions : OptionSetType {

    public let rawValue: UInt

    public init(rawValue: UInt) {
        self.rawValue = rawValue
    }

    /// None options. Kingfisher will keep its default behavior.
    public static let None = KingfisherOptions(rawValue: 0)

    /// Download in a low priority.
    public static let LowPriority = KingfisherOptions(rawValue: 1 << 0)

    /// Try to send request to server first. If response code is 304 (Not Modified), use the cached image. Otherwise, download the image and cache it again.
    public static var ForceRefresh = KingfisherOptions(rawValue: 1 << 1)

    /// Only cache downloaded image to memory, not cache in disk.
    public static var CacheMemoryOnly = KingfisherOptions(rawValue: 1 << 2)

    /// Decode the image in background thread before using.
    public static var BackgroundDecode = KingfisherOptions(rawValue: 1 << 3)

    /// If set it will dispatch callbacks asynchronously to the global queue DISPATCH_QUEUE_PRIORITY_DEFAULT. Otherwise it will use the queue defined at KingfisherManager.DefaultOptions.queue
    public static var BackgroundCallback = KingfisherOptions(rawValue: 1 << 4)

    /// Decode the image using the same scale as the main screen. Otherwise it will use the same scale as defined on the KingfisherManager.DefaultOptions.scale.
    public static var ScreenScale = KingfisherOptions(rawValue: 1 << 5)
}

除第一位以外,rawValue的每一个二进制位都代表一个单独的配置参数,这样直接通过判断 rawValue 的值,就能知道哪些选项是被选中的,比如,若 rawValue == 10,其二进制位后四位为1010,即可知道 BackgroundDecode 以及 ForceRefresh 被选中,当然你也可以直接使用 OptionSetType 协议所提供的 contains 函数来判定某选项是否被包含。
另外说一点,Kingfisher 这里的 OptionSetType 的用法并不标准,更为常见的用法是不设置 rawValue == 0 时所对应的参数,这样每个配置参数就正好对应一位二进制位了,也更容易理解。