Warum eine angeklickte Artikelseite nur zur Haelfte lud: Die Bild-Route wird je Tabellenzeile einmal aufgerufen, und ein Browser haelt je Host nur rund sechs Verbindungen offen. Was diese Route aufhaelt, legt die ganze Oberflaeche lahm. Sie hielt gleich dreifach auf: 1. Fehlte die lokale Kopie, holte ``images.ensure`` das Bild MITTEN in der Anfrage von der fremden Adresse - bis zu 15 Sekunden, je Zeile, und bei jedem Aufruf erneut, weil ein Fehlschlag nirgends vermerkt wurde. Artikel aus einem Backup-Import haben genau diese Ausgangslage. Gemessen: 2,01 s je Aufruf, dreimal hintereinander. Jetzt: 0,01 s, das Nachholen laeuft nach der Antwort im Hintergrund und ein toter Link wird eine Stunde gesperrt. 2. Ein Vorschaubild ist 34x34 Pixel gross - ausgeliefert wurde das Original. Gemessen: 2530 KB je Briefmarke, bei 40 Zeilen 99 MB fuer eine Liste. Neu erzeugt die Bild-Route (Pillow) eine Vorschau und legt sie daneben ab; ``?thumb=1`` liefert sie aus. Gemessen: 0,6 KB. 3. If-None-Match wurde ignoriert. Nach den 5 Minuten Cache-Frist lud der Browser jedes Bild komplett neu - das erklaert, warum es "alle 20 Minuten" wieder losging. Jetzt 304 ohne Daten. Dazu im Web: laufende Bildabrufe werden beim Seitenwechsel abgebrochen (sonst steht die neue Seite hinter Bildern Schlange, die niemand mehr sieht), und wo ``image_version`` bekannt ist, entfaellt die Anfrage fuer bildlose Artikel ganz. Und der Verbindungsvorrat der Datenbank: 5 (+10) gegen 40 Arbeits-Threads von FastAPI. Ab der 16. gleichzeitigen Anfrage wartete eine Route stillschweigend 30 Sekunden - von aussen ein haengender Server. Jetzt 10 (+20) mit 10 Sekunden Frist: lieber ein Fehler als eine halbe Minute Stille. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
11 KiB
Swift
281 lines
11 KiB
Swift
import SwiftUI
|
||
import PhotosUI
|
||
|
||
/// 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 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 index.empty.contains(id)
|
||
}
|
||
|
||
/// 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) {
|
||
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, thumb: true),
|
||
let ui = UIImage(data: data) {
|
||
store(ui, data: data, version: sv, for: p.id)
|
||
} else {
|
||
markEmpty(p.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Kleines Vorschaubild für Listen: zeigt das Artikelbild, sonst das neutrale
|
||
/// Kästchen-Symbol wie bisher. Ergebnisse kommen aus dem gemeinsamen Cache.
|
||
struct ProductThumb: View {
|
||
let productId: Int
|
||
@State private var image: UIImage?
|
||
|
||
var body: some View {
|
||
content
|
||
.frame(width: 34, height: 34)
|
||
.task(id: productId) { await load() }
|
||
}
|
||
|
||
@ViewBuilder private var content: some View {
|
||
if let image {
|
||
Image(uiImage: image)
|
||
.resizable().scaledToFill()
|
||
.frame(width: 34, height: 34)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
} else {
|
||
Image(systemName: "shippingbox")
|
||
.font(.body).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
private func load() async {
|
||
if let cached = ProductImageCache.shared.image(for: productId) {
|
||
image = cached
|
||
return
|
||
}
|
||
if ProductImageCache.shared.isKnownEmpty(productId) { return }
|
||
guard let data = try? await APIClient.shared.productImage(id: productId, thumb: true),
|
||
let ui = UIImage(data: data) else {
|
||
ProductImageCache.shared.markEmpty(productId)
|
||
return
|
||
}
|
||
ProductImageCache.shared.store(ui, data: data, version: nil, for: productId)
|
||
image = ui
|
||
}
|
||
}
|
||
|
||
/// Artikelfoto ansehen, per Kamera aufnehmen oder aus der Galerie wählen und
|
||
/// hochladen. Wird als Abschnitt in die Produkt-Detailansicht eingebettet.
|
||
struct ProductPhotoSection: View {
|
||
let productId: Int
|
||
|
||
@State private var imageData: Data?
|
||
@State private var loaded = false
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
@State private var showCamera = false
|
||
@State private var pickerItem: PhotosPickerItem?
|
||
|
||
var body: some View {
|
||
Section("Foto") {
|
||
if let imageData, let ui = UIImage(data: imageData) {
|
||
Image(uiImage: ui)
|
||
.resizable().scaledToFit()
|
||
.frame(maxWidth: .infinity)
|
||
.frame(maxHeight: 240)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
} else if loaded {
|
||
Text("Noch kein Foto.").foregroundStyle(.secondary)
|
||
}
|
||
|
||
Button { showCamera = true } label: {
|
||
Label("Foto machen", systemImage: "camera")
|
||
}
|
||
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||
Label("Aus Galerie", systemImage: "photo.on.rectangle")
|
||
}
|
||
if imageData != nil {
|
||
Button(role: .destructive) { Task { await removePhoto() } } label: {
|
||
Label("Foto entfernen", systemImage: "trash")
|
||
}
|
||
}
|
||
if busy { ProgressView() }
|
||
if let error { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
.fullScreenCover(isPresented: $showCamera) {
|
||
CameraPicker(isPresented: $showCamera) { image in Task { await upload(image) } }
|
||
.ignoresSafeArea()
|
||
}
|
||
.onChange(of: pickerItem) { item in
|
||
guard let item else { return }
|
||
Task {
|
||
if let data = try? await item.loadTransferable(type: Data.self),
|
||
let ui = UIImage(data: data) {
|
||
await upload(ui)
|
||
}
|
||
pickerItem = nil
|
||
}
|
||
}
|
||
.task { await load() }
|
||
}
|
||
|
||
private func load() async {
|
||
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, data: imageData, version: nil, for: productId)
|
||
} else {
|
||
ProductImageCache.shared.markEmpty(productId)
|
||
}
|
||
loaded = true
|
||
}
|
||
|
||
private func upload(_ image: UIImage) async {
|
||
guard let data = productImageJPEG(image) else { return }
|
||
busy = true
|
||
defer { busy = false }
|
||
error = nil
|
||
do {
|
||
_ = try await APIClient.shared.uploadProductImage(
|
||
id: productId, data: data, contentType: "image/jpeg")
|
||
imageData = data
|
||
if let ui = UIImage(data: data) { ProductImageCache.shared.store(ui, data: data, version: nil, for: productId) }
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func removePhoto() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
error = nil
|
||
do {
|
||
try await APIClient.shared.deleteProductImage(id: productId)
|
||
imageData = nil
|
||
ProductImageCache.shared.markEmpty(productId)
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
/// Foto verkleinern und als JPEG unter die Server-Grenze (2 MB) drücken.
|
||
/// Frei (nicht an eine View gebunden), damit auch das Anlege-Formular es nutzt.
|
||
func productImageJPEG(_ image: UIImage, maxDim: CGFloat = 1600) -> Data? {
|
||
let size = image.size
|
||
let scale = min(1, maxDim / max(size.width, size.height))
|
||
let target = CGSize(width: size.width * scale, height: size.height * scale)
|
||
let scaled = UIGraphicsImageRenderer(size: target).image { _ in
|
||
image.draw(in: CGRect(origin: .zero, size: target))
|
||
}
|
||
var quality: CGFloat = 0.8
|
||
var data = scaled.jpegData(compressionQuality: quality)
|
||
while let d = data, d.count > 1_800_000, quality > 0.3 {
|
||
quality -= 0.15
|
||
data = scaled.jpegData(compressionQuality: quality)
|
||
}
|
||
return data
|
||
}
|
||
|
||
/// Kamera über UIImagePickerController – SwiftUI hat keine eigene Kamera-Ansicht.
|
||
/// Ohne Kamera (Simulator) fällt es auf die Fotoauswahl zurück.
|
||
struct CameraPicker: UIViewControllerRepresentable {
|
||
@Binding var isPresented: Bool
|
||
var onImage: (UIImage) -> Void
|
||
|
||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||
let picker = UIImagePickerController()
|
||
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
|
||
? .camera : .photoLibrary
|
||
picker.delegate = context.coordinator
|
||
return picker
|
||
}
|
||
|
||
func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||
|
||
// Schließen deterministisch über das Binding statt über die Umgebung –
|
||
// das behebt das Flackern (Kamera geht direkt wieder zu), wenn die Ansicht
|
||
// während der Präsentation neu ausgewertet wird.
|
||
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||
let parent: CameraPicker
|
||
init(_ parent: CameraPicker) { self.parent = parent }
|
||
|
||
func imagePickerController(_ picker: UIImagePickerController,
|
||
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||
if let image = info[.originalImage] as? UIImage { parent.onImage(image) }
|
||
parent.isPresented = false
|
||
}
|
||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||
parent.isPresented = false
|
||
}
|
||
}
|
||
}
|