Kingfisher源码解析之ImageCache

Kingfisher源码解析系列,由于水平有限,哪里有错,肯请不吝赐教

Kingfisher中ImageCache里提供内存缓存和磁盘缓存,分别是MemoryStorage.Backend<KFCrossPlatformImage>DiskStorage.Backend<Data>来实现的,注:内存缓存和磁盘缓存都是通过class Backend,不过这2个类,是完全不同的类,使用枚举来充当命名空间来区分的,分别定义在MemoryStorage.swiftDiskStorage.swift

内存缓存

内存缓存一共有三个类构成,Backend提供缓存的功能,Config提供缓存的配置项,StorageObject<T>缓存的封装类型

Config的主要内容
public struct Config {
    //内存缓存的最大容量,ImageCache.default中提供的默认值是设备物理内存的四分之一
    public var totalCostLimit: Int
    //内存缓存的最大长度
    public var countLimit: Int = .max
    //内存缓存的的过期时长
    public var expiration: StorageExpiration = .seconds(300)
    //清除过期缓存的时间间隔
    public let cleanInterval: TimeInterval
}
StorageObject<T>的主要内容
class StorageObject<T> {
      //缓存的真正的值
      let value: T
      //存活时间,也就是多久之后过期
      let expiration: StorageExpiration
      //缓存e的key
      let key: String
      //过期时间,默认值是当前时间加上expiration
      private(set) var estimatedExpiration: Date
      // 更新过期时间
      func extendExpiration(_ extendingExpiration: ExpirationExtending = .cacheTime) {
          switch extendingExpiration {
          case .none://不更新过期时间
              return
          case .cacheTime://把过期时间设置为当前时间加上存活时间
              self.estimatedExpiration = expiration.estimatedExpirationSinceNow
          case .expirationTime(let expirationTime)://把过期时间设置为指定时间
              self.estimatedExpiration = expirationTime.estimatedExpirationSinceNow
          }
      }
      // 是否已经过期
      var expired: Bool {
          //estimatedExpiration.isPast 是对Date的一个扩展方法,判断estimatedExpiration是否小于当前时间
          return estimatedExpiration.isPast
      }
}
Backend的主要内容
public class Backend<T: CacheCostCalculable> {
    //使用NSCache进行缓存
    let storage = NSCache<NSString, StorageObject<T>>()
    //存放所有缓存的key,在删除过期缓存是有用
    var keys = Set<String>()
    //定时器,用于定时清除过期数据
    private var cleanTimer: Timer? = nil
    //配置项
    public var config: Config
    ...下面还有一些缓存数据,读取数据,删除缓存,是否已缓存,删除过期数据等方法
}

由上面我们可以看出,Kingfisher中内存缓存是用NSCache实现的,NSCache是一个类似于Dictionary的类,拥有相似的API,不过区别于Dictionary的是,NSCache是线程安全的,并且提供了设置最大缓存个数和最大缓存大小的配置,Backend就是通过设置NSCache的countLimittotalCostLimit来实现最大缓存个数和最大缓存大小。

通过下面的代码,看下Backend是如何缓存数据,读取数据,判断是否已缓存,删除缓存,删除过期数据的?代码中有详细的注释,注:下面的代码删除了一些非核心代码,比如异常,加锁保证线程安全等

缓存数据
func store(value: T,forKey key: String,expiration: StorageExpiration? = nil) {
    //获取存活时间,若缓存时没设置,则从配置中获取
    let expiration = expiration ?? config.expiration
    //判断是否过期,若已经过期直接返回
    guard !expiration.isExpired else { return }
    //把要缓存的值封装成StorageObject类型
    let object = StorageObject(value, key: key, expiration: expiration)
    //把结果缓存起来
    storage.setObject(object, forKey: key as NSString, cost: value.cacheCost)
    //把key保存起来
    keys.insert(key)
}
读取数据,判断数据是否已缓存
// 读取数据
func value(forKey key: String, extendingExpiration: ExpirationExtending = .cacheTime) -> T? {
    //从NSCache中获取数据,如获取不到直接返回nil
    guard let object = storage.object(forKey: key as NSString) else { return nil }
    //判断是否过期,若过期直接返回nil
    if object.expired { return nil }
    //去更新过期时间
    object.extendExpiration(extendingExpiration)
    return object.value
}
// 判断是否缓存,其本质就是去读取数据,只是不更新缓存时间,若取到,则已缓存,否则未缓存
func isCached(forKey key: String) -> Bool {
    guard let _ = value(forKey: key, extendingExpiration: .none) else {
        return false
    }
    return true
}
删除缓存
 func remove(forKey key: String) throws {
    storage.removeObject(forKey: key as NSString)
    keys.remove(key)
}
删除过期数据,这里使用Set存储key的原因是NSCache,并没有像Dictionary一样提供获取allKeys或allValues的方法
func removeExpired() {
    for key in keys {
        let nsKey = key as NSString
        //通过key获取数据,若获取失败,则删除从keys中删除key
        guard let object = storage.object(forKey: nsKey) else {
            keys.remove(key)
            continue
        }
        //判断object是否过期,若过期,则从cache中删除数据,从keys中删除key
        if object.estimatedExpiration.isPast {
            storage.removeObject(forKey: nsKey)
            keys.remove(key)
        }
    }
}

磁盘缓存

Kingfisher中磁盘缓存是通过文件系统来实现的,也就是说每个缓存的数据都对应一个文件,其中Kingfisher把文件的创建时间修改为最后一次读取的时间,把文件的修改时间修改为过期时间。

磁盘缓存一共有三个类构成,Backend提供缓存的功能,Config提供缓存的配置项,FileMeta存储着文件信息。

Config的主要内容
public struct Config {
    //磁盘缓存占用磁盘的最大值,为0z时,表示不限制
    public var sizeLimit: UInt
    //存活时间
    public var expiration: StorageExpiration = .days(7)
    //文件的扩展名
    public var pathExtension: String? = nil
    //是否需要把文件名哈希
    public var usesHashedFileName = true
    //操作文件的FileManager
    let fileManager: FileManager
    //文件缓存所在的文件夹,默认在cache文件夹里
    let directory: URL?
}

FileMeta的主要内容
struct FileMeta {
    //文件路径
    let url: URL
    //文件最后一次读取时间
    //这个在超过sizeLimit大小时,需要删除文件时,用此属性进行排序,把时间较早的删除掉
    let lastAccessDate: Date?
    //过期时间
    let estimatedExpirationDate: Date?
    //是否是个文件夹
    let isDirectory: Bool
    //文件大小
    let fileSize: Int
}
Backend的主要内容
public class Backend<T: DataTransformable> {
    //配置信息
    public var config: Config
    //写入文件所在的文件夹,默认在cache文件夹里
    public let directoryURL: URL
    //修改文件原信息时,所在的队列
    let metaChangingQueue: DispatchQueue
     //该方法会在init着调用,保证directoryURLs文件夹,已经被创建过了
    func prepareDirectory() throws {
        let fileManager = config.fileManager
        let path = directoryURL.path
        guard !fileManager.fileExists(atPath: path) else { return }
        try fileManager.createDirectory(atPath: path,withIntermediateDirectories: true,attributes: nil)
    }
    ...下面还有缓存数据,读取数据,判断是否已缓存,删除缓存,删除过期缓存,删除超过sizeLimit的缓存,统计缓存大小等
}

通过下面的代码看Backend是如何缓存数据,读取数据,判断是否已缓存,删除缓存,删除过期缓存,删除超过sizeLimit的缓存,统计缓存大小以及如何通过key生成文件名的?代码中有详细的注释。注:下面的代码删除了一些非核心代码,比如异常,加锁保证线程安全等

通过key生成文件名

下面那段代码和源码中不太一样,但逻辑是一样的,我改成这样是因为方面我描述

//首先判断是否使用key的MD5值当做文件名,若是,则把filename设置成key.MD5
//然后再判断是否设置了扩展名,若设置了,则把扩展名拼接到filename上
func cacheFileName(forKey key: String) -> String {
    var filename = key
    if config.usesHashedFileName {
        filename = key.kf.md5
    } 
    if let ext = config.pathExtension {
        filename =  "\(filename).\(ext)"
    }
    return filename
}
缓存数据
    func store(
        value: T,
        forKey key: String,
        expiration: StorageExpiration? = nil) throws
    {
        //获取存活时间,若缓存时没设置,则从配置中获取
        let expiration = expiration ?? config.expiration
         //判断是否过期,若已经过期直接返回
        guard !expiration.isExpired else { return }
        // 把value转成data,这里value类型是DataTransformable,需要实现toData等其他方法
        let data: try value.toData()
        //通过cacheKeyc生成一个完整的路径
        //完整的路径等于directoryURL+filename
        let fileURL = cacheFileURL(forKey: key)
        let now = Date()
        //把当前时间设置为文件的创建时间,把过期时间设置为文件的修改时间
        let attributes: [FileAttributeKey : Any] = [
            .creationDate: now.fileAttributeDate,
            .modificationDate: expiration.estimatedExpirationSinceNow.fileAttributeDate
        ]
        //通过fileManager把data写入文件
        config.fileManager.createFile(atPath: fileURL.path, contents: data, attributes: attributes)
    }

上面代码中给文件设置创建时间和修改时间用的是给Date扩展的计算属性fileAttributeDate,fileAttributeDate返回的是Date(timeIntervalSince1970: ceil(timeIntervalSince1970)),也就是说把date的秒值向上取整后再转成date,为什么要这么做呢?作者解释说,date在内容中实际是一个double类型的值,而在file的属性中,只接受Int类型的值,会默认舍去小数部分,会导致对测试不友好,所以就改成这样了,我不是很理解为什么对测试不友好,难道是会导致提前一会结束过期吗?

加载缓存
    func value(
        forKey key: String,/
        referenceDate: Date,
        actuallyLoad: Bool,
        extendingExpiration: ExpirationExtending) throws -> T?
    {
        let fileManager = config.fileManager
        //通过cacheKeyc生成一个完整的路径
        let fileURL = cacheFileURL(forKey: key)
        let filePath = fileURL.path
        //判断是否存在该文件是否存在
        guard fileManager.fileExists(atPath: filePath) else {
            return nil
        }
        //通过fileURL生成一个FileMeta文件描述信息的类
        let resourceKeys: Set<URLResourceKey> = [.contentModificationDateKey, .creationDateKey]
        let meta = try FileMeta(fileURL: fileURL, resourceKeys: resourceKeys)
        //判断文件的过期时间是否大于referenceDate
        if meta.expired(referenceDate: referenceDate) {
            return nil
        }
        //判断是否是真的需要去加载数据,比如判断是否已缓存的时候,就不需要真的去加载,只要知道有就好了
        if !actuallyLoad { return T.empty }
        //读取文件
        let data = try Data(contentsOf: fileURL)
        let obj = try T.fromData(data)
        //更新文件的描述信息,本质也是为了h更新最后一次的读取时间和过期时间
        metaChangingQueue.async { meta.extendExpiration(with: fileManager, extendingExpiration: extendingExpiration) }
    }
判断是否已缓存

通过调用value方法,判断value的返回值是否为nil,调用时会把actuallyLoad参数传为false,这样就不会去读取文件

通过key删除缓存,以及删除所有缓存
//通过key生成URL,然后把该文件删除
func remove(forKey key: String) throws {
    let fileURL = cacheFileURL(forKey: key)
    config.fileManager.removeItem(at: url)
}
//直接把文件夹删除
func removeAll(skipCreatingDirectory: Bool) throws {
    try config.fileManager.removeItem(at: directoryURL)
    if !skipCreatingDirectory {
        try prepareDirectory()
    }
}
获取缓存大小

获取文件夹下的所有文件,并把每个文件的大小加起来

删除过期的缓存
    //删除在指定时间过期的缓存,若传入当前时间,则是删除现在已经过期的文件
    //返回值:删除的文件路径 
    func removeExpiredValues(referenceDate: Date = Date()) throws -> [URL] {
        let propertyKeys: [URLResourceKey] = [
            .isDirectoryKey,
            .contentModificationDateKey
        ]
        //获取所有的文件URL
        let urls = try allFileURLs(for: propertyKeys)
        let keys = Set(propertyKeys)
        //过滤出过期的文件URL
        let expiredFiles = urls.filter { fileURL in
            let meta = FileMeta(fileURL: fileURL, resourceKeys: keys)
            if meta.isDirectory {
                return false
            }
            return meta.expired(referenceDate: referenceDate)
        }
        //遍历所有的过期的文件UR,依次删除它们
        try expiredFiles.forEach { url in
            try removeFile(at: url)
        }
        return expiredFiles
    }
缓存大小超过sizeLimit时删除缓存
  func removeSizeExceededValues() throws -> [URL] {
        //如果sizeLimit == 0代表不限制大小,直接返回
        if config.sizeLimit == 0 { return [] } 
        var size = try totalSize()
        //如果当前的缓存大小小于sizeLimit直接返回
        if size < config.sizeLimit { return [] }
        let urls = 获取所有的URLs
        //通过urls生成所有的文件信息,这里包含的信息有是否是文件夹,创建时间,和文件大小
        var pendings: [FileMeta] = urls.compactMap { fileURL in
            guard let meta = try? FileMeta(fileURL: fileURL, resourceKeys: keys) else {
                return nil
            }
            return meta
        }
        //通过创建时间排序,也就是通过最后一次的读取时间
        pendings.sort(by: FileMeta.lastAccessDate)
        var removed: [URL] = []
        let target = config.sizeLimit / 2
        //直到当前缓存大小小于sizeLimit的2分之一,否则按照最后的读取时间一次删除
        while size > target, let meta = pendings.popLast() {
            size -= UInt(meta.fileSize)
            try removeFile(at: meta.url)
            removed.append(meta.url)
        }
        return removed
    }

补充

在ImageCache里监听了三个通知,分别是收到内存警告,应用即将被杀死,应用已经进入到后台,在这三个通知里分别做了,清空内存缓存,异步的清除磁盘过期缓存和磁盘大小超过simeLimit清除缓存,在后台清除磁盘过期缓存和磁盘大小超过simeLimit清除缓存

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

推荐阅读更多精彩内容