Die fremde Info.plist war es, nicht der Build-Ordner
Beide App-Ziele lesen denselben Ordner ein. XcodeGen nimmt nur die eigene Info.plist aus den Quellen heraus — die des jeweils anderen Ziels bleibt drin und landet unverarbeitet als Ressource im Bündel, mitsamt dem wörtlichen $(PRODUCT_BUNDLE_IDENTIFIER). Wer die Datei danach liest, liest die falsche: codesign stempelte Identifier=$(PRODUCT_BUNDLE_IDENTIFIER) auf die App, und das iPad lehnte sie als „not a valid bundle" ab. „Clean Build Folder" konnte daran nichts ändern — der Rat war falsch, der Fehler kam aus project.yml. Dazu: Bilder laden jetzt über ArtworkLoader statt AsyncImage. AsyncImage lädt bei jedem Erscheinen neu und kennt keinen Zwischenspeicher; bei sechzig Kacheln plus einem großen Aufmacher stauen sich die Anfragen hinter sechs Verbindungen. Auf dem iPad fiel das nicht auf, auf dem Mac blieb der Aufmacher grau. Und die beiden #if os(macOS) um die Breite der Seitenleiste liefen unter Catalyst ins Leere — dort ist os(iOS) wahr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
39d9138911
commit
7faa16be9c
@@ -248,7 +248,7 @@ struct RootView: View {
|
||||
.padding(.horizontal, FlixySpacing.snug)
|
||||
// Der Griff, an dem die Spalte hängt: dieser Inhalt sitzt in
|
||||
// der Seitenleiste, also nimmt sie seine Breitenangabe an.
|
||||
#if os(macOS)
|
||||
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||
.navigationSplitViewColumnWidth(
|
||||
min: Self.sidebarWidth.min,
|
||||
ideal: Self.sidebarWidth.ideal,
|
||||
@@ -286,7 +286,7 @@ struct RootView: View {
|
||||
/// abgeschnitten wird; **ohne Obergrenze** ließ sie sich über das halbe
|
||||
/// Fenster ziehen. Beide Zahlen sind nötig, und die obere entspricht dem,
|
||||
/// was eine Seitenleiste auf dem iPad breit ist.
|
||||
#if os(macOS)
|
||||
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||
private static let sidebarWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat) =
|
||||
(min: 190, ideal: 230, max: 320)
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
private let cache = NSCache<NSURL, PlatformImage>()
|
||||
/// 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
|
||||
}
|
||||
|
||||
public func image(for url: URL) async -> PlatformImage? {
|
||||
if let cached = cache.object(forKey: url as NSURL) { 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.setObject(image, forKey: url as NSURL) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import SwiftUI
|
||||
|
||||
/// Ein Bild vom Server mit Lade- und Fehlerzustand.
|
||||
///
|
||||
/// Kapselt `AsyncImage`, damit der Austausch gegen einen eigenen, zwischen-
|
||||
/// speichernden Lader später genau eine Datei betrifft. Die Startseite zeigt
|
||||
/// dutzende Bilder gleichzeitig; ohne Zwischenspeicher lädt `AsyncImage` sie
|
||||
/// bei jedem Erscheinen neu.
|
||||
/// Der Austausch, den die frühere Notiz hier angekündigt hat, ist vollzogen:
|
||||
/// statt `AsyncImage` lädt `ArtworkLoader`. Der Grund stand schon da — eine
|
||||
/// Startseite zeigt dutzende Bilder gleichzeitig, und `AsyncImage` lädt sie bei
|
||||
/// jedem Erscheinen neu. Auf dem Mac wurde daraus ein sichtbarer Fehler: der
|
||||
/// Aufmacher und die Hälfte der Plakate blieben grau.
|
||||
public struct FlixyArtwork: View {
|
||||
private let url: URL?
|
||||
private let aspectRatio: CGFloat
|
||||
@@ -30,31 +31,62 @@ public struct FlixyArtwork: View {
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
@State private var loaded: PlatformImage?
|
||||
@State private var hasFailed = false
|
||||
|
||||
public var body: some View {
|
||||
Rectangle()
|
||||
.fill(Color.flixyPlaceholder)
|
||||
.aspectRatio(aspectRatio, contentMode: .fit)
|
||||
.overlay {
|
||||
AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.2))) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
switch contentMode {
|
||||
case .fill: image.resizable().scaledToFill()
|
||||
case .fit: image.resizable().scaledToFit()
|
||||
content
|
||||
}
|
||||
case .failure:
|
||||
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln
|
||||
// wäre das mehr Lärm als Hilfe. Das Symbol sagt genug.
|
||||
.clipped()
|
||||
.task(id: url) { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let loaded {
|
||||
switch contentMode {
|
||||
case .fill: Image(platform: loaded).resizable().scaledToFill()
|
||||
case .fit: Image(platform: loaded).resizable().scaledToFit()
|
||||
}
|
||||
} else if hasFailed {
|
||||
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln wäre das
|
||||
// mehr Lärm als Hilfe. Das Symbol sagt genug.
|
||||
Image(systemName: fallbackSymbol)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
case .empty:
|
||||
ShimmerPlaceholder()
|
||||
@unknown default:
|
||||
} else {
|
||||
ShimmerPlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let url else {
|
||||
hasFailed = false
|
||||
loaded = nil
|
||||
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
|
||||
hasFailed = false
|
||||
return
|
||||
}
|
||||
|
||||
loaded = nil
|
||||
hasFailed = false
|
||||
|
||||
let image = await ArtworkLoader.shared.image(for: url)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
loaded = image
|
||||
hasFailed = image == nil
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -34,9 +34,18 @@ targets:
|
||||
# Ausschluss läuft XcodeGen hinein und trägt icon.json und play.svg
|
||||
# einzeln ein — dann fehlt das Icon, ohne dass irgendwo ein Fehler
|
||||
# auftaucht.
|
||||
#
|
||||
# Die Info.plist des jeweils anderen Ziels muss ebenfalls hinaus.
|
||||
# XcodeGen nimmt nur die eigene automatisch heraus; die fremde landet
|
||||
# als gewöhnliche Ressource unverarbeitet im Bündel — mitsamt dem
|
||||
# wörtlichen $(PRODUCT_BUNDLE_IDENTIFIER). Beim Installieren liest das
|
||||
# Gerät dann diese statt der richtigen und lehnt die App als „not a
|
||||
# valid bundle" ab.
|
||||
- path: Flixy
|
||||
excludes:
|
||||
- "AppIcon.icon"
|
||||
- "Info-macOS.plist"
|
||||
- "Flixy-macCatalyst.entitlements"
|
||||
- path: Flixy/AppIcon.icon
|
||||
type: file
|
||||
dependencies:
|
||||
@@ -164,9 +173,12 @@ targets:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
# Siehe Flixy: umgekehrt genauso, sonst trägt das Mac-Bündel die
|
||||
# Info.plist des iPhones in seinen Ressourcen.
|
||||
- path: Flixy
|
||||
excludes:
|
||||
- "AppIcon.icon"
|
||||
- "Info.plist"
|
||||
- path: Flixy/AppIcon.icon
|
||||
type: file
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user