Kingfisher源码阅读(三)

上一篇地址:Kingfisher源码阅读(二)
第一篇地址:Kingfisher源码阅读(一)

上篇看完下载模块了,这篇主要是看一下缓存模块。我们是从KingfisherManager中的downloadAndCacheImageWithURL为入口进入到下载模块的,缓存模块也从这里进入。再贴一下downloadAndCacheImageWithURL吧:

func downloadAndCacheImageWithURL(URL: NSURL,
    forKey key: String,
    retrieveImageTask: RetrieveImageTask,
    progressBlock: DownloadProgressBlock?,
    completionHandler: CompletionHandler?,
    options: Options,
    targetCache: ImageCache,
    downloader: ImageDownloader)
{
    //下载图片
    downloader.downloadImageWithURL(URL, retrieveImageTask: retrieveImageTask, options: options,
        progressBlock: { receivedSize, totalSize in
            progressBlock?(receivedSize: receivedSize, totalSize: totalSize)
        },
        completionHandler: { image, error, imageURL, originalData in
            //304 NOT MODIFIED,尝试从缓存中取数据
            if let error = error where error.code == KingfisherError.NotModified.rawValue {
                // Not modified. Try to find the image from cache.
                // (The image should be in cache. It should be guaranteed by the framework users.)
                targetCache.retrieveImageForKey(key, options: options, completionHandler: { (cacheImage, cacheType) -> () in
                    completionHandler?(image: cacheImage, error: nil, cacheType: cacheType, imageURL: URL)
                    
                })
                return
            }
            
            if let image = image, originalData = originalData {
                targetCache.storeImage(image, originalData: originalData, forKey: key, toDisk: !options.cacheMemoryOnly, completionHandler: nil)
            }
            
            completionHandler?(image: image, error: error, cacheType: .None, imageURL: URL)
        }
    )
}

在下载完图片之后的完成闭包中(会在下载请求结束后调用),如果服务器返回状态码304,说明服务器图片未更新,我们可以从缓存中取得图片数据,就是调用targetCache(ImageCache的一个实例)retrieveImageForKey。我们进入到ImageCache中看看这个方法的具体逻辑:

  • 给完成闭包进行解包,若为空则提前返回:
// No completion handler. Not start working and early return.
guard let completionHandler = completionHandler else {
      return nil
}
  • 如果内存中有缓存则直接从内存中取图片;再判断图片是否需要解码,若需要,则先解码再调用完成闭包,否则直接调用完成闭包:
//如果内存中有缓存,则直接从内存中读取图片
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)
    }
}
  • 如果内存中没有缓存,则从文件中取图片,并判断是否需要进行解码,若需要则先解码再将它缓存到内存中然后执行完成闭包,否则直接缓存到内存中然后执行完成闭包,这里有一些关于GCD和避免retain cycle的技术细节,我写在注释中了:
//会在回调中置空(为了避免retain cycle?)
var sSelf: ImageCache! = self
//创建一个调度对象块(可以使用dispatch_block_cancle(block)在对象块执行前取消对象块),DISPATCH_BLOCK_INHERIT_QOS_CLASS这个flag表明块从它进入的队列那里继承Qos等级
block = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS) {
    
    // Begin to load image from disk
    dispatch_async(sSelf.ioQueue, { () -> Void in
        //通过key从文件中获取缓存图片
        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
            })
        }
    })
}
//调度到主线程队列,retrieveImageForKey函数本身是在主线程中的,所以block会在retrieveImageForKey返回之后执行,而在执行之前,还可以被取消。
dispatch_async(dispatch_get_main_queue(), block!)

获取图片就是这样了,这个方法里调用了storeImage这个方法,显然是用来缓存图片的,来看一下它的具体逻辑:

  • 缓存到内存:
//内存缓存,memoryCache是一个NSCache,cost是图片尺寸(像素)
memoryCache.setObject(image, forKey: key, cost: image.kf_imageCost)
  • 如果方法参数toDisktrue则先将其缓存到文件(如果图片数据存在并能被正确解析的话),然后调用完成闭包:
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)
            //若originalData为nil,重绘图片后解析成PNG数据
        case .Unknown: data = originalData ?? UIImagePNGRepresentation(image.kf_normalizedImage())
        }
        
        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)
            //在主线程执行回调(一般是UI操作吧)
            callHandlerInMainQueue()
        } else {
            callHandlerInMainQueue()
        }
    })
}

整个缓存逻辑就是这样,这里有一个用来解析图片格式的属性kf_imageFormat,它是NSData的一个扩展属性:

extension NSData {
    //图片格式解析
    var kf_imageFormat: ImageFormat {
        var buffer = [UInt8](count: 8, repeatedValue: 0)
        //获取前8个Byte
        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
    }
}

至于pngHeaderjpgHeaderSOIjpgHeaderIFgifHeader这几个东西么,是几个常量:

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]

它们虽然是全局的,但因为访问权限是private所以只能在当前文件内使用。这段代码思路很清晰,就是通过读取图片数据的头几个字节然后和对应图片格式标准进行比对。对图片格式感兴趣的同学可以看看这篇文章——移动端图片格式调研,作者是最近风头正劲的YYKit的作者ibireme。

ImageCache中还有一个删除过期缓存的方法cleanExpiredDiskCacheWithCompletionHander,我觉得也挺关键的,来看一下它的具体逻辑吧:

  • 一些准备工作,取缓存路径,过期时间等:
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),
        urls = fileEnumerator.allObjects as? [NSURL] {
    for fileURL in urls {
            
        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 _ {
    }
}
  • 若剩余缓存内容超过预设的最大缓存尺寸,则删除存在时间较长的缓存,并将已删除图片的URL也加大删除队列中(为了一会儿的广播),直到缓存尺寸到达预设最大尺寸的一半:
//若当前缓存内容超过预设的最大缓存尺寸,则先将文件根据时间排序(旧的在前),然后开始循环删除,直到尺寸降到最大缓存尺寸的一半。
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, let date2 = resourceValue2[NSURLContentModificationDateKey] as? NSDate {
        return date1.compare(date2) == .OrderedAscending
        }
        */
        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()
    }
})

缓存模块的主要内容就这些了,其他还有一些辅助方法像计算缓存尺寸啊、图片的排序啊、把图片URL进行MD5加密作为缓存文件名啊等等,我就不具体写了,有兴趣的同学可以直接去看源码。在UIImage+Extension文件中还有一些处理图片的扩展方法,诸如标准化图片格式、GIF图片的存储、GIF图片的展示等等我也不细讲了,这些都算是一些套路上的东西,正确调用苹果给的API就好了。

Kingfisher中还用到了很多小技巧,比如对关联对象(Associated Object)的使用,解决了extension不能扩展存储属性的问题:

//全局变量,用来作为关联对象(估计是因为extension里面不能添加储存属性,只能通过关联对象配合计算属性和方法的方式来hack)
// MARK: - Associated Object
private var lastURLKey: Void?
...
public extension UIImageView {
    /// Get the image URL binded to this image view.
    public var kf_webURL: NSURL? {
        return objc_getAssociatedObject(self, &lastURLKey) as? NSURL
    }
    private func kf_setWebURL(URL: NSURL) {
        objc_setAssociatedObject(self, &lastURLKey, URL, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
}

最后还是小结一下知识点吧:

  • GCD的调度对象块(dispatch_block_t),可以在执行前取消(dispatch_block_cancel)
  • 文件操作相关知识(遍历文件、跳过隐藏文件、按日期排序文件等等)
  • 图片处理相关知识(判断图片格式、处理GIF等等)
  • MD5摘要算法(这个我并没有仔细看)
  • Associated Object的运用

对了,最后的最后,Swift已经开源啦!

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

推荐阅读更多精彩内容