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

@@ -308,7 +308,11 @@ struct ProductListView: View {
private func load() async { private func load() async {
busy = true busy = true
defer { busy = false } defer { busy = false }
products = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? [] let geladen = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
products = geladen
// Bilder im Hintergrund sicherstellen nur fehlende/geänderte werden geholt,
// damit die Thumbnails auch nach App-Neustart sofort aus dem Cache kommen.
Task { await ProductImageCache.shared.preload(geladen) }
} }
} }

View File

@@ -54,6 +54,8 @@ struct Product: Codable, Identifiable, Hashable {
let minStock: Double? let minStock: Double?
let stock: Double let stock: Double
let expiredCount: Int let expiredCount: Int
/// Epoch der letzten Bildänderung (nil = kein Bild) steuert den Bild-Cache.
let imageVersion: Int?
let kind: String let kind: String
let unitName: String let unitName: String
let unitFactor: Double let unitFactor: Double
@@ -94,6 +96,7 @@ struct Product: Codable, Identifiable, Hashable {
case groupId = "group_id" case groupId = "group_id"
case minStock = "min_stock" case minStock = "min_stock"
case expiredCount = "expired_count" case expiredCount = "expired_count"
case imageVersion = "image_version"
case unitName = "unit_name" case unitName = "unit_name"
case unitFactor = "unit_factor" case unitFactor = "unit_factor"
case shopId = "shop_id" case shopId = "shop_id"

View File

@@ -1,30 +1,91 @@
import SwiftUI import SwiftUI
import PhotosUI import PhotosUI
/// Gemeinsamer Zwischenspeicher für Artikelbilder, damit Listen beim Scrollen /// Zwischenspeicher für Artikelbilder im Speicher **und auf der Platte**, damit
/// nicht dieselben Bilder immer wieder laden. Kein Bild" wird ebenfalls /// die Bilder einen App-Neustart überleben und in Listen sofort da sind. Zu jedem
/// gemerkt, damit erfolglose Abfragen sich nicht wiederholen. /// 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 { final class ProductImageCache {
static let shared = 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 { func isKnownEmpty(_ id: Int) -> Bool {
lock.lock(); defer { lock.unlock() } lock.lock(); defer { lock.unlock() }
return empty.contains(id) return index.empty.contains(id)
} }
func store(_ image: UIImage, for id: Int) { /// Bild speichern. `version` = `image_version` des Produkts (nil, wenn unbekannt,
cache.setObject(image, forKey: NSNumber(value: id)) /// z.B. beim Lazy-Nachladen einzelner Thumbnails).
lock.lock(); empty.remove(id); lock.unlock() 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) { func markEmpty(_ id: Int) {
cache.removeObject(forKey: NSNumber(value: id)) memory.removeObject(forKey: NSNumber(value: id))
lock.lock(); empty.insert(id); lock.unlock() 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) ProductImageCache.shared.markEmpty(productId)
return return
} }
ProductImageCache.shared.store(ui, for: productId) ProductImageCache.shared.store(ui, data: data, version: nil, for: productId)
image = ui image = ui
} }
} }
@@ -127,7 +188,7 @@ struct ProductPhotoSection: View {
imageData = try? await APIClient.shared.productImage(id: productId) imageData = try? await APIClient.shared.productImage(id: productId)
// Listen-Cache mit der Wahrheit abgleichen. // Listen-Cache mit der Wahrheit abgleichen.
if let imageData, let ui = UIImage(data: imageData) { 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 { } else {
ProductImageCache.shared.markEmpty(productId) ProductImageCache.shared.markEmpty(productId)
} }
@@ -143,7 +204,7 @@ struct ProductPhotoSection: View {
_ = try await APIClient.shared.uploadProductImage( _ = try await APIClient.shared.uploadProductImage(
id: productId, data: data, contentType: "image/jpeg") id: productId, data: data, contentType: "image/jpeg")
imageData = data 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 { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }