// // MemoryStorage.swift // Kingfisher // // Created by Wei Wang on 2018/10/15. // // Copyright (c) 2019 Wei Wang // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import Foundation /// Represents a set of conception related to storage which stores a certain type of value in memory. /// This is a namespace for the memory storage types. A `Backend` with a certain `Config` will be used to describe the /// storage. See these composed types for more information. public enum MemoryStorage { /// Represents a storage which stores a certain type of value in memory. It provides fast access, /// but limited storing size. The stored value type needs to conform to `CacheCostCalculable`, /// and its `cacheCost` will be used to determine the cost of size for the cache item. /// /// You can config a `MemoryStorage.Backend` in its initializer by passing a `MemoryStorage.Config` value. /// or modifying the `config` property after it being created. The backend of `MemoryStorage` has /// upper limitation on cost size in memory and item count. All items in the storage has an expiration /// date. When retrieved, if the target item is already expired, it will be recognized as it does not /// exist in the storage. The `MemoryStorage` also contains a scheduled self clean task, to evict expired /// items from memory. public class Backend { let storage = NSCache>() var keys = Set() var cleanTimer: Timer? = nil let lock = NSLock() let cacheDelegate = CacheDelegate>() /// The config used in this storage. It is a value you can set and /// use to config the storage in air. public var config: Config { didSet { storage.totalCostLimit = config.totalCostLimit storage.countLimit = config.countLimit } } /// Creates a `MemoryStorage` with a given `config`. /// /// - Parameter config: The config used to create the storage. It determines the max size limitation, /// default expiration setting and more. public init(config: Config) { self.config = config storage.totalCostLimit = config.totalCostLimit storage.countLimit = config.countLimit storage.delegate = cacheDelegate cacheDelegate.onObjectRemoved.delegate(on: self) { (self, obj) in self.keys.remove(obj.key) } cleanTimer = .scheduledTimer(withTimeInterval: config.cleanInterval, repeats: true) { [weak self] _ in guard let self = self else { return } self.removeExpired() } } func removeExpired() { lock.lock() defer { lock.unlock() } for key in keys { let nsKey = key as NSString guard let object = storage.object(forKey: nsKey) else { keys.remove(key) continue } if object.estimatedExpiration.isPast { storage.removeObject(forKey: nsKey) keys.remove(key) } } } // Storing in memory will not throw. It is just for meeting protocol requirement and // forwarding to no throwing method. func store( value: T, forKey key: String, expiration: StorageExpiration? = nil) throws { storeNoThrow(value: value, forKey: key, expiration: expiration) } // The no throw version for storing value in cache. Kingfisher knows the detail so it // could use this version to make syntax simpler internally. func storeNoThrow( value: T, forKey key: String, expiration: StorageExpiration? = nil) { lock.lock() defer { lock.unlock() } let expiration = expiration ?? config.expiration // The expiration indicates that already expired, no need to store. guard !expiration.isExpired else { return } let object = StorageObject(value, key: key, expiration: expiration) storage.setObject(object, forKey: key as NSString, cost: value.cacheCost) keys.insert(key) } // Use this when you actually access the memory cached item. // This will extend the expired data for the accessed item. func value(forKey key: String) throws -> T? { return value(forKey: key, extendingExpiration: true) } func value(forKey key: String, extendingExpiration: Bool) -> T? { guard let object = storage.object(forKey: key as NSString) else { return nil } if object.expired { return nil } if extendingExpiration { object.extendExpiration() } return object.value } func isCached(forKey key: String) -> Bool { guard let _ = value(forKey: key, extendingExpiration: false) else { return false } return true } func remove(forKey key: String) throws { lock.lock() defer { lock.unlock() } storage.removeObject(forKey: key as NSString) keys.remove(key) } func removeAll() throws { lock.lock() defer { lock.unlock() } storage.removeAllObjects() keys.removeAll() } class CacheDelegate: NSObject, NSCacheDelegate { let onObjectRemoved = Delegate() func cache(_ cache: NSCache, willEvictObject obj: Any) { if let obj = obj as? T { onObjectRemoved.call(obj) } } } } } extension MemoryStorage { /// Represents the config used in a `MemoryStorage`. public struct Config { /// Total cost limit of the storage in bytes. public var totalCostLimit: Int /// The item count limit of the memory storage. public var countLimit: Int = .max /// The `StorageExpiration` used in this memory storage. Default is `.seconds(300)`, /// means that the memory cache would expire in 5 minutes. public var expiration: StorageExpiration = .seconds(300) /// The time interval between the storage do clean work for swiping expired items. public let cleanInterval: TimeInterval /// Creates a config from a given `totalCostLimit` value. /// /// - Parameters: /// - totalCostLimit: Total cost limit of the storage in bytes. /// - cleanInterval: The time interval between the storage do clean work for swiping expired items. /// Default is 120, means the auto eviction happens once per two minutes. /// /// - Note: /// Other members of `MemoryStorage.Config` will use their default values when created. public init(totalCostLimit: Int, cleanInterval: TimeInterval = 120) { self.totalCostLimit = totalCostLimit self.cleanInterval = cleanInterval } } } extension MemoryStorage { class StorageObject { let value: T let expiration: StorageExpiration let key: String private(set) var estimatedExpiration: Date init(_ value: T, key: String, expiration: StorageExpiration) { self.value = value self.key = key self.expiration = expiration self.estimatedExpiration = expiration.estimatedExpirationSinceNow } func extendExpiration() { self.estimatedExpiration = expiration.estimatedExpirationSinceNow } var expired: Bool { return estimatedExpiration.isPast } } }