Auf einem großen Fenster blieb die zweite Reihe fast vollständig grau, während die erste geladen hatte. Es lag weder am Server noch an fehlenden Bildern: sechzig sichtbare Kacheln teilen sich zwölf Verbindungen, und 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. Jetzt wird bis zu dreimal gefragt, mit wachsendem Abstand. Aber nur, wo es sich lohnt: ein 404 heißt, dass es das Bild nicht gibt, und dreimal zu fragen macht es nicht vorhanden. Wiederholt werden Zeitabläufe, abgerissene Verbindungen, 5xx und 429. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
6.7 KiB
Swift
167 lines
6.7 KiB
Swift
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<PlatformImage?, Never>] = [:]
|
|
|
|
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<PlatformImage?, Never> { [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? {
|
|
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<NSURL, PlatformImage>()
|
|
|
|
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
|
|
}
|
|
}
|