iOS: dauerhafter Bild-Cache (Platte) + Vorladen, nur geaenderte Bilder neu

ProductImageCache haelt Bilder jetzt zusaetzlich auf der Platte (Caches-Verzeichnis) mit gemerkter image_version - so ueberleben Thumbnails den App-Neustart und sind in Listen sofort da, statt bei drei Vierteln zu fehlen. Beim Laden der Produktliste werden alle Bilder im Hintergrund sichergestellt; per image_version (aus einem Listen-Request) werden nur fehlende oder geaenderte Bilder geladen. Product-Model um imageVersion ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 14:42:29 +02:00
parent 72256f4fe9
commit 89d8ba4eff
3 changed files with 85 additions and 17 deletions

View File

@@ -1,30 +1,91 @@
import SwiftUI
import PhotosUI
/// Gemeinsamer Zwischenspeicher für Artikelbilder, damit Listen beim Scrollen
/// nicht dieselben Bilder immer wieder laden. Kein Bild" wird ebenfalls
/// gemerkt, damit erfolglose Abfragen sich nicht wiederholen.
/// Zwischenspeicher für Artikelbilder im Speicher **und auf der Platte**, damit
/// die Bilder einen App-Neustart überleben und in Listen sofort da sind. Zu jedem
/// Bild wird die Version (`image_version` des Produkts) gemerkt; so lädt `preload`
/// nur fehlende oder geänderte Bilder neu. Kein Bild" wird ebenfalls gemerkt.
final class ProductImageCache {
static let shared = ProductImageCache()
private let cache = NSCache<NSNumber, UIImage>()
private var empty: Set<Int> = []
private let lock = NSLock()
func image(for id: Int) -> UIImage? { cache.object(forKey: NSNumber(value: id)) }
private struct Index: Codable { var versions: [Int: Int] = [:]; var empty: [Int] = [] }
private let memory = NSCache<NSNumber, UIImage>()
private let lock = NSLock()
private let dir: URL
private var index = Index()
private init() {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
dir = base.appendingPathComponent("product-images", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
if let data = try? Data(contentsOf: indexURL),
let decoded = try? JSONDecoder().decode(Index.self, from: data) {
index = decoded
}
}
private var indexURL: URL { dir.appendingPathComponent("index.json") }
private func fileURL(_ id: Int) -> URL { dir.appendingPathComponent("\(id).img") }
private func saveIndex() {
if let data = try? JSONEncoder().encode(index) {
try? data.write(to: indexURL, options: .atomic)
}
}
/// Bild aus dem Speicher, sonst von der Platte (überlebt Neustart).
func image(for id: Int) -> UIImage? {
if let ui = memory.object(forKey: NSNumber(value: id)) { return ui }
guard let data = try? Data(contentsOf: fileURL(id)), let ui = UIImage(data: data) else { return nil }
memory.setObject(ui, forKey: NSNumber(value: id))
return ui
}
func isKnownEmpty(_ id: Int) -> Bool {
lock.lock(); defer { lock.unlock() }
return empty.contains(id)
return index.empty.contains(id)
}
func store(_ image: UIImage, for id: Int) {
cache.setObject(image, forKey: NSNumber(value: id))
lock.lock(); empty.remove(id); lock.unlock()
/// Bild speichern. `version` = `image_version` des Produkts (nil, wenn unbekannt,
/// z.B. beim Lazy-Nachladen einzelner Thumbnails).
func store(_ image: UIImage, data: Data, version: Int?, for id: Int) {
memory.setObject(image, forKey: NSNumber(value: id))
try? data.write(to: fileURL(id), options: .atomic)
lock.lock()
index.empty.removeAll { $0 == id }
if let version { index.versions[id] = version }
saveIndex()
lock.unlock()
}
func markEmpty(_ id: Int) {
cache.removeObject(forKey: NSNumber(value: id))
lock.lock(); empty.insert(id); lock.unlock()
memory.removeObject(forKey: NSNumber(value: id))
try? FileManager.default.removeItem(at: fileURL(id))
lock.lock()
index.versions[id] = nil
if !index.empty.contains(id) { index.empty.append(id) }
saveIndex()
lock.unlock()
}
private func isFresh(_ id: Int, _ serverVersion: Int) -> Bool {
lock.lock(); let known = index.versions[id]; lock.unlock()
return known == serverVersion && FileManager.default.fileExists(atPath: fileURL(id).path)
}
/// Alle Bilder einer Produktliste sicherstellen nur fehlende/geänderte werden geladen.
/// Beim Öffnen der Produkt-/Einzelstück-Liste aufrufen.
func preload(_ products: [Product]) async {
for p in products {
guard let sv = p.imageVersion else { markEmpty(p.id); continue }
if isFresh(p.id, sv) { continue }
if let data = try? await APIClient.shared.productImage(id: p.id),
let ui = UIImage(data: data) {
store(ui, data: data, version: sv, for: p.id)
} else {
markEmpty(p.id)
}
}
}
}
@@ -63,7 +124,7 @@ struct ProductThumb: View {
ProductImageCache.shared.markEmpty(productId)
return
}
ProductImageCache.shared.store(ui, for: productId)
ProductImageCache.shared.store(ui, data: data, version: nil, for: productId)
image = ui
}
}
@@ -127,7 +188,7 @@ struct ProductPhotoSection: View {
imageData = try? await APIClient.shared.productImage(id: productId)
// Listen-Cache mit der Wahrheit abgleichen.
if let imageData, let ui = UIImage(data: imageData) {
ProductImageCache.shared.store(ui, for: productId)
ProductImageCache.shared.store(ui, data: imageData, version: nil, for: productId)
} else {
ProductImageCache.shared.markEmpty(productId)
}
@@ -143,7 +204,7 @@ struct ProductPhotoSection: View {
_ = try await APIClient.shared.uploadProductImage(
id: productId, data: data, contentType: "image/jpeg")
imageData = data
if let ui = UIImage(data: data) { ProductImageCache.shared.store(ui, for: productId) }
if let ui = UIImage(data: data) { ProductImageCache.shared.store(ui, data: data, version: nil, for: productId) }
} catch {
self.error = error.localizedDescription
}