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

2 years ago
  1. // The MIT License (MIT)
  2. //
  3. // Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean).
  4. import Foundation
  5. // MARK: - DataCaching
  6. /// Data cache.
  7. ///
  8. /// - warning: The implementation must be thread safe.
  9. public protocol DataCaching {
  10. /// Retrieves data from cache for the given key.
  11. func cachedData(for key: String) -> Data?
  12. /// Stores data for the given key.
  13. /// - note: The implementation must return immediately and store data
  14. /// asynchronously.
  15. func storeData(_ data: Data, for key: String)
  16. /// Removes data for the given key.
  17. func removeData(for key: String)
  18. }
  19. // MARK: - DataCache
  20. /// Data cache backed by a local storage.
  21. ///
  22. /// The DataCache uses LRU cleanup policy (least recently used items are removed
  23. /// first). The elements stored in the cache are automatically discarded if
  24. /// either *cost* or *count* limit is reached. The sweeps are performed periodically.
  25. ///
  26. /// DataCache always writes and removes data asynchronously. It also allows for
  27. /// reading and writing data in parallel. This is implemented using a "staging"
  28. /// area which stores changes until they are flushed to disk:
  29. ///
  30. /// // Schedules data to be written asynchronously and returns immediately
  31. /// cache[key] = data
  32. ///
  33. /// // The data is returned from the staging area
  34. /// let data = cache[key]
  35. ///
  36. /// // Schedules data to be removed asynchronously and returns immediately
  37. /// cache[key] = nil
  38. ///
  39. /// // Data is nil
  40. /// let data = cache[key]
  41. ///
  42. /// Thread-safe.
  43. ///
  44. /// - warning: It's possible to have more than one instance of `DataCache` with
  45. /// the same `path` but it is not recommended.
  46. public final class DataCache: DataCaching {
  47. /// A cache key.
  48. public typealias Key = String
  49. /// The maximum number of items. `Int.max` by default.
  50. ///
  51. /// Changes tos `countLimit` will take effect when the next LRU sweep is run.
  52. var deprecatedCountLimit: Int = Int.max
  53. /// Size limit in bytes. `100 Mb` by default.
  54. ///
  55. /// Changes to `sizeLimit` will take effect when the next LRU sweep is run.
  56. public var sizeLimit: Int = 1024 * 1024 * 100
  57. /// When performing a sweep, the cache will remote entries until the size of
  58. /// the remaining items is lower than or equal to `sizeLimit * trimRatio` and
  59. /// the total count is lower than or equal to `countLimit * trimRatio`. `0.7`
  60. /// by default.
  61. var trimRatio = 0.7
  62. /// The path for the directory managed by the cache.
  63. public let path: URL
  64. /// The number of seconds between each LRU sweep. 30 by default.
  65. /// The first sweep is performed right after the cache is initialized.
  66. ///
  67. /// Sweeps are performed in a background and can be performed in parallel
  68. /// with reading.
  69. public var sweepInterval: TimeInterval = 30
  70. /// The delay after which the initial sweep is performed. 10 by default.
  71. /// The initial sweep is performed after a delay to avoid competing with
  72. /// other subsystems for the resources.
  73. private var initialSweepDelay: TimeInterval = 10
  74. // Staging
  75. private let lock = NSLock()
  76. private var staging = Staging()
  77. private var isFlushNeeded = false
  78. private var isFlushScheduled = false
  79. var flushInterval: DispatchTimeInterval = .seconds(2)
  80. /// A queue which is used for disk I/O.
  81. public let queue = DispatchQueue(label: "com.github.kean.Nuke.DataCache.WriteQueue", qos: .utility)
  82. /// A function which generates a filename for the given key. A good candidate
  83. /// for a filename generator is a _cryptographic_ hash function like SHA1.
  84. ///
  85. /// The reason why filename needs to be generated in the first place is
  86. /// that filesystems have a size limit for filenames (e.g. 255 UTF-8 characters
  87. /// in AFPS) and do not allow certain characters to be used in filenames.
  88. public typealias FilenameGenerator = (_ key: String) -> String?
  89. private let filenameGenerator: FilenameGenerator
  90. /// Creates a cache instance with a given `name`. The cache creates a directory
  91. /// with the given `name` in a `.cachesDirectory` in `.userDomainMask`.
  92. /// - parameter filenameGenerator: Generates a filename for the given URL.
  93. /// The default implementation generates a filename using SHA1 hash function.
  94. public convenience init(name: String, filenameGenerator: @escaping (String) -> String? = DataCache.filename(for:)) throws {
  95. guard let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
  96. throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
  97. }
  98. try self.init(path: root.appendingPathComponent(name, isDirectory: true), filenameGenerator: filenameGenerator)
  99. }
  100. /// Creates a cache instance with a given path.
  101. /// - parameter filenameGenerator: Generates a filename for the given URL.
  102. /// The default implementation generates a filename using SHA1 hash function.
  103. public init(path: URL, filenameGenerator: @escaping (String) -> String? = DataCache.filename(for:)) throws {
  104. self.path = path
  105. self.filenameGenerator = filenameGenerator
  106. try self.didInit()
  107. #if TRACK_ALLOCATIONS
  108. Allocations.increment("DataCache")
  109. #endif
  110. }
  111. deinit {
  112. #if TRACK_ALLOCATIONS
  113. Allocations.decrement("ImageCache")
  114. #endif
  115. }
  116. /// A `FilenameGenerator` implementation which uses SHA1 hash function to
  117. /// generate a filename from the given key.
  118. public static func filename(for key: String) -> String? {
  119. return key.sha1
  120. }
  121. private func didInit() throws {
  122. try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil)
  123. queue.asyncAfter(deadline: .now() + initialSweepDelay) { [weak self] in
  124. self?.performAndScheduleSweep()
  125. }
  126. }
  127. // MARK: DataCaching
  128. /// Retrieves data for the given key.
  129. public func cachedData(for key: Key) -> Data? {
  130. lock.lock()
  131. if let change = staging.change(for: key) {
  132. lock.unlock()
  133. switch change { // Change wasn't flushed to disk yet
  134. case let .add(data):
  135. return data
  136. case .remove:
  137. return nil
  138. }
  139. }
  140. lock.unlock()
  141. guard let url = url(for: key) else {
  142. return nil
  143. }
  144. return try? Data(contentsOf: url)
  145. }
  146. /// Stores data for the given key. The method returns instantly and the data
  147. /// is written asynchronously.
  148. public func storeData(_ data: Data, for key: Key) {
  149. stage { staging.add(data: data, for: key) }
  150. }
  151. /// Removes data for the given key. The method returns instantly, the data
  152. /// is removed asynchronously.
  153. public func removeData(for key: Key) {
  154. stage { staging.removeData(for: key) }
  155. }
  156. /// Removes all items. The method returns instantly, the data is removed
  157. /// asynchronously.
  158. public func removeAll() {
  159. stage { staging.removeAll() }
  160. }
  161. private func stage(_ change: () -> Void) {
  162. lock.lock()
  163. change()
  164. setNeedsFlushChanges()
  165. lock.unlock()
  166. }
  167. /// Accesses the data associated with the given key for reading and writing.
  168. ///
  169. /// When you assign a new data for a key and the key already exists, the cache
  170. /// overwrites the existing data.
  171. ///
  172. /// When assigning or removing data, the subscript adds a requested operation
  173. /// in a staging area and returns immediately. The staging area allows for
  174. /// reading and writing data in parallel.
  175. ///
  176. /// // Schedules data to be written asynchronously and returns immediately
  177. /// cache[key] = data
  178. ///
  179. /// // The data is returned from the staging area
  180. /// let data = cache[key]
  181. ///
  182. /// // Schedules data to be removed asynchronously and returns immediately
  183. /// cache[key] = nil
  184. ///
  185. /// // Data is nil
  186. /// let data = cache[key]
  187. ///
  188. public subscript(key: Key) -> Data? {
  189. get {
  190. cachedData(for: key)
  191. }
  192. set {
  193. if let data = newValue {
  194. storeData(data, for: key)
  195. } else {
  196. removeData(for: key)
  197. }
  198. }
  199. }
  200. // MARK: Managing URLs
  201. /// Uses the `FilenameGenerator` that the cache was initialized with to
  202. /// generate and return a filename for the given key.
  203. public func filename(for key: Key) -> String? {
  204. filenameGenerator(key)
  205. }
  206. /// Returns `url` for the given cache key.
  207. public func url(for key: Key) -> URL? {
  208. guard let filename = self.filename(for: key) else {
  209. return nil
  210. }
  211. return self.path.appendingPathComponent(filename, isDirectory: false)
  212. }
  213. // MARK: Flush Changes
  214. /// Synchronously waits on the caller's thread until all outstanding disk I/O
  215. /// operations are finished.
  216. public func flush() {
  217. queue.sync(execute: flushChangesIfNeeded)
  218. }
  219. /// Synchronously waits on the caller's thread until all outstanding disk I/O
  220. /// operations for the given key are finished.
  221. public func flush(for key: Key) {
  222. queue.sync {
  223. guard let change = lock.sync({ staging.changes[key] }) else { return }
  224. perform(change)
  225. lock.sync { staging.flushed(change) }
  226. }
  227. }
  228. private func setNeedsFlushChanges() {
  229. guard !isFlushNeeded else { return }
  230. isFlushNeeded = true
  231. scheduleNextFlush()
  232. }
  233. private func scheduleNextFlush() {
  234. guard !isFlushScheduled else { return }
  235. isFlushScheduled = true
  236. queue.asyncAfter(deadline: .now() + flushInterval, execute: flushChangesIfNeeded)
  237. }
  238. private func flushChangesIfNeeded() {
  239. // Create a snapshot of the recently made changes
  240. let staging: Staging
  241. lock.lock()
  242. guard isFlushNeeded else {
  243. return lock.unlock()
  244. }
  245. staging = self.staging
  246. isFlushNeeded = false
  247. lock.unlock()
  248. // Apply the snapshot to disk
  249. performChanges(for: staging)
  250. // Update the staging area and schedule the next flush if needed
  251. lock.lock()
  252. self.staging.flushed(staging)
  253. isFlushScheduled = false
  254. if isFlushNeeded {
  255. scheduleNextFlush()
  256. }
  257. lock.unlock()
  258. }
  259. // MARK: - I/O
  260. private func performChanges(for staging: Staging) {
  261. autoreleasepool {
  262. if let change = staging.changeRemoveAll {
  263. perform(change)
  264. }
  265. for change in staging.changes.values {
  266. perform(change)
  267. }
  268. }
  269. }
  270. private func perform(_ change: Staging.ChangeRemoveAll) {
  271. try? FileManager.default.removeItem(at: self.path)
  272. try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil)
  273. }
  274. /// Performs the IO for the given change.
  275. private func perform(_ change: Staging.Change) {
  276. guard let url = url(for: change.key) else {
  277. return
  278. }
  279. switch change.type {
  280. case let .add(data):
  281. do {
  282. try data.write(to: url)
  283. } catch let error as NSError {
  284. guard error.code == CocoaError.fileNoSuchFile.rawValue && error.domain == CocoaError.errorDomain else { return }
  285. try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil)
  286. try? data.write(to: url) // re-create a directory and try again
  287. }
  288. case .remove:
  289. try? FileManager.default.removeItem(at: url)
  290. }
  291. }
  292. // MARK: Sweep
  293. private func performAndScheduleSweep() {
  294. performSweep()
  295. queue.asyncAfter(deadline: .now() + sweepInterval) { [weak self] in
  296. self?.performAndScheduleSweep()
  297. }
  298. }
  299. /// Synchronously performs a cache sweep and removes the least recently items
  300. /// which no longer fit in cache.
  301. public func sweep() {
  302. queue.sync(execute: performSweep)
  303. }
  304. /// Discards the least recently used items first.
  305. private func performSweep() {
  306. var items = contents(keys: [.contentAccessDateKey, .totalFileAllocatedSizeKey])
  307. guard !items.isEmpty else {
  308. return
  309. }
  310. var size = items.reduce(0) { $0 + ($1.meta.totalFileAllocatedSize ?? 0) }
  311. var count = items.count
  312. guard size > sizeLimit || count > deprecatedCountLimit else {
  313. return // All good, no need to perform any work.
  314. }
  315. let sizeLimit = Int(Double(self.sizeLimit) * trimRatio)
  316. let countLimit = Int(Double(self.deprecatedCountLimit) * trimRatio)
  317. // Most recently accessed items first
  318. let past = Date.distantPast
  319. items.sort { // Sort in place
  320. ($0.meta.contentAccessDate ?? past) > ($1.meta.contentAccessDate ?? past)
  321. }
  322. // Remove the items until it satisfies both size and count limits.
  323. while (size > sizeLimit || count > countLimit), let item = items.popLast() {
  324. size -= (item.meta.totalFileAllocatedSize ?? 0)
  325. count -= 1
  326. try? FileManager.default.removeItem(at: item.url)
  327. }
  328. }
  329. // MARK: Contents
  330. struct Entry {
  331. let url: URL
  332. let meta: URLResourceValues
  333. }
  334. func contents(keys: [URLResourceKey] = []) -> [Entry] {
  335. guard let urls = try? FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: keys, options: .skipsHiddenFiles) else {
  336. return []
  337. }
  338. let keys = Set(keys)
  339. return urls.compactMap {
  340. guard let meta = try? $0.resourceValues(forKeys: keys) else {
  341. return nil
  342. }
  343. return Entry(url: $0, meta: meta)
  344. }
  345. }
  346. // MARK: Inspection
  347. /// The total number of items in the cache.
  348. /// - warning: Requires disk IO, avoid using from the main thread.
  349. public var totalCount: Int {
  350. contents().count
  351. }
  352. /// The total file size of items written on disk.
  353. ///
  354. /// Uses `URLResourceKey.fileSizeKey` to calculate the size of each entry.
  355. /// The total allocated size (see `totalAllocatedSize`. on disk might
  356. /// actually be bigger.
  357. ///
  358. /// - warning: Requires disk IO, avoid using from the main thread.
  359. public var totalSize: Int {
  360. contents(keys: [.fileSizeKey]).reduce(0) {
  361. $0 + ($1.meta.fileSize ?? 0)
  362. }
  363. }
  364. /// The total file allocated size of all the items written on disk.
  365. ///
  366. /// Uses `URLResourceKey.totalFileAllocatedSizeKey`.
  367. ///
  368. /// - warning: Requires disk IO, avoid using from the main thread.
  369. public var totalAllocatedSize: Int {
  370. contents(keys: [.totalFileAllocatedSizeKey]).reduce(0) {
  371. $0 + ($1.meta.totalFileAllocatedSize ?? 0)
  372. }
  373. }
  374. }
  375. // MARK: - Staging
  376. /// DataCache allows for parallel reads and writes. This is made possible by
  377. /// DataCacheStaging.
  378. ///
  379. /// For example, when the data is added in cache, it is first added to staging
  380. /// and is removed from staging only after data is written to disk. Removal works
  381. /// the same way.
  382. private struct Staging {
  383. private(set) var changes = [String: Change]()
  384. private(set) var changeRemoveAll: ChangeRemoveAll?
  385. struct ChangeRemoveAll {
  386. let id: Int
  387. }
  388. struct Change {
  389. let key: String
  390. let id: Int
  391. let type: ChangeType
  392. }
  393. enum ChangeType {
  394. case add(Data)
  395. case remove
  396. }
  397. private var nextChangeId = 0
  398. // MARK: Changes
  399. func change(for key: String) -> ChangeType? {
  400. if let change = changes[key] {
  401. return change.type
  402. }
  403. if changeRemoveAll != nil {
  404. return .remove
  405. }
  406. return nil
  407. }
  408. // MARK: Register Changes
  409. mutating func add(data: Data, for key: String) {
  410. nextChangeId += 1
  411. changes[key] = Change(key: key, id: nextChangeId, type: .add(data))
  412. }
  413. mutating func removeData(for key: String) {
  414. nextChangeId += 1
  415. changes[key] = Change(key: key, id: nextChangeId, type: .remove)
  416. }
  417. mutating func removeAll() {
  418. nextChangeId += 1
  419. changeRemoveAll = ChangeRemoveAll(id: nextChangeId)
  420. changes.removeAll()
  421. }
  422. // MARK: Flush Changes
  423. mutating func flushed(_ staging: Staging) {
  424. for change in staging.changes.values {
  425. flushed(change)
  426. }
  427. if let change = staging.changeRemoveAll {
  428. flushed(change)
  429. }
  430. }
  431. mutating func flushed(_ change: Change) {
  432. if let index = changes.index(forKey: change.key),
  433. changes[index].value.id == change.id {
  434. changes.remove(at: index)
  435. }
  436. }
  437. mutating func flushed(_ change: ChangeRemoveAll) {
  438. if changeRemoveAll?.id == change.id {
  439. changeRemoveAll = nil
  440. }
  441. }
  442. }