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 guard let (data, response) = try? await session.data(from: url), let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let image = PlatformImage(data: data) else { return nil } return image } inFlight[url] = task let image = await task.value inFlight[url] = nil if let image { cache.store(image, for: url) } return image } } /// 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 } }