import Foundation import SwiftUI #if canImport(UIKit) import UIKit public typealias PlatformImage = UIImage #else import AppKit public typealias PlatformImage = NSImage #endif /// Lädt Bilder vom Server — einmal, und behält sie. /// /// `AsyncImage` konnte das nicht: es lädt bei **jedem** Erscheinen neu, kennt /// keinen Zwischenspeicher und legt für jedes Bild eine eigene Anfrage an. Eine /// Startseite zeigt leicht sechzig Kacheln; dazu ein Aufmacher, der ein großes /// Bild anfordert, das der Server erst umrechnen muss. Mit sechs gleichzeitigen /// Verbindungen je Gegenstelle — der Voreinstellung — stauen sich die übrigen /// dahinter, und wer wegscrollt und zurückkommt, stellt sich hinten wieder an. /// /// Auf dem iPad fiel das nicht auf, auf dem Mac schon: dort blieben der /// Aufmacher und die Hälfte der Plakate grau. /// /// Zwei Speicher übereinander: die entpackten Bilder im Arbeitsspeicher, die /// Rohdaten auf der Platte. Der zweite überlebt einen Neustart der App — /// dieselbe Startseite kostet dann keine einzige Anfrage mehr. public actor ArtworkLoader { public static let shared = ArtworkLoader() private let session: URLSession /// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne /// `await` lesen lässt. /// /// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await` /// kostet immer mindestens einen Durchlauf, und genau der war beim /// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten /// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also /// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll /// es im selben Augenblick zeigen können. private let cache = ImageMemoryCache() /// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal /// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt, /// es auch zweimal. private var inFlight: [URL: Task] = [:] init() { let configuration = URLSessionConfiguration.default configuration.urlCache = URLCache( memoryCapacity: 32 * 1_024 * 1_024, diskCapacity: 512 * 1_024 * 1_024 ) configuration.requestCachePolicy = .returnCacheDataElseLoad // Bilder sind klein und viele. Sechs gleichzeitige Verbindungen sind // für eine Seite mit sechzig Kacheln zu wenig. configuration.httpMaximumConnectionsPerHost = 12 configuration.timeoutIntervalForRequest = 30 session = URLSession(configuration: configuration) cache.countLimit = 400 } /// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten. /// /// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann. public nonisolated static func cachedImage(for url: URL) -> PlatformImage? { shared.cache.image(for: url) } public func image(for url: URL) async -> PlatformImage? { if let cached = cache.image(for: url) { return cached } if let running = inFlight[url] { return await running.value } let task = Task { [session] in await Self.fetch(url, using: session) } inFlight[url] = task let image = await task.value inFlight[url] = nil if let image { cache.store(image, for: url) } return image } /// Holt ein Bild — und gibt nicht beim ersten Nein auf. /// /// Der Grund ist ein Fehlerbild, das lange rätselhaft aussah: auf einem /// großen Fenster blieb die **zweite** Reihe fast vollständig grau, /// während die erste geladen hatte. Es lag nicht am Server und nicht an /// fehlenden Bildern, sondern an der Gleichzeitigkeit — sechzig sichtbare /// Kacheln teilen sich zwölf Verbindungen, die hinteren stehen so lange /// in der Schlange, dass sie in den Zeitablauf laufen. /// /// Oben fragt niemand ein zweites Mal: `task(id:)` läuft einmal je /// Adresse. Ein einziger verpasster Versuch war damit endgültig. /// /// **Nur wiederholt, was sich zu wiederholen lohnt.** Ein 404 heißt, dass /// es das Bild nicht gibt — dreimal zu fragen macht es nicht vorhanden /// und kostet den Server drei Antworten. Ein Zeitablauf oder ein /// abgerissener Strang dagegen sagt nur, dass es *gerade* nicht ging. private static func fetch( _ url: URL, using session: URLSession, attempts: Int = 3 ) async -> PlatformImage? { // **Eine Datei auf der Platte ist kein Server.** // // Hier stand nur der Weg über HTTP, und die Antwort einer // `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter // gab also `nil` zurück, immer. Sichtbar wurde das erst, als die // Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und // die Kacheln blieben trotzdem grau. Kein Wiederholen, kein // Zeitablauf: eine Datei ist entweder da oder nicht. if url.isFileURL { guard let data = try? Data(contentsOf: url) else { return nil } return PlatformImage(data: data) } for attempt in 1...attempts { if Task.isCancelled { return nil } do { let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse else { return nil } if (200..<300).contains(http.statusCode) { return PlatformImage(data: data) } // Der Server hat geantwortet und nein gesagt. Bei 5xx und 429 // lohnt ein zweiter Versuch, sonst nicht. guard http.statusCode >= 500 || http.statusCode == 429 else { return nil } } catch is CancellationError { return nil } catch { // Zeitablauf, abgerissene Verbindung: der Fall, für den das // alles hier gebaut ist. } guard attempt < attempts else { return nil } // Wachsender Abstand: ist die Leitung gerade verstopft, hilft es // nicht, sofort noch einmal zu drängeln. try? await Task.sleep(for: .milliseconds(300 * attempt * attempt)) } return nil } } /// Ein Bildspeicher, der sich von überall lesen lässt. /// /// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus /// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier /// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn /// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten /// lesbar zu sein. private final class ImageMemoryCache: @unchecked Sendable { private let storage = NSCache() var countLimit: Int { get { storage.countLimit } set { storage.countLimit = newValue } } func image(for url: URL) -> PlatformImage? { storage.object(forKey: url as NSURL) } func store(_ image: PlatformImage, for url: URL) { storage.setObject(image, forKey: url as NSURL) } } extension Image { /// Aus einem Plattformbild, ohne dass die Ansicht wissen muss, welches. init(platform image: PlatformImage) { #if canImport(UIKit) self.init(uiImage: image) #else self.init(nsImage: image) #endif } }