Kingfisher源码解析系列,由于水平有限,哪里有错,肯请不吝赐教
- Kingfisher源码解析之使用
- Kingfisher源码解析之Options解释
- Kingfisher源码解析之加载流程
- Kingfisher源码解析之加载动图
- Kingfisher源码解析之ImageCache
- Kingfisher源码解析之Processor和CacheSerializer
- Kingfisher源码解析之ImagePrefetcher
Kingfisher中ImageCache里提供内存缓存和磁盘缓存,分别是MemoryStorage.Backend<KFCrossPlatformImage>
和DiskStorage.Backend<Data>
来实现的,注:内存缓存和磁盘缓存都是通过class Backend
,不过这2个类,是完全不同的类,使用枚举来充当命名空间来区分的,分别定义在MemoryStorage.swift
和DiskStorage.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的countLimit
和totalCostLimit
来实现最大缓存个数和最大缓存大小。
通过下面的代码,看下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清除缓存