Version 1 in App Store
This commit is contained in:
@@ -7,13 +7,23 @@
|
||||
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
import OSLog
|
||||
|
||||
private let imgLogger = Logger(subsystem: "com.musicassistant.mobile", category: "ImageCache")
|
||||
|
||||
// MARK: - Image Cache
|
||||
|
||||
final class ImageCache: @unchecked Sendable {
|
||||
static let shared = ImageCache()
|
||||
|
||||
// NSCache: auto-evicts under memory pressure (large buffer)
|
||||
private let memory = NSCache<NSString, UIImage>()
|
||||
// LRU strong-reference store: last N images stay alive even after NSCache eviction
|
||||
private var lruImages: [String: UIImage] = [:]
|
||||
private var lruKeys: [String] = []
|
||||
private let lruMaxCount = 60
|
||||
private let lock = NSLock()
|
||||
|
||||
private let directory: URL
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
@@ -30,23 +40,44 @@ final class ImageCache: @unchecked Sendable {
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func image(for key: String) -> UIImage? {
|
||||
// 1. Memory
|
||||
if let img = memory.object(forKey: key as NSString) { return img }
|
||||
// 2. Disk
|
||||
/// Check in-memory caches (LRU + NSCache). Thread-safe, no disk I/O — safe on main thread.
|
||||
func memoryImage(for key: String) -> UIImage? {
|
||||
lock.withLock {
|
||||
lruImages[key] ?? memory.object(forKey: key as NSString)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check disk only (no memory). Must be called from a background thread.
|
||||
func diskImage(for key: String) -> UIImage? {
|
||||
let file = directory.appendingPathComponent(key)
|
||||
guard let data = try? Data(contentsOf: file),
|
||||
let img = UIImage(data: data) else { return nil }
|
||||
memory.setObject(img, forKey: key as NSString, cost: data.count)
|
||||
// Promote to memory on load
|
||||
storeMemory(img, data: data, for: key)
|
||||
return img
|
||||
}
|
||||
|
||||
func store(_ image: UIImage, data: Data, for key: String) {
|
||||
memory.setObject(image, forKey: key as NSString, cost: data.count)
|
||||
storeMemory(image, data: data, for: key)
|
||||
let file = directory.appendingPathComponent(key)
|
||||
try? data.write(to: file, options: .atomic)
|
||||
}
|
||||
|
||||
private func storeMemory(_ image: UIImage, data: Data, for key: String) {
|
||||
lock.withLock {
|
||||
memory.setObject(image, forKey: key as NSString, cost: data.count)
|
||||
// Update LRU: move to most-recent position
|
||||
lruKeys.removeAll { $0 == key }
|
||||
lruKeys.append(key)
|
||||
lruImages[key] = image
|
||||
// Trim oldest entry when over limit
|
||||
while lruKeys.count > lruMaxCount {
|
||||
let oldest = lruKeys.removeFirst()
|
||||
lruImages.removeValue(forKey: oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes currently stored on disk.
|
||||
var diskUsageBytes: Int {
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
@@ -63,7 +94,11 @@ final class ImageCache: @unchecked Sendable {
|
||||
|
||||
/// Remove all cached artwork from disk and memory.
|
||||
func clearAll() {
|
||||
memory.removeAllObjects()
|
||||
lock.withLock {
|
||||
memory.removeAllObjects()
|
||||
lruImages.removeAll()
|
||||
lruKeys.removeAll()
|
||||
}
|
||||
try? fileManager.removeItem(at: directory)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
@@ -71,7 +106,7 @@ final class ImageCache: @unchecked Sendable {
|
||||
|
||||
// MARK: - CachedAsyncImage
|
||||
|
||||
/// Async image view that caches to both memory (NSCache) and disk (FileManager).
|
||||
/// Async image view that caches to memory (NSCache + LRU) and disk.
|
||||
/// Sends the MA auth token in the Authorization header so the image proxy responds.
|
||||
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@@ -90,6 +125,12 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
self.url = url
|
||||
self.content = content
|
||||
self.placeholder = placeholder
|
||||
// Pre-warm from memory (LRU + NSCache) — thread-safe, no disk I/O.
|
||||
// Prevents placeholder flash when navigating back to a previously loaded view.
|
||||
let cached = url.flatMap { u -> UIImage? in
|
||||
ImageCache.shared.memoryImage(for: ImageCache.shared.cacheKey(for: u))
|
||||
}
|
||||
_image = State(initialValue: cached)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -108,16 +149,26 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
|
||||
private func loadImage() async {
|
||||
guard let url else { return }
|
||||
|
||||
let key = ImageCache.shared.cacheKey(for: url)
|
||||
|
||||
// Serve from cache instantly if available
|
||||
if let cached = ImageCache.shared.image(for: key) {
|
||||
// 1. Memory (LRU + NSCache) — instant, no I/O
|
||||
if let cached = ImageCache.shared.memoryImage(for: key) {
|
||||
image = cached
|
||||
return
|
||||
}
|
||||
|
||||
// Build request with auth header
|
||||
// 2. Disk — read off the main thread to avoid UI blocking
|
||||
let diskCached = await Task.detached(priority: .userInitiated) {
|
||||
ImageCache.shared.diskImage(for: key)
|
||||
}.value
|
||||
|
||||
if let diskCached {
|
||||
guard !Task.isCancelled else { return }
|
||||
image = diskCached
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Network fetch with auth header
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
if let token = service.authManager.currentToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
@@ -125,13 +176,16 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
// Only cache successful responses
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return }
|
||||
guard !Task.isCancelled else { return }
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
guard status == 200 else { return }
|
||||
guard let uiImage = UIImage(data: data) else { return }
|
||||
ImageCache.shared.store(uiImage, data: data, for: key)
|
||||
image = uiImage
|
||||
} catch {
|
||||
// Network errors are silent — placeholder stays visible
|
||||
// URLError.cancelled is expected when lazy views leave the viewport — not a real error
|
||||
guard (error as? URLError)?.code != .cancelled else { return }
|
||||
imgLogger.debug("Image load failed: \(error.localizedDescription) for \(url.absoluteString)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user