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:
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user