Bilder ohne Warten — und der Beweis, dass der Mac die Leiste selbst zeichnet
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e5e01838f7
commit
e7de8a9055
@@ -27,7 +27,16 @@ public actor ArtworkLoader {
|
||||
public static let shared = ArtworkLoader()
|
||||
|
||||
private let session: URLSession
|
||||
private let cache = NSCache<NSURL, PlatformImage>()
|
||||
/// 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.
|
||||
@@ -49,8 +58,15 @@ public actor ArtworkLoader {
|
||||
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.object(forKey: url as NSURL) { return cached }
|
||||
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
|
||||
@@ -65,16 +81,33 @@ public actor ArtworkLoader {
|
||||
|
||||
let image = await task.value
|
||||
inFlight[url] = nil
|
||||
if let image { cache.setObject(image, forKey: url as NSURL) }
|
||||
if let image { cache.store(image, for: url) }
|
||||
return image
|
||||
}
|
||||
|
||||
/// Das Bild, wenn es schon im Arbeitsspeicher liegt.
|
||||
///
|
||||
/// Damit eine Kachel, die zum zweiten Mal erscheint, nicht erst kurz grau
|
||||
/// aufblitzt — das ist beim Scrollen der sichtbarste Unterschied.
|
||||
public func cached(_ url: URL) -> PlatformImage? {
|
||||
cache.object(forKey: url as NSURL)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,21 @@ public struct FlixyArtwork: View {
|
||||
.task(id: url) { await load() }
|
||||
}
|
||||
|
||||
/// Das Bild, das jetzt gezeigt werden kann.
|
||||
///
|
||||
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
|
||||
/// Kachel, die zum zweiten Mal erscheint, zeigt ihr Bild damit schon im
|
||||
/// ersten Bild und blitzt nicht kurz grau auf. Über ein `await` ginge das
|
||||
/// nicht — das kostet immer einen Durchlauf.
|
||||
private var available: PlatformImage? {
|
||||
if let loaded { return loaded }
|
||||
guard let url else { return nil }
|
||||
return ArtworkLoader.cachedImage(for: url)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let loaded {
|
||||
if let loaded = available {
|
||||
switch contentMode {
|
||||
case .fill: Image(platform: loaded).resizable().scaledToFill()
|
||||
case .fit: Image(platform: loaded).resizable().scaledToFit()
|
||||
@@ -70,10 +82,9 @@ public struct FlixyArtwork: View {
|
||||
return
|
||||
}
|
||||
|
||||
// Liegt es schon im Arbeitsspeicher, gibt es kein Aufblitzen: ohne
|
||||
// diesen Schritt wäre jede zurückscrollende Kachel kurz grau.
|
||||
if let cached = await ArtworkLoader.shared.cached(url) {
|
||||
loaded = cached
|
||||
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das
|
||||
// Nachladen.
|
||||
if ArtworkLoader.cachedImage(for: url) != nil {
|
||||
hasFailed = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ public struct HeroBanner<Actions: View>: View {
|
||||
@ViewBuilder
|
||||
private func titleView(scale: CGFloat) -> some View {
|
||||
Group {
|
||||
if let logo {
|
||||
if let logo = availableLogo {
|
||||
Image(platform: logo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
@@ -89,14 +89,22 @@ public struct HeroBanner<Actions: View>: View {
|
||||
}
|
||||
}
|
||||
.task(id: logoURL) {
|
||||
guard let logoURL else {
|
||||
logo = nil
|
||||
return
|
||||
}
|
||||
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
||||
logo = await ArtworkLoader.shared.image(for: logoURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
||||
///
|
||||
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
||||
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
|
||||
/// steht bis dahin der gesetzte Titel.
|
||||
private var availableLogo: PlatformImage? {
|
||||
if let logo { return logo }
|
||||
guard let logoURL else { return nil }
|
||||
return ArtworkLoader.cachedImage(for: logoURL)
|
||||
}
|
||||
|
||||
private func plainTitle(scale: CGFloat) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 34 * scale, weight: .bold))
|
||||
|
||||
Reference in New Issue
Block a user