Files
SideStore/Pods/Nuke/Sources/DataCache.swift

442 lines
15 KiB
Swift

// The MIT License (MIT)
//
// Copyright (c) 2015-2019 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)
}
// 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. `1000` by default.
///
/// Changes tos `countLimit` will take effect when the next LRU sweep is run.
public var countLimit: Int = 1000
/// 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.
internal 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 = 15
// Staging
private let _lock = NSLock()
private var _staging = Staging()
/* testable */ let _wqueue = DispatchQueue(label: "com.github.kean.Nuke.DataCache.WriteQueue")
/// 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()
}
/// 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)
_wqueue.asyncAfter(deadline: .now() + initialSweepDelay) { [weak self] in
self?._performAndScheduleSweep()
}
}
// MARK: DataCaching
/// Retrieves data for the given key. The completion will be called
/// syncrhonously if there is no cached 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 {
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) {
_lock.sync {
let change = _staging.add(data: data, for: key)
_wqueue.async {
if let url = self._url(for: key) {
try? data.write(to: url)
}
self._lock.sync {
self._staging.flushed(change)
}
}
}
}
/// Removes data for the given key. The method returns instantly, the data
/// is removed asynchronously.
public func removeData(for key: Key) {
_lock.sync {
let change = _staging.removeData(for: key)
_wqueue.async {
if let url = self._url(for: key) {
try? FileManager.default.removeItem(at: url)
}
self._lock.sync {
self._staging.flushed(change)
}
}
}
}
/// Removes all items. The method returns instantly, the data is removed
/// asynchronously.
public func removeAll() {
_lock.sync {
let change = _staging.removeAll()
_wqueue.async {
try? FileManager.default.removeItem(at: self.path)
try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil)
self._lock.sync {
self._staging.flushed(change)
}
}
}
}
/// 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 {
return 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? {
return _filenameGenerator(key)
}
/* testable */ 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 IO
/// operations are finished.
func flush() {
_wqueue.sync {}
}
// MARK: Sweep
private func _performAndScheduleSweep() {
_sweep()
_wqueue.asyncAfter(deadline: .now() + sweepInterval) { [weak self] in
self?._performAndScheduleSweep()
}
}
/// Schedules a cache sweep to be performed immediately.
public func sweep() {
_wqueue.async {
self._sweep()
}
}
/// Discards the least recently used items first.
private func _sweep() {
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
let sizeLimit = self.sizeLimit / Int(1 / trimRatio)
let countLimit = self.countLimit / Int(1 / trimRatio)
guard size > sizeLimit || count > countLimit else {
return // All good, no need to perform any work.
}
// 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 we satisfy 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 {
return 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 {
return 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 {
return 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 final class Staging {
private var changes = [String: Change]()
private 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
func add(data: Data, for key: String) -> Change {
return _makeChange(.add(data), for: key)
}
func removeData(for key: String) -> Change {
return _makeChange(.remove, for: key)
}
private func _makeChange(_ type: ChangeType, for key: String) -> Change {
nextChangeId += 1
let change = Change(key: key, id: nextChangeId, type: type)
changes[key] = change
return change
}
func removeAll() -> ChangeRemoveAll {
nextChangeId += 1
let change = ChangeRemoveAll(id: nextChangeId)
changeRemoveAll = change
changes.removeAll()
return change
}
// MARK: Flush Changes
func flushed(_ change: Change) {
if let index = changes.index(forKey: change.key),
changes[index].value.id == change.id {
changes.remove(at: index)
}
}
func flushed(_ change: ChangeRemoveAll) {
if changeRemoveAll?.id == change.id {
changeRemoveAll = nil
}
}
}
}