Files
ScarriffleandClaude Opus 5 79d7d04583 Ein Download sah aus wie eine Videodatei, nicht wie ein Film
Vier Dinge, die beim Ausprobieren auffielen.

**Die Plakate blieben grau.** Sie lagen auf der Platte, der Lader wollte
sie aber nicht: er verlangte von jeder Antwort eine HTTPURLResponse, und
eine Datei antwortet nicht so. Jede lokale Adresse kam als `nil` zurück.

**Eine Serie hatte ohnehin nie ein Plakat.** Heruntergeladen wird nie die
Serie, nur ihre Folgen — und deren Bild ist das Szenenbild, quer. Jetzt
kommt das Serienplakat unter dem Serienschlüssel mit und geht mit der
letzten Folge wieder.

**Zwanzig Folgen waren zwanzig Kacheln.** Jetzt steht die Serie einmal
da, darunter wie viele Folgen bereitliegen; welche genau, zeigt ihre
Seite — auch ohne Server, dann aus dem, was auf dem Gerät liegt.

**Das Bild wurde schwarz, sobald jemand auf „Bildfüllend" wechselte.**
Daran war ein eigener ViewModifier schuld, der zwischen
`ignoresSafeArea()` und gar nichts umschaltete. Genau das ist in SwiftUI
ein Zweig: der Baum darunter wurde neu gebaut, die Bildebene mit ihm.
`edges: []` ignoriert nichts und bleibt doch derselbe Modifikator.

Dazu „Als Nächstes": die Reihe begann am Anfang der Serie statt dort, wo
der Benutzer steht. Wer bei Folge 2 war und Folge 1 nie abgehakt hatte,
bekam Folge 1 vorgeschlagen — und das Abhaken half nicht, weil die
Kachel sich nur selbst einen Haken malte, statt zu verschwinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:20:27 +02:00

180 lines
7.3 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? {
// **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<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
}
}