Kingfisher 源码解析系列,由于水平有限,哪里有错,肯请不吝赐教
Kingfisher 中 ImageCache 里提供内存缓存和磁盘缓存,分别是MemoryStorage.Backend<KFCrossPlatformImage>和DiskStorage.Backend<Data>来实现的,注:内存缓存和磁盘缓存都是通过class Backend,不过这 2 个类,是完全不同的类,使用枚举来充当命名空间来区分的,分别定义在MemoryStorage.swift和DiskStorage.swift中
内存缓存
内存缓存一共有三个类构成,Backend提供缓存的功能,Config提供缓存的配置项,StorageObject<T>缓存的封装类型
Config的主要内容
1 | public struct Config { |
2 | //内存缓存的最大容量,ImageCache.default中提供的默认值是设备物理内存的四分之一 |
3 | public var totalCostLimit: Int |
4 | //内存缓存的最大长度 |
5 | public var countLimit: Int = .max |
6 | //内存缓存的的过期时长 |
7 | public var expiration: StorageExpiration = .seconds(300) |
8 | //清除过期缓存的时间间隔 |
9 | public let cleanInterval: TimeInterval |
10 | } |
StorageObject<T>的主要内容
1 | class StorageObject<T> { |
2 | //缓存的真正的值 |
3 | let value: T |
4 | //存活时间,也就是多久之后过期 |
5 | let expiration: StorageExpiration |
6 | //缓存e的key |
7 | let key: String |
8 | //过期时间,默认值是当前时间加上expiration |
9 | private(set) var estimatedExpiration: Date |
10 | // 更新过期时间 |
11 | func extendExpiration(_ extendingExpiration: ExpirationExtending = .cacheTime) { |
12 | switch extendingExpiration { |
13 | case .none://不更新过期时间 |
14 | return |
15 | case .cacheTime://把过期时间设置为当前时间加上存活时间 |
16 | self.estimatedExpiration = expiration.estimatedExpirationSinceNow |
17 | case .expirationTime(let expirationTime)://把过期时间设置为指定时间 |
18 | self.estimatedExpiration = expirationTime.estimatedExpirationSinceNow |
19 | } |
20 | } |
21 | // 是否已经过期 |
22 | var expired: Bool { |
23 | //estimatedExpiration.isPast 是对Date的一个扩展方法,判断estimatedExpiration是否小于当前时间 |
24 | return estimatedExpiration.isPast |
25 | } |
26 | } |
Backend的主要内容
1 | public class Backend<T: CacheCostCalculable> { |
2 | //使用NSCache进行缓存 |
3 | let storage = NSCache<NSString, StorageObject<T>>() |
4 | //存放所有缓存的key,在删除过期缓存是有用 |
5 | var keys = Set<String>() |
6 | //定时器,用于定时清除过期数据 |
7 | private var cleanTimer: Timer? = nil |
8 | //配置项 |
9 | public var config: Config |
10 | ...下面还有一些缓存数据,读取数据,删除缓存,是否已缓存,删除过期数据等方法 |
11 | } |
由上面我们可以看出,Kingfisher 中内存缓存是用 NSCache 实现的,NSCache 是一个类似于 Dictionary 的类,拥有相似的 API,不过区别于 Dictionary 的是,NSCache 是线程安全的,并且提供了设置最大缓存个数和最大缓存大小的配置,Backend 就是通过设置 NSCache 的countLimit和totalCostLimit来实现最大缓存个数和最大缓存大小。
通过下面的代码,看下 Backend 是如何缓存数据,读取数据,判断是否已缓存,删除缓存,删除过期数据的?代码中有详细的注释,注:下面的代码删除了一些非核心代码,比如异常,加锁保证线程安全等
缓存数据
1 | func store(value: T,forKey key: String,expiration: StorageExpiration? = nil) { |
2 | //获取存活时间,若缓存时没设置,则从配置中获取 |
3 | let expiration = expiration ?? config.expiration |
4 | //判断是否过期,若已经过期直接返回 |
5 | guard !expiration.isExpired else { return } |
6 | //把要缓存的值封装成StorageObject类型 |
7 | let object = StorageObject(value, key: key, expiration: expiration) |
8 | //把结果缓存起来 |
9 | storage.setObject(object, forKey: key as NSString, cost: value.cacheCost) |
10 | //把key保存起来 |
11 | keys.insert(key) |
12 | } |
读取数据,判断数据是否已缓存
1 | // 读取数据 |
2 | func value(forKey key: String, extendingExpiration: ExpirationExtending = .cacheTime) -> T? { |
3 | //从NSCache中获取数据,如获取不到直接返回nil |
4 | guard let object = storage.object(forKey: key as NSString) else { return nil } |
5 | //判断是否过期,若过期直接返回nil |
6 | if object.expired { return nil } |
7 | //去更新过期时间 |
8 | object.extendExpiration(extendingExpiration) |
9 | return object.value |
10 | } |
11 | // 判断是否缓存,其本质就是去读取数据,只是不更新缓存时间,若取到,则已缓存,否则未缓存 |
12 | func isCached(forKey key: String) -> Bool { |
13 | guard let _ = value(forKey: key, extendingExpiration: .none) else { |
14 | return false |
15 | } |
16 | return true |
17 | } |
删除缓存
1 | func remove(forKey key: String) throws { |
2 | storage.removeObject(forKey: key as NSString) |
3 | keys.remove(key) |
4 | } |
删除过期数据,这里使用 Set 存储 key 的原因是 NSCache,并没有像 Dictionary 一样提供获取 allKeys 或 allValues 的方法
1 | func removeExpired() { |
2 | for key in keys { |
3 | let nsKey = key as NSString |
4 | //通过key获取数据,若获取失败,则删除从keys中删除key |
5 | guard let object = storage.object(forKey: nsKey) else { |
6 | keys.remove(key) |
7 | continue |
8 | } |
9 | //判断object是否过期,若过期,则从cache中删除数据,从keys中删除key |
10 | if object.estimatedExpiration.isPast { |
11 | storage.removeObject(forKey: nsKey) |
12 | keys.remove(key) |
13 | } |
14 | } |
15 | } |
磁盘缓存
Kingfisher 中磁盘缓存是通过文件系统来实现的,也就是说每个缓存的数据都对应一个文件,其中 Kingfisher 把文件的创建时间修改为最后一次读取的时间,把文件的修改时间修改为过期时间。
磁盘缓存一共有三个类构成,Backend提供缓存的功能,Config提供缓存的配置项,FileMeta存储着文件信息。
Config的主要内容
1 | public struct Config { |
2 | //磁盘缓存占用磁盘的最大值,为0z时,表示不限制 |
3 | public var sizeLimit: UInt |
4 | //存活时间 |
5 | public var expiration: StorageExpiration = .days(7) |
6 | //文件的扩展名 |
7 | public var pathExtension: String? = nil |
8 | //是否需要把文件名哈希 |
9 | public var usesHashedFileName = true |
10 | //操作文件的FileManager |
11 | let fileManager: FileManager |
12 | //文件缓存所在的文件夹,默认在cache文件夹里 |
13 | let directory: URL? |
14 | } |
1 | struct FileMeta { |
2 | //文件路径 |
3 | let url: URL |
4 | //文件最后一次读取时间 |
5 | //这个在超过sizeLimit大小时,需要删除文件时,用此属性进行排序,把时间较早的删除掉 |
6 | let lastAccessDate: Date? |
7 | //过期时间 |
8 | let estimatedExpirationDate: Date? |
9 | //是否是个文件夹 |
10 | let isDirectory: Bool |
11 | //文件大小 |
12 | let fileSize: Int |
13 | } |
Backend的主要内容
1 | public class Backend<T: DataTransformable> { |
2 | //配置信息 |
3 | public var config: Config |
4 | //写入文件所在的文件夹,默认在cache文件夹里 |
5 | public let directoryURL: URL |
6 | //修改文件原信息时,所在的队列 |
7 | let metaChangingQueue: DispatchQueue |
8 | //该方法会在init着调用,保证directoryURLs文件夹,已经被创建过了 |
9 | func prepareDirectory() throws { |
10 | let fileManager = config.fileManager |
11 | let path = directoryURL.path |
12 | guard !fileManager.fileExists(atPath: path) else { return } |
13 | try fileManager.createDirectory(atPath: path,withIntermediateDirectories: true,attributes: nil) |
14 | } |
15 | ...下面还有缓存数据,读取数据,判断是否已缓存,删除缓存,删除过期缓存,删除超过sizeLimit的缓存,统计缓存大小等 |
16 | } |
通过下面的代码看Backend是如何缓存数据,读取数据,判断是否已缓存,删除缓存,删除过期缓存,删除超过 sizeLimit 的缓存,统计缓存大小以及如何通过 key 生成文件名的?代码中有详细的注释。注:下面的代码删除了一些非核心代码,比如异常,加锁保证线程安全等
通过 key 生成文件名
下面那段代码和源码中不太一样,但逻辑是一样的,我改成这样是因为方面我描述
1 | //首先判断是否使用key的MD5值当做文件名,若是,则把filename设置成key.MD5 |
2 | //然后再判断是否设置了扩展名,若设置了,则把扩展名拼接到filename上 |
3 | func cacheFileName(forKey key: String) -> String { |
4 | var filename = key |
5 | if config.usesHashedFileName { |
6 | filename = key.kf.md5 |
7 | } |
8 | if let ext = config.pathExtension { |
9 | filename = "\(filename).\(ext)" |
10 | } |
11 | return filename |
12 | } |
缓存数据
1 | func store( |
2 | value: T, |
3 | forKey key: String, |
4 | expiration: StorageExpiration? = nil) throws |
5 | { |
6 | //获取存活时间,若缓存时没设置,则从配置中获取 |
7 | let expiration = expiration ?? config.expiration |
8 | //判断是否过期,若已经过期直接返回 |
9 | guard !expiration.isExpired else { return } |
10 | // 把value转成data,这里value类型是DataTransformable,需要实现toData等其他方法 |
11 | let data: try value.toData() |
12 | //通过cacheKeyc生成一个完整的路径 |
13 | //完整的路径等于directoryURL+filename |
14 | let fileURL = cacheFileURL(forKey: key) |
15 | let now = Date() |
16 | //把当前时间设置为文件的创建时间,把过期时间设置为文件的修改时间 |
17 | let attributes: [FileAttributeKey : Any] = [ |
18 | .creationDate: now.fileAttributeDate, |
19 | .modificationDate: expiration.estimatedExpirationSinceNow.fileAttributeDate |
20 | ] |
21 | //通过fileManager把data写入文件 |
22 | config.fileManager.createFile(atPath: fileURL.path, contents: data, attributes: attributes) |
23 | } |
上面代码中给文件设置创建时间和修改时间用的是给 Date 扩展的计算属性 fileAttributeDate,fileAttributeDate 返回的是 Date(timeIntervalSince1970: ceil(timeIntervalSince1970)),也就是说把 date 的秒值向上取整后再转成 date,为什么要这么做呢?作者解释说,date 在内容中实际是一个 double 类型的值,而在 file 的属性中,只接受 Int 类型的值,会默认舍去小数部分,会导致对测试不友好,所以就改成这样了,我不是很理解为什么对测试不友好,难道是会导致提前一会结束过期吗?
加载缓存
1 | func value( |
2 | forKey key: String,/ |
3 | referenceDate: Date, |
4 | actuallyLoad: Bool, |
5 | extendingExpiration: ExpirationExtending) throws -> T? |
6 | { |
7 | let fileManager = config.fileManager |
8 | //通过cacheKeyc生成一个完整的路径 |
9 | let fileURL = cacheFileURL(forKey: key) |
10 | let filePath = fileURL.path |
11 | //判断是否存在该文件是否存在 |
12 | guard fileManager.fileExists(atPath: filePath) else { |
13 | return nil |
14 | } |
15 | //通过fileURL生成一个FileMeta文件描述信息的类 |
16 | let resourceKeys: Set<URLResourceKey> = [.contentModificationDateKey, .creationDateKey] |
17 | let meta = try FileMeta(fileURL: fileURL, resourceKeys: resourceKeys) |
18 | //判断文件的过期时间是否大于referenceDate |
19 | if meta.expired(referenceDate: referenceDate) { |
20 | return nil |
21 | } |
22 | //判断是否是真的需要去加载数据,比如判断是否已缓存的时候,就不需要真的去加载,只要知道有就好了 |
23 | if !actuallyLoad { return T.empty } |
24 | //读取文件 |
25 | let data = try Data(contentsOf: fileURL) |
26 | let obj = try T.fromData(data) |
27 | //更新文件的描述信息,本质也是为了h更新最后一次的读取时间和过期时间 |
28 | metaChangingQueue.async { meta.extendExpiration(with: fileManager, extendingExpiration: extendingExpiration) } |
29 | } |
判断是否已缓存
通过调用 value 方法,判断 value 的返回值是否为 nil,调用时会把 actuallyLoad 参数传为 false,这样就不会去读取文件
通过 key 删除缓存,以及删除所有缓存
1 | //通过key生成URL,然后把该文件删除 |
2 | func remove(forKey key: String) throws { |
3 | let fileURL = cacheFileURL(forKey: key) |
4 | config.fileManager.removeItem(at: url) |
5 | } |
6 | //直接把文件夹删除 |
7 | func removeAll(skipCreatingDirectory: Bool) throws { |
8 | try config.fileManager.removeItem(at: directoryURL) |
9 | if !skipCreatingDirectory { |
10 | try prepareDirectory() |
11 | } |
12 | } |
获取缓存大小
获取文件夹下的所有文件,并把每个文件的大小加起来
删除过期的缓存
1 | //删除在指定时间过期的缓存,若传入当前时间,则是删除现在已经过期的文件 |
2 | //返回值:删除的文件路径 |
3 | func removeExpiredValues(referenceDate: Date = Date()) throws -> [URL] { |
4 | let propertyKeys: [URLResourceKey] = [ |
5 | .isDirectoryKey, |
6 | .contentModificationDateKey |
7 | ] |
8 | //获取所有的文件URL |
9 | let urls = try allFileURLs(for: propertyKeys) |
10 | let keys = Set(propertyKeys) |
11 | //过滤出过期的文件URL |
12 | let expiredFiles = urls.filter { fileURL in |
13 | let meta = FileMeta(fileURL: fileURL, resourceKeys: keys) |
14 | if meta.isDirectory { |
15 | return false |
16 | } |
17 | return meta.expired(referenceDate: referenceDate) |
18 | } |
19 | //遍历所有的过期的文件UR,依次删除它们 |
20 | try expiredFiles.forEach { url in |
21 | try removeFile(at: url) |
22 | } |
23 | return expiredFiles |
24 | } |
缓存大小超过 sizeLimit 时删除缓存
1 | func removeSizeExceededValues() throws -> [URL] { |
2 | //如果sizeLimit == 0代表不限制大小,直接返回 |
3 | if config.sizeLimit == 0 { return [] } |
4 | var size = try totalSize() |
5 | //如果当前的缓存大小小于sizeLimit直接返回 |
6 | if size < config.sizeLimit { return [] } |
7 | let urls = 获取所有的URLs |
8 | //通过urls生成所有的文件信息,这里包含的信息有是否是文件夹,创建时间,和文件大小 |
9 | var pendings: [FileMeta] = urls.compactMap { fileURL in |
10 | guard let meta = try? FileMeta(fileURL: fileURL, resourceKeys: keys) else { |
11 | return nil |
12 | } |
13 | return meta |
14 | } |
15 | //通过创建时间排序,也就是通过最后一次的读取时间 |
16 | pendings.sort(by: FileMeta.lastAccessDate) |
17 | var removed: [URL] = [] |
18 | let target = config.sizeLimit / 2 |
19 | //直到当前缓存大小小于sizeLimit的2分之一,否则按照最后的读取时间一次删除 |
20 | while size > target, let meta = pendings.popLast() { |
21 | size -= UInt(meta.fileSize) |
22 | try removeFile(at: meta.url) |
23 | removed.append(meta.url) |
24 | } |
25 | return removed |
26 | } |
补充
在 ImageCache 里监听了三个通知,分别是收到内存警告,应用即将被杀死,应用已经进入到后台,在这三个通知里分别做了,清空内存缓存,异步的清除磁盘过期缓存和磁盘大小超过 simeLimit 清除缓存,在后台清除磁盘过期缓存和磁盘大小超过 simeLimit 清除缓存