Der Schriftzug hing weiter nicht an der Animation, und der Zwischenspeicher war nicht schuld: er lag im Actor, und ein `await` kostet immer mindestens einen Durchlauf. Das neue Bild erschien deshalb im ersten Bild ohne Schriftzug und im zweiten mit — er sprang also getrennt herein, obwohl er längst da war. Der Speicher liegt jetzt außerhalb des Actors und lässt sich beim Zeichnen lesen. Das gilt für alle Bilder: auch eine zurückscrollende Kachel blitzt nicht mehr kurz grau auf. Zur Seitenleiste auf dem Mac, gemessen statt vermutet: `automatic` löst sich laut Apples eigener Beschreibung auf iOS zu `overlap` auf (die Leiste schwebt über dem Inhalt) und auf macOS zu `tile` (sie schiebt ihn zur Seite und klebt am Fensterrand). Der Griff dorthin sitzt richtig — derselbe Weg hat die Leiste auf Kommando ganz ausgeblendet —, aber `overlap` wird auf dem Mac stillschweigend verworfen. Die Bitte bleibt trotzdem stehen; sie kostet nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
4.7 KiB
Swift
124 lines
4.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
|
|
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<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
|
|
}
|
|
}
|