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.
 
 
 
 

316 lines
9.4 KiB

// The MIT License (MIT)
//
// Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean).
import Foundation
#if !os(macOS)
import UIKit
#else
import Cocoa
#endif
/// In-memory image cache.
///
/// The implementation must be thread safe.
public protocol ImageCaching: AnyObject {
/// Access the image cached for the given request.
subscript(request: ImageRequest) -> ImageContainer? { get set }
}
public extension ImageCaching {
subscript(url: URL) -> ImageContainer? {
get { self[ImageRequest(url: url)] }
set { self[ImageRequest(url: url)] = newValue }
}
}
/// Memory cache with LRU cleanup policy (least recently used are removed first).
///
/// The elements stored in cache are automatically discarded if either *cost* or
/// *count* limit is reached. The default cost limit represents a number of bytes
/// and is calculated based on the amount of physical memory available on the
/// device. The default count limit is set to `Int.max`.
///
/// `ImageCache` automatically removes all stored elements when it receives a
/// memory warning. It also automatically removes *most* stored elements
/// when the app enters the background.
public final class ImageCache: ImageCaching {
private let impl: Cache<ImageRequest.CacheKey, ImageContainer>
/// The maximum total cost that the cache can hold.
public var costLimit: Int {
get { impl.costLimit }
set { impl.costLimit = newValue }
}
/// The maximum number of items that the cache can hold.
public var countLimit: Int {
get { impl.countLimit }
set { impl.countLimit = newValue }
}
/// Default TTL (time to live) for each entry. Can be used to make sure that
/// the entries get validated at some point. `0` (never expire) by default.
public var ttl: TimeInterval {
get { impl.ttl }
set { impl.ttl = newValue }
}
/// The total cost of items in the cache.
public var totalCost: Int {
return impl.totalCost
}
/// The total number of items in the cache.
public var totalCount: Int {
return impl.totalCount
}
/// Shared `Cache` instance.
public static let shared = ImageCache()
deinit {
#if TRACK_ALLOCATIONS
Allocations.decrement("ImageCache")
#endif
}
/// Initializes `Cache`.
/// - parameter costLimit: Default value representes a number of bytes and is
/// calculated based on the amount of the phisical memory available on the device.
/// - parameter countLimit: `Int.max` by default.
public init(costLimit: Int = ImageCache.defaultCostLimit(), countLimit: Int = Int.max) {
impl = Cache(costLimit: costLimit, countLimit: countLimit)
#if TRACK_ALLOCATIONS
Allocations.increment("ImageCache")
#endif
}
/// Returns a recommended cost limit which is computed based on the amount
/// of the phisical memory available on the device.
public static func defaultCostLimit() -> Int {
let physicalMemory = ProcessInfo.processInfo.physicalMemory
let ratio = physicalMemory <= (536_870_912 /* 512 Mb */) ? 0.1 : 0.2
let limit = physicalMemory / UInt64(1 / ratio)
return limit > UInt64(Int.max) ? Int.max : Int(limit)
}
/// Returns the `ImageResponse` stored in the cache with the given request.
public subscript(request: ImageRequest) -> ImageContainer? {
get {
let key = request.makeCacheKeyForFinalImage()
return impl.value(forKey: key)
}
set {
let key = request.makeCacheKeyForFinalImage()
if let image = newValue {
impl.set(image, forKey: key, cost: self.cost(for: image))
} else {
impl.removeValue(forKey: key)
}
}
}
/// Removes all cached images.
public func removeAll() {
impl.removeAll()
}
/// Removes least recently used items from the cache until the total cost
/// of the remaining items is less than the given cost limit.
public func trim(toCost limit: Int) {
impl.trim(toCost: limit)
}
/// Removes least recently used items from the cache until the total count
/// of the remaining items is less than the given count limit.
public func trim(toCount limit: Int) {
impl.trim(toCount: limit)
}
/// Returns cost for the given image by approximating its bitmap size in bytes in memory.
func cost(for container: ImageContainer) -> Int {
let dataCost: Int
if ImagePipeline.Configuration._isAnimatedImageDataEnabled {
dataCost = container.image._animatedImageData?.count ?? 0
} else {
dataCost = container.data?.count ?? 0
}
// bytesPerRow * height gives a rough estimation of how much memory
// image uses in bytes. In practice this algorithm combined with a
// conservative default cost limit works OK.
guard let cgImage = container.image.cgImage else {
return 1 + dataCost
}
return cgImage.bytesPerRow * cgImage.height + dataCost
}
}
final class Cache<Key: Hashable, Value> {
// Can't use `NSCache` because it is not LRU
private var map = [Key: LinkedList<Entry>.Node]()
private let list = LinkedList<Entry>()
private let lock = NSLock()
private let memoryPressure: DispatchSourceMemoryPressure
var costLimit: Int {
didSet { lock.sync(_trim) }
}
var countLimit: Int {
didSet { lock.sync(_trim) }
}
private(set) var totalCost = 0
var ttl: TimeInterval = 0
var totalCount: Int {
map.count
}
init(costLimit: Int, countLimit: Int) {
self.costLimit = costLimit
self.countLimit = countLimit
self.memoryPressure = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical], queue: .main)
self.memoryPressure.setEventHandler { [weak self] in
self?.removeAll()
}
self.memoryPressure.resume()
#if os(iOS) || os(tvOS)
let center = NotificationCenter.default
center.addObserver(self, selector: #selector(didEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil)
#endif
#if TRACK_ALLOCATIONS
Allocations.increment("Cache")
#endif
}
deinit {
memoryPressure.cancel()
#if TRACK_ALLOCATIONS
Allocations.decrement("Cache")
#endif
}
func value(forKey key: Key) -> Value? {
lock.lock(); defer { lock.unlock() }
guard let node = map[key] else {
return nil
}
guard !node.value.isExpired else {
_remove(node: node)
return nil
}
// bubble node up to make it last added (most recently used)
list.remove(node)
list.append(node)
return node.value.value
}
func set(_ value: Value, forKey key: Key, cost: Int = 0, ttl: TimeInterval? = nil) {
lock.lock(); defer { lock.unlock() }
let ttl = ttl ?? self.ttl
let expiration = ttl == 0 ? nil : (Date() + ttl)
let entry = Entry(value: value, key: key, cost: cost, expiration: expiration)
_add(entry)
_trim() // _trim is extremely fast, it's OK to call it each time
}
@discardableResult
func removeValue(forKey key: Key) -> Value? {
lock.lock(); defer { lock.unlock() }
guard let node = map[key] else {
return nil
}
_remove(node: node)
return node.value.value
}
private func _add(_ element: Entry) {
if let existingNode = map[element.key] {
_remove(node: existingNode)
}
map[element.key] = list.append(element)
totalCost += element.cost
}
private func _remove(node: LinkedList<Entry>.Node) {
list.remove(node)
map[node.value.key] = nil
totalCost -= node.value.cost
}
@objc
dynamic func removeAll() {
lock.sync {
map.removeAll()
list.removeAll()
totalCost = 0
}
}
private func _trim() {
_trim(toCost: costLimit)
_trim(toCount: countLimit)
}
@objc
private dynamic func didEnterBackground() {
// Remove most of the stored items when entering background.
// This behavior is similar to `NSCache` (which removes all
// items). This feature is not documented and may be subject
// to change in future Nuke versions.
lock.sync {
_trim(toCost: Int(Double(costLimit) * 0.1))
_trim(toCount: Int(Double(countLimit) * 0.1))
}
}
func trim(toCost limit: Int) {
lock.sync { _trim(toCost: limit) }
}
private func _trim(toCost limit: Int) {
_trim(while: { totalCost > limit })
}
func trim(toCount limit: Int) {
lock.sync { _trim(toCount: limit) }
}
private func _trim(toCount limit: Int) {
_trim(while: { totalCount > limit })
}
private func _trim(while condition: () -> Bool) {
while condition(), let node = list.first { // least recently used
_remove(node: node)
}
}
private struct Entry {
let value: Value
let key: Key
let cost: Int
let expiration: Date?
var isExpired: Bool {
guard let expiration = expiration else {
return false
}
return expiration.timeIntervalSinceNow < 0
}
}
}