You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
520 lines
17 KiB
520 lines
17 KiB
// The MIT License (MIT)
|
|
//
|
|
// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean).
|
|
|
|
import Foundation
|
|
|
|
// MARK: - DataCaching
|
|
|
|
/// Data cache.
|
|
///
|
|
/// - warning: The implementation must be thread safe.
|
|
public protocol DataCaching {
|
|
/// Retrieves data from cache for the given key.
|
|
func cachedData(for key: String) -> Data?
|
|
|
|
/// Stores data for the given key.
|
|
/// - note: The implementation must return immediately and store data
|
|
/// asynchronously.
|
|
func storeData(_ data: Data, for key: String)
|
|
|
|
/// Removes data for the given key.
|
|
func removeData(for key: String)
|
|
}
|
|
|
|
// MARK: - DataCache
|
|
|
|
/// Data cache backed by a local storage.
|
|
///
|
|
/// The DataCache uses LRU cleanup policy (least recently used items are removed
|
|
/// first). The elements stored in the cache are automatically discarded if
|
|
/// either *cost* or *count* limit is reached. The sweeps are performed periodically.
|
|
///
|
|
/// DataCache always writes and removes data asynchronously. It also allows for
|
|
/// reading and writing data in parallel. This is implemented using a "staging"
|
|
/// area which stores changes until they are flushed to disk:
|
|
///
|
|
/// // Schedules data to be written asynchronously and returns immediately
|
|
/// cache[key] = data
|
|
///
|
|
/// // The data is returned from the staging area
|
|
/// let data = cache[key]
|
|
///
|
|
/// // Schedules data to be removed asynchronously and returns immediately
|
|
/// cache[key] = nil
|
|
///
|
|
/// // Data is nil
|
|
/// let data = cache[key]
|
|
///
|
|
/// Thread-safe.
|
|
///
|
|
/// - warning: It's possible to have more than one instance of `DataCache` with
|
|
/// the same `path` but it is not recommended.
|
|
public final class DataCache: DataCaching {
|
|
/// A cache key.
|
|
public typealias Key = String
|
|
|
|
/// The maximum number of items. `Int.max` by default.
|
|
///
|
|
/// Changes tos `countLimit` will take effect when the next LRU sweep is run.
|
|
var deprecatedCountLimit: Int = Int.max
|
|
|
|
/// Size limit in bytes. `100 Mb` by default.
|
|
///
|
|
/// Changes to `sizeLimit` will take effect when the next LRU sweep is run.
|
|
public var sizeLimit: Int = 1024 * 1024 * 100
|
|
|
|
/// When performing a sweep, the cache will remote entries until the size of
|
|
/// the remaining items is lower than or equal to `sizeLimit * trimRatio` and
|
|
/// the total count is lower than or equal to `countLimit * trimRatio`. `0.7`
|
|
/// by default.
|
|
var trimRatio = 0.7
|
|
|
|
/// The path for the directory managed by the cache.
|
|
public let path: URL
|
|
|
|
/// The number of seconds between each LRU sweep. 30 by default.
|
|
/// The first sweep is performed right after the cache is initialized.
|
|
///
|
|
/// Sweeps are performed in a background and can be performed in parallel
|
|
/// with reading.
|
|
public var sweepInterval: TimeInterval = 30
|
|
|
|
/// The delay after which the initial sweep is performed. 10 by default.
|
|
/// The initial sweep is performed after a delay to avoid competing with
|
|
/// other subsystems for the resources.
|
|
private var initialSweepDelay: TimeInterval = 10
|
|
|
|
// Staging
|
|
|
|
private let lock = NSLock()
|
|
private var staging = Staging()
|
|
private var isFlushNeeded = false
|
|
private var isFlushScheduled = false
|
|
var flushInterval: DispatchTimeInterval = .seconds(2)
|
|
|
|
/// A queue which is used for disk I/O.
|
|
public let queue = DispatchQueue(label: "com.github.kean.Nuke.DataCache.WriteQueue", qos: .utility)
|
|
|
|
/// A function which generates a filename for the given key. A good candidate
|
|
/// for a filename generator is a _cryptographic_ hash function like SHA1.
|
|
///
|
|
/// The reason why filename needs to be generated in the first place is
|
|
/// that filesystems have a size limit for filenames (e.g. 255 UTF-8 characters
|
|
/// in AFPS) and do not allow certain characters to be used in filenames.
|
|
public typealias FilenameGenerator = (_ key: String) -> String?
|
|
|
|
private let filenameGenerator: FilenameGenerator
|
|
|
|
/// Creates a cache instance with a given `name`. The cache creates a directory
|
|
/// with the given `name` in a `.cachesDirectory` in `.userDomainMask`.
|
|
/// - parameter filenameGenerator: Generates a filename for the given URL.
|
|
/// The default implementation generates a filename using SHA1 hash function.
|
|
public convenience init(name: String, filenameGenerator: @escaping (String) -> String? = DataCache.filename(for:)) throws {
|
|
guard let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
|
|
}
|
|
try self.init(path: root.appendingPathComponent(name, isDirectory: true), filenameGenerator: filenameGenerator)
|
|
}
|
|
|
|
/// Creates a cache instance with a given path.
|
|
/// - parameter filenameGenerator: Generates a filename for the given URL.
|
|
/// The default implementation generates a filename using SHA1 hash function.
|
|
public init(path: URL, filenameGenerator: @escaping (String) -> String? = DataCache.filename(for:)) throws {
|
|
self.path = path
|
|
self.filenameGenerator = filenameGenerator
|
|
try self.didInit()
|
|
|
|
#if TRACK_ALLOCATIONS
|
|
Allocations.increment("DataCache")
|
|
#endif
|
|
}
|
|
|
|
deinit {
|
|
#if TRACK_ALLOCATIONS
|
|
Allocations.decrement("ImageCache")
|
|
#endif
|
|
}
|
|
|
|
/// A `FilenameGenerator` implementation which uses SHA1 hash function to
|
|
/// generate a filename from the given key.
|
|
public static func filename(for key: String) -> String? {
|
|
return key.sha1
|
|
}
|
|
|
|
private func didInit() throws {
|
|
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil)
|
|
queue.asyncAfter(deadline: .now() + initialSweepDelay) { [weak self] in
|
|
self?.performAndScheduleSweep()
|
|
}
|
|
}
|
|
|
|
// MARK: DataCaching
|
|
|
|
/// Retrieves data for the given key.
|
|
public func cachedData(for key: Key) -> Data? {
|
|
lock.lock()
|
|
if let change = staging.change(for: key) {
|
|
lock.unlock()
|
|
switch change { // Change wasn't flushed to disk yet
|
|
case let .add(data):
|
|
return data
|
|
case .remove:
|
|
return nil
|
|
}
|
|
}
|
|
lock.unlock()
|
|
|
|
guard let url = url(for: key) else {
|
|
return nil
|
|
}
|
|
return try? Data(contentsOf: url)
|
|
}
|
|
|
|
/// Stores data for the given key. The method returns instantly and the data
|
|
/// is written asynchronously.
|
|
public func storeData(_ data: Data, for key: Key) {
|
|
stage { staging.add(data: data, for: key) }
|
|
}
|
|
|
|
/// Removes data for the given key. The method returns instantly, the data
|
|
/// is removed asynchronously.
|
|
public func removeData(for key: Key) {
|
|
stage { staging.removeData(for: key) }
|
|
}
|
|
|
|
/// Removes all items. The method returns instantly, the data is removed
|
|
/// asynchronously.
|
|
public func removeAll() {
|
|
stage { staging.removeAll() }
|
|
}
|
|
|
|
private func stage(_ change: () -> Void) {
|
|
lock.lock()
|
|
change()
|
|
setNeedsFlushChanges()
|
|
lock.unlock()
|
|
}
|
|
|
|
/// Accesses the data associated with the given key for reading and writing.
|
|
///
|
|
/// When you assign a new data for a key and the key already exists, the cache
|
|
/// overwrites the existing data.
|
|
///
|
|
/// When assigning or removing data, the subscript adds a requested operation
|
|
/// in a staging area and returns immediately. The staging area allows for
|
|
/// reading and writing data in parallel.
|
|
///
|
|
/// // Schedules data to be written asynchronously and returns immediately
|
|
/// cache[key] = data
|
|
///
|
|
/// // The data is returned from the staging area
|
|
/// let data = cache[key]
|
|
///
|
|
/// // Schedules data to be removed asynchronously and returns immediately
|
|
/// cache[key] = nil
|
|
///
|
|
/// // Data is nil
|
|
/// let data = cache[key]
|
|
///
|
|
public subscript(key: Key) -> Data? {
|
|
get {
|
|
cachedData(for: key)
|
|
}
|
|
set {
|
|
if let data = newValue {
|
|
storeData(data, for: key)
|
|
} else {
|
|
removeData(for: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Managing URLs
|
|
|
|
/// Uses the `FilenameGenerator` that the cache was initialized with to
|
|
/// generate and return a filename for the given key.
|
|
public func filename(for key: Key) -> String? {
|
|
filenameGenerator(key)
|
|
}
|
|
|
|
/// Returns `url` for the given cache key.
|
|
public func url(for key: Key) -> URL? {
|
|
guard let filename = self.filename(for: key) else {
|
|
return nil
|
|
}
|
|
return self.path.appendingPathComponent(filename, isDirectory: false)
|
|
}
|
|
|
|
// MARK: Flush Changes
|
|
|
|
/// Synchronously waits on the caller's thread until all outstanding disk I/O
|
|
/// operations are finished.
|
|
public func flush() {
|
|
queue.sync(execute: flushChangesIfNeeded)
|
|
}
|
|
|
|
/// Synchronously waits on the caller's thread until all outstanding disk I/O
|
|
/// operations for the given key are finished.
|
|
public func flush(for key: Key) {
|
|
queue.sync {
|
|
guard let change = lock.sync({ staging.changes[key] }) else { return }
|
|
perform(change)
|
|
lock.sync { staging.flushed(change) }
|
|
}
|
|
}
|
|
|
|
private func setNeedsFlushChanges() {
|
|
guard !isFlushNeeded else { return }
|
|
isFlushNeeded = true
|
|
scheduleNextFlush()
|
|
}
|
|
|
|
private func scheduleNextFlush() {
|
|
guard !isFlushScheduled else { return }
|
|
isFlushScheduled = true
|
|
queue.asyncAfter(deadline: .now() + flushInterval, execute: flushChangesIfNeeded)
|
|
}
|
|
|
|
private func flushChangesIfNeeded() {
|
|
// Create a snapshot of the recently made changes
|
|
let staging: Staging
|
|
lock.lock()
|
|
guard isFlushNeeded else {
|
|
return lock.unlock()
|
|
}
|
|
staging = self.staging
|
|
isFlushNeeded = false
|
|
lock.unlock()
|
|
|
|
// Apply the snapshot to disk
|
|
performChanges(for: staging)
|
|
|
|
// Update the staging area and schedule the next flush if needed
|
|
lock.lock()
|
|
self.staging.flushed(staging)
|
|
isFlushScheduled = false
|
|
if isFlushNeeded {
|
|
scheduleNextFlush()
|
|
}
|
|
lock.unlock()
|
|
}
|
|
|
|
// MARK: - I/O
|
|
|
|
private func performChanges(for staging: Staging) {
|
|
autoreleasepool {
|
|
if let change = staging.changeRemoveAll {
|
|
perform(change)
|
|
}
|
|
for change in staging.changes.values {
|
|
perform(change)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func perform(_ change: Staging.ChangeRemoveAll) {
|
|
try? FileManager.default.removeItem(at: self.path)
|
|
try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil)
|
|
}
|
|
|
|
/// Performs the IO for the given change.
|
|
private func perform(_ change: Staging.Change) {
|
|
guard let url = url(for: change.key) else {
|
|
return
|
|
}
|
|
switch change.type {
|
|
case let .add(data):
|
|
do {
|
|
try data.write(to: url)
|
|
} catch let error as NSError {
|
|
guard error.code == CocoaError.fileNoSuchFile.rawValue && error.domain == CocoaError.errorDomain else { return }
|
|
try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil)
|
|
try? data.write(to: url) // re-create a directory and try again
|
|
}
|
|
case .remove:
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
|
|
// MARK: Sweep
|
|
|
|
private func performAndScheduleSweep() {
|
|
performSweep()
|
|
queue.asyncAfter(deadline: .now() + sweepInterval) { [weak self] in
|
|
self?.performAndScheduleSweep()
|
|
}
|
|
}
|
|
|
|
/// Synchronously performs a cache sweep and removes the least recently items
|
|
/// which no longer fit in cache.
|
|
public func sweep() {
|
|
queue.sync(execute: performSweep)
|
|
}
|
|
|
|
/// Discards the least recently used items first.
|
|
private func performSweep() {
|
|
var items = contents(keys: [.contentAccessDateKey, .totalFileAllocatedSizeKey])
|
|
guard !items.isEmpty else {
|
|
return
|
|
}
|
|
var size = items.reduce(0) { $0 + ($1.meta.totalFileAllocatedSize ?? 0) }
|
|
var count = items.count
|
|
|
|
guard size > sizeLimit || count > deprecatedCountLimit else {
|
|
return // All good, no need to perform any work.
|
|
}
|
|
|
|
let sizeLimit = Int(Double(self.sizeLimit) * trimRatio)
|
|
let countLimit = Int(Double(self.deprecatedCountLimit) * trimRatio)
|
|
|
|
// Most recently accessed items first
|
|
let past = Date.distantPast
|
|
items.sort { // Sort in place
|
|
($0.meta.contentAccessDate ?? past) > ($1.meta.contentAccessDate ?? past)
|
|
}
|
|
|
|
// Remove the items until it satisfies both size and count limits.
|
|
while (size > sizeLimit || count > countLimit), let item = items.popLast() {
|
|
size -= (item.meta.totalFileAllocatedSize ?? 0)
|
|
count -= 1
|
|
try? FileManager.default.removeItem(at: item.url)
|
|
}
|
|
}
|
|
|
|
// MARK: Contents
|
|
|
|
struct Entry {
|
|
let url: URL
|
|
let meta: URLResourceValues
|
|
}
|
|
|
|
func contents(keys: [URLResourceKey] = []) -> [Entry] {
|
|
guard let urls = try? FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: keys, options: .skipsHiddenFiles) else {
|
|
return []
|
|
}
|
|
let keys = Set(keys)
|
|
return urls.compactMap {
|
|
guard let meta = try? $0.resourceValues(forKeys: keys) else {
|
|
return nil
|
|
}
|
|
return Entry(url: $0, meta: meta)
|
|
}
|
|
}
|
|
|
|
// MARK: Inspection
|
|
|
|
/// The total number of items in the cache.
|
|
/// - warning: Requires disk IO, avoid using from the main thread.
|
|
public var totalCount: Int {
|
|
contents().count
|
|
}
|
|
|
|
/// The total file size of items written on disk.
|
|
///
|
|
/// Uses `URLResourceKey.fileSizeKey` to calculate the size of each entry.
|
|
/// The total allocated size (see `totalAllocatedSize`. on disk might
|
|
/// actually be bigger.
|
|
///
|
|
/// - warning: Requires disk IO, avoid using from the main thread.
|
|
public var totalSize: Int {
|
|
contents(keys: [.fileSizeKey]).reduce(0) {
|
|
$0 + ($1.meta.fileSize ?? 0)
|
|
}
|
|
}
|
|
|
|
/// The total file allocated size of all the items written on disk.
|
|
///
|
|
/// Uses `URLResourceKey.totalFileAllocatedSizeKey`.
|
|
///
|
|
/// - warning: Requires disk IO, avoid using from the main thread.
|
|
public var totalAllocatedSize: Int {
|
|
contents(keys: [.totalFileAllocatedSizeKey]).reduce(0) {
|
|
$0 + ($1.meta.totalFileAllocatedSize ?? 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Staging
|
|
|
|
/// DataCache allows for parallel reads and writes. This is made possible by
|
|
/// DataCacheStaging.
|
|
///
|
|
/// For example, when the data is added in cache, it is first added to staging
|
|
/// and is removed from staging only after data is written to disk. Removal works
|
|
/// the same way.
|
|
private struct Staging {
|
|
private(set) var changes = [String: Change]()
|
|
private(set) var changeRemoveAll: ChangeRemoveAll?
|
|
|
|
struct ChangeRemoveAll {
|
|
let id: Int
|
|
}
|
|
|
|
struct Change {
|
|
let key: String
|
|
let id: Int
|
|
let type: ChangeType
|
|
}
|
|
|
|
enum ChangeType {
|
|
case add(Data)
|
|
case remove
|
|
}
|
|
|
|
private var nextChangeId = 0
|
|
|
|
// MARK: Changes
|
|
|
|
func change(for key: String) -> ChangeType? {
|
|
if let change = changes[key] {
|
|
return change.type
|
|
}
|
|
if changeRemoveAll != nil {
|
|
return .remove
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: Register Changes
|
|
|
|
mutating func add(data: Data, for key: String) {
|
|
nextChangeId += 1
|
|
changes[key] = Change(key: key, id: nextChangeId, type: .add(data))
|
|
}
|
|
|
|
mutating func removeData(for key: String) {
|
|
nextChangeId += 1
|
|
changes[key] = Change(key: key, id: nextChangeId, type: .remove)
|
|
}
|
|
|
|
mutating func removeAll() {
|
|
nextChangeId += 1
|
|
changeRemoveAll = ChangeRemoveAll(id: nextChangeId)
|
|
changes.removeAll()
|
|
}
|
|
|
|
// MARK: Flush Changes
|
|
|
|
mutating func flushed(_ staging: Staging) {
|
|
for change in staging.changes.values {
|
|
flushed(change)
|
|
}
|
|
if let change = staging.changeRemoveAll {
|
|
flushed(change)
|
|
}
|
|
}
|
|
|
|
mutating func flushed(_ change: Change) {
|
|
if let index = changes.index(forKey: change.key),
|
|
changes[index].value.id == change.id {
|
|
changes.remove(at: index)
|
|
}
|
|
}
|
|
|
|
mutating func flushed(_ change: ChangeRemoveAll) {
|
|
if changeRemoveAll?.id == change.id {
|
|
changeRemoveAll = nil
|
|
}
|
|
}
|
|
}
|