iOS Marketing Capture
Übersicht
Automatisieren Sie die reproduzierbare Erstellung von Marketing-Screenshots für eine SwiftUI iOS App über mehrere Locales hinweg, mit zwei parallelen Ausgabeströmen:
- Vollbildaufnahmen — jeder marketingrelevante Bildschirm mit deterministischen Seed-Daten, echter Statusleiste / Safe-Area-Chrome
- Elementaufnahmen — isolierte Renderings bestimmter Komponenten (Karten, Widgets, Diagramme) in beliebiger Skalierung, mit natürlichem Hintergrund innerhalb abgerundeter Ecken und Transparenz außerhalb
Dieses Skill ist der Capture-Schritt. Wenn der Benutzer auch Apple-ähnliche Marketing-Seiten wünscht, die um die Aufnahmen herum zusammengesetzt werden (Gerätemockups, Überschriften, Farbverläufe), kombinieren Sie dies mit dem app-store-screenshots-Skill als Nachbearbeitungsschritt.
Kernansatz
In-App-Capture-Modus, nicht XCUITest. Dies ist eine bewusste Entscheidung, die gegen Fastlane-Snapshot / XCUITest-Konventionen abgewogen wurde, und sie gewinnt für fast jedes reale Projekt.
Warum In-App gegenüber XCUITest:
- Kein neues Test-Target. Das Hinzufügen eines UI-Test-Targets zu einem bestehenden Xcode-Projekt ist eine fragile pbxproj-Operation. Viele Projekte haben keine Test-Targets und kein xcodegen – das manuelle Hinzufügen ist fehleranfällig.
- Schnellere Iteration. Ein UI-Test benötigt 30s+ pro Start. In-App-Capture ist lediglich ein Neustart der installierten Binärdatei.
- Kein
xcodebuild test. Der gesamte Ablauf ist einmalxcodebuild build, dannsimctl launchpro Locale. Kein Test-Bundle-Overhead. - Zugriff auf echten App-Zustand. Sie können ViewModels, SwiftData, ImageRenderer und
UIWindow.drawHierarchydirekt aufrufen. XCUITest kann nur tippen und Barrierefreiheitselemente lesen. - Element-Renderings benötigen ohnehin In-Process-Zugriff.
ImageRendererfür Widget-Ansichten oder isolierte Komponenten muss innerhalb des App-Prozesses ausgeführt werden – es gibt kein XCUITest-Äquivalent.
So funktioniert es:
- Eine DEBUG-exklusive Datei
MarketingCapture.swiftbefindet sich im Haupt-App-Target - Wenn die App mit
-MarketingCapture 1gestartet wird, werden Seed-Daten gesetzt, dann durchläuft ein Koordinator eine Liste vonCaptureSteps – jeder Schritt navigiert, wartet auf Beruhigung, erstellt einen Screenshot und räumt auf - PNGs werden in das Sandbox-Verzeichnis
Documents/marketing/<locale>/der App geschrieben - Ein Shell-Skript baut einmal, installiert, durchläuft dann die Locales durch Neustart mit
-AppleLanguages (xx) -AppleLocale xxund extrahiert die Dateien übersimctl get_app_container
Ablauf
Arbeiten Sie diese Schritte in der angegebenen Reihenfolge ab. Überspringen Sie keine Schritte.
Schritt 1: Anforderungen sammeln
Stellen Sie dem Benutzer diese Fragen einzeln (nicht gebündelt – jede Antwort kann spätere Fragen ungültig machen):
- Aufzunehmende Bildschirme — "Welche Bildschirme möchten Sie? Geben Sie für jeden den Navigationspfad oder den Tab-Namen an." Holen Sie eine konkrete Liste ein, nicht "die Hauptabläufe".
- Isolierte Elemente — "Gibt es Komponenten, die Sie unabhängig mit transparentem Hintergrund rendern möchten? (Karussellkarten, Widgets, Hero-Kacheln, Diagramme usw.)"
- Locales — "Welche Locales? (a) alle Locales in Ihrer
Localizable.xcstrings, (b) eine von mir zu spezifizierende App Store-Teilmenge, oder (c) ich gebe Ihnen eine explizite Liste." Wenn (a), durchsuchen Sie die.xcstrings-Datei nach Locale-Codes:python3 -c "import json; d=json.load(open('<path>/Localizable.xcstrings')); langs=set(); [langs.update(v.get('localizations',{}).keys()) for v in d['strings'].values()]; print(sorted(langs))" - Gerät — "Welcher Simulator? (6,1" iPhone 17 empfohlen für iOS 26 Design-Features)" – Überprüfen Sie, ob das Gerät über
xcrun simctl list devices availableverfügbar ist. - Darstellung — "Nur hell, nur dunkel oder beides?"
- Seed-Daten — "Wie werden Demodaten derzeit befüllt? (a) Frischinstallation seedet automatisch, (b) es gibt einen Debug-Button 'Demo-Daten laden', (c) Sie fügen sie manuell hinzu, (d) es existieren noch keine Demo-Daten." Dann: "Sind die vorhandenen Daten umfassend genug, dass jeder von Ihnen aufgelistete Bildschirm für Marketing befüllt aussieht? Prüfen Sie dies mit dem Benutzer."
Schritt 2: Erkundung
Bevor Sie Code schreiben, erkunden Sie die Codebasis ausreichend, um Folgendes zu beantworten:
- Verwendet das Projekt Xcode synchronisierte Ordner-Gruppen (Xcode 16+,
PBXFileSystemSynchronizedRootGroup)? Wenn ja, werden neue Dateien automatisch in ihr Target aufgenommen – keine pbxproj-Bearbeitung erforderlich. Überprüfen Sie mitgrep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj. - Was ist das grundlegende Navigationsmuster?
TabView(selection:)– am häufigsten. Sie benötigen: die@State selectedTab-Bindung, Tab-Indizes und welche Tabs verschachtelteNavigationStacks haben.NavigationStack(einzelner Stack mit einem Router) – Sie benötigen: die Pfadbindung oder das Router-Objekt sowie die Menge derNavigationLink(value:)/.navigationDestination-Typen.NavigationSplitView– Sie benötigen: die Seitenleisten-Auswahlbindung, den Navigationsstatus der Detailspalte.- Benutzerdefinierter Koordinator / UIKit-Host – Sie benötigen: die
navigate(to:)-Methode des Koordinators oder ein Äquivalent.
- Wie werden Deep Links weitergeleitet? Finden Sie den
onOpenURL-Handler und das Enum/Switch, das URLs mit Navigationszuständen verknüpft. - Wo sind Demodaten-Seeder definiert? Verfolgen Sie den Codepfad vom Debug-Button (falls vorhanden) zur Funktion, die tatsächlich in den
ModelContextschreibt. Wenn kein Seeder existiert, siehe "Erstellen eines Demo-Daten-Seeders" unten. - Leben Widgets in einem separaten Target? Sind die Widget-View-Dateien und Entry-Typen auch im Haupt-App-Target? (Höchstwahrscheinlich nein – sie müssen hinzugefügt werden, wenn Sie sie über ImageRenderer rendern möchten.)
- Verwendet die App Live Activities / ActivityKit? Wenn ja, markieren Sie dies als bekannte Hürde (siehe unten).
- Verwendet die App SwiftData + CloudKit Sync (
cloudKitDatabase: .automatic)? Wenn ja, markieren Sie dies als bekannte Hürde. - Muss eine Ansicht in einem vom Standard abweichenden Zustand erfasst werden? (z. B. ein Timer während des Countdowns, ein teilweise ausgefülltes Formular, ein Diagramm mit bestimmten Werten). Wenn ja, benötigt jede einen
static var-Mechanismus zum Vorbereiten (siehe "View-Zustand vorbereiten" unten).
Schritt 3: Design dem Benutzer vorstellen
Fassen Sie Ihren Plan in dieser Struktur zusammen. Holen Sie vor der Implementierung explizite Zustimmung ein:
- Architektur (In-App-Capture-Modus, einzelne Datei, DEBUG-gesteuert)
- Dateiliste (genaue Pfade, die Sie erstellen / ändern werden)
- Bildschirm-für-Bildschirm-Capture-Plan (wie jeder Bildschirm erreicht wird – Tab-Index, Navigationspfad, Sheet-Trigger)
- Begründung der Capture-Reihenfolge (welche Bildschirme müssen vor anderen kommen – siehe Hürde Nr. 5)
- Ansatz zum Rendern von Elementen (welche Komponenten, wie sie eingebettet werden)
- Ausgabelayout (Ordnerstruktur, Namenskonvention)
- Bekannte Hürden, die für dieses Projekt relevant sind (aus Schritt 2 markiert)
- Vorbereitete Zustände (welche Ansichten, welche statischen Variablen)
Schritt 4: Implementieren
Verwenden Sie die Vorlagen in templates/ als Ausgangspunkte. Sie sind Referenzmuster, kein Kopier-Einfüge-Gerüst – jedes Projekt hat andere Navigation, Modelle und Ansichten. Die Vorlagen zeigen die Bausteine; Sie setzen sie für die Ziel-App zusammen.
Wichtige zu erstellende Dateien:
<AppName>/Debug/MarketingCapture.swift– das gesamte Capture-System, DEBUG-exklusiv. Enthält:MarketingCapture-Enum (Launch-Argument-Parsing, Ausgabe-Helfer, Fenster-Screenshot, Vorbereitungsvariablen)MarketingCaptureCoordinator-Klasse (durchläuft[CaptureStep]und erstellt für jeden einen Screenshot)MarketingElementHarness-Enum (ImageRenderer-Renderings von Karten, Widgets, Diagrammen)
<AppName>/ContentView.swift(oder wo die Root-View lebt) – DEBUG-Hook, der Daten seedet und den Koordinator ausführt.- Alle Ansichten, die vorbereitete Zustände benötigen – DEBUG-gesteuerte
.onAppear-Hooks und.onReceive-Dismiss-Listener. scripts/capture-marketing.sh– Build + Install + Locale-Schleife..gitignore–marketing/hinzufügen.
Schritt 5: Iterativ verifizieren
Übergeben Sie das Skript nicht an den Benutzer und warten Sie. Führen Sie es selbst gegen einen Simulator aus und verifizieren Sie mindestens ein Locale, bevor Sie es als fertig erklären. Lesen Sie die ausgegebenen PNGs mit dem Lese-Tool, um visuell zu überprüfen, ob jeder Bildschirm Ihren Erwartungen entspricht. Häufige Laufzeitprobleme sind in "Bekannte Hürden" unten aufgeführt.
Wenn Sie ein Problem finden, beheben Sie es, führen Sie das gesamte Skript erneut aus (nicht nur das fehlschlagende Locale – Korrekturen können frühere Locales beeinträchtigen) und überprüfen Sie visuell erneut.
Architektur: Schrittbasierte Aufnahme
Der Koordinator steuert die Aufnahme, indem er eine Liste von CaptureStep-Werten durchläuft. Jeder Schritt ist in sich abgeschlossen: Er weiß, wie er zu seinem Bildschirm navigiert, wie lange er warten muss und wie er danach aufräumt.
struct CaptureStep {
let name: String // Ausgabedateiname, z.B. "01-home"
let navigate: @MainActor () -> Void // App in den richtigen Zustand versetzen
let settle: Duration // Warten auf Animationen/Ladevorgänge
let cleanup: (@MainActor () -> Void)? // Aufräumen vor dem nächsten Schritt
}
Der Koordinator ist eine einfache Schleife:
for step in steps {
step.navigate()
try? await Task.sleep(for: step.settle)
if let image = MarketingCapture.snapshotKeyWindow() {
MarketingCapture.writePNG(image, name: step.name)
}
step.cleanup?()
try? await Task.sleep(for: .milliseconds(400)) // Aufräum-Animation
}
Schritte für verschiedene Navigationsmuster erstellen
TabView-App (am häufigsten):
// Einfacher Tab-Wechsel – nur den Index setzen
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)
// Tab + angezeigtes Sheet
CaptureStep(
name: "05-timer-setup",
navigate: {
setTab(3)
pendingBrewRecipe = someRecipe
},
settle: .milliseconds(2000),
cleanup: {
NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
pendingBrewRecipe = nil
}
)
NavigationStack + Router-App:
// Eine Route auf den Stack schieben
CaptureStep(
name: "02-detail",
navigate: { router.push(.itemDetail(item)) },
settle: .milliseconds(1800),
cleanup: { router.popToRoot() }
)
NavigationSplitView-App:
// Seitenleisten-Element auswählen, dann Detail
CaptureStep(
name: "03-detail",
navigate: {
sidebarSelection = .recipes
detailSelection = recipes.first
},
settle: .milliseconds(1800),
cleanup: { detailSelection = nil }
)
Reihenfolge: Die Stapelregel
Erfassen Sie Bildschirme, die einen "sauberen" Navigationszustand benötigen, VOR Bildschirmen, die auf denselben Stapel schieben. Verschachtelte NavigationPath / @State innerhalb von Kindansichten können vom Koordinator nicht zurückgesetzt werden. Also:
Gut: Regal (saubere Liste) → Kaffee-Detail (schiebt auf das Stapel des Regals)
Schlecht: Kaffee-Detail → Regal (Stapel hat immer noch das Detail drauf)
Wenn zwei Bildschirme einen gemeinsamen NavigationStack verwenden, erfassen Sie zuerst die Ansicht auf der Stammebene.
View-Zustand vorbereiten
Einige Bildschirme müssen in einem bestimmten, vom Standard abweichenden Zustand erfasst werden – ein Timer während des Countdowns, ein Diagramm mit bestimmten Werten, ein halb ausgefülltes Formular. Das Muster:
-
Fügen Sie eine
static varzuMarketingCapturefür jeden vorzubereitenden Wert hinzu:/// Vom Koordinator gesetzt, bevor die Timer-Ansicht angezeigt wird. /// Die Ansicht liest dies in .onAppear, um zu einer bestimmten verstrichenen Zeit zu springen. static var pendingElapsedSeconds: Int? /// Auf true gesetzt, um den Bewertungs-Overlay auf dem Timer anzuzeigen. static var pendingShowAssessment: Bool = false -
Fügen Sie in der Zielansicht einen DEBUG-gesteuerten
.onAppear-Hook hinzu, der den Vorbereitungswert liest:.onAppear { #if DEBUG if MarketingCapture.isActive, let elapsed = MarketingCapture.pendingElapsedSeconds { phase = .active timerVM.elapsedTime = TimeInterval(elapsed) timerVM.start() DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { timerVM.pause() } } #endif } -
Setzen Sie im Koordinator die Variable vor der Navigation:
CaptureStep( name: "06-timer-midway", navigate: { MarketingCapture.pendingElapsedSeconds = 75 openTimerSheet(someRecipe) }, settle: .milliseconds(2400), cleanup: { MarketingCapture.pendingElapsedSeconds = nil NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil) } )
Erstellen eines Demo-Daten-Seeders
Wenn die App keinen bestehenden Mechanismus für Demo-Daten hat, erstellen Sie einen. Platzieren Sie ihn in <AppName>/Debug/DemoDataSeeder.swift, eingeschlossen in #if DEBUG.
Richtlinien:
- Seedet genügend Daten, dass jeder erfasste Bildschirm befüllt aussieht. Prüfen Sie die Liste der Bildschirme gegen den Seed.
- Verwenden Sie realistische Inhalte: echte Ortsnamen, plausible Zahlen, unterschiedliche Zustände (einige Artikel "zur Neige gehend", einige "frisch", einige mit Bildern, einige ohne).
- Wenn die App SwiftData verwendet, schreiben Sie direkt in den
ModelContext. Bei Core Data verwenden Sie den Managed-Object-Context. Bei einem REST-Backend seeden Sie über die lokale Cache/Speicherschicht. - Machen Sie das Seeden idempotent – prüfen Sie, ob bereits Daten vorhanden sind, bevor Sie einfügen. Der Speicher bleibt über Simulator-Neustarts hinweg erhalten und das erneute Seed pro Locale verursacht CloudKit-Synchronisationschurn und Abstürze.
- Fügen Sie genug Vielfalt hinzu, um verschiedene UI-Zustände zu füllen: Leere Zustände sollten NICHT erscheinen, es sei denn, sie sind ein Marketing-Bildschirm.
Minimales Beispiel:
#if DEBUG
enum DemoDataSeeder {
static func seedIfEmpty(in context: ModelContext) {
let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
guard existing == 0 else { return }
// Artikel mit verschiedenen Zuständen
let items = [
Item(name: "...", status: .active, ...),
Item(name: "...", status: .lowStock, ...),
// ...genug, um jeden Bildschirm zu füllen
]
items.forEach { context.insert($0) }
try? context.save()
}
}
#endif
Element-Rendering
Elemente werden über ImageRenderer im 3-fachen Maßstab mit Transparenz außerhalb abgerundeter Ecken gerendert.
Karten / Listenzeilen
@MainActor
static func renderCards(items: [Item], theme: AppTheme) {
let cardWidth: CGFloat = 380
for item in items {
let card = ItemCard(item: item, theme: theme)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(width: cardWidth)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
let renderer = ImageRenderer(content: card)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: cardWidth, height: ...)
// ...weitere Logik
}
}
Widgets
Standard-Widget-Background (benötigt App-Erweiterung im Haupt-Target):
// Widget in einer View einbetten, die den ContainerBackground API verwendet
// (Nur iOS 26+ für Drittanbieter-Widgets)
Bildschirmaufnahme
Vollbildaufnahmen werden mit UIWindow.drawHierarchy erstellt:
static func snapshotKeyWindow() -> UIImage? {
guard let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.windows
.first(where: \.isKeyWindow) else { return nil }
let format = UIGraphicsImageRendererFormat()
format.scale = 1
format.opaque = true
let renderer = UIGraphicsImageRenderer(bounds: window.bounds, format: format)
return renderer.image { ctx in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
}
Ausgabe-Pipeline
Der Ausgabeordner ist Documents/marketing/<locale>/ im Sandbox der App. Dateien heißen <name>.png. Nach der Aufnahme extrahiert das Shell-Skript sie:
xcrun simctl get_app_container <device> <bundle-id> data
Dann kopiert es den gesamten Ordner Documents/marketing an einem definierten Ausgabeort.
Shell-Skript
Das Skript scripts/capture-marketing.sh:
- Baut das App-Target mit
xcodebuild build(Debug-Schema) - Installiert den Build auf dem Simulator (
xcrun simctl install) - Für jedes Locale:
- Startet die App mit
-AppleLanguages (xx) -AppleLocale xx -MarketingCapture 1 - Wartet auf Abschluss des Capture-Durchlaufs
- Extrahiert Ausgabedateien aus dem Sandbox
- Startet die App mit
- Kombiniert alle Locales im Ausgabeordner
Git-Ignorieren
Fügen Sie marketing/ zu Ihrer .gitignore hinzu, um generierte Screenshots aus der Versionskontrolle auszuschließen.
Bekannte Hürden
1. SwiftData + CloudKit Sync
Wenn die App cloudKitDatabase: .automatic verwendet, verursacht jede ModelContext.save()-Operation einen Netzwerk- und Synchronisationsaufwand. Bei mehreren Locales kann dies die App verlangsamen oder einfrieren (CloudKit-Warteschlange wächst). Lösung: Vor dem Capture-Durchlauf die CloudKit-Synchronisation deaktivieren:
// In der App-Umgebung, falls verfügbar
UserDefaults.standard.set(false, forKey: "CloudKitSyncEnabled")
Alternativ Modell-Container nur für die Sitzung local-only konfigurieren.
2. ActivityKit / Live Activities
Live Activities können nicht in einem Simulator ausgeführt werden. Wenn die App eine aktive Live Activity benötigt, um den Bildschirm korrekt darzustellen, muss der Benutzer auf einem physischen Gerät testen. Das Skript kann dies nicht automatisieren.
3. Widget-Rendering erfordert zusätzliches Target
Wenn Widgets in einem separaten Widget-Extension-Target leben, sind die SwiftUI-Views und Entry-Strukturen für das Haupt-App-Target nicht sichtbar. Lösung: Verschieben Sie die Widget-View-Dateien und Entry-Modelle in das Haupt-Target (wählen Sie in Xcode das Haupt-Target in der Dateiinspektor aus). Dies bricht das Widget nicht, solange die Dateien in beiden Targets bleiben.
4. Core Data + CloudKit Sync
Ähnlich wie SwiftData+CloudKit. Verwenden Sie die Option NSPersistentCloudKitContainer.Options zum Deaktivieren während der Aufnahmesitzung.
5. Stapelverschmutzung bei nicht bereinigten Ansichten
Jeder CaptureStep sollte seinen eigenen Zustand bereinigen. Wenn ein Schritt vergisst, einen Sheet zu schließen (cleanup: { dismissSheet() }) oder eine Route nicht poppt, wird der nächste Schritt in einem verschmutzten Navigationszustand starten. Lösung: Erfassen Sie immer die übergeordnete Ansicht vor untergeordneten Ansichten auf demselben NavigationStack. Verwenden Sie DispatchQueue.main.async für Bereinigungen, die nach Abschluss der Animation ausgeführt werden müssen.
6. Statusleistenüberlagerung
Für Aufnahmen in voller Bildschirmgröße (ohne Safe Area) sollten Sie die Statusleiste im Simulator ausblenden (xcrun simctl status_bar <device> override --time "9:41" --batteryState charged --batteryLevel 100), um konsistente Aufnahmen zu gewährleisten. Fügen Sie dies als optionalen Schritt im Shell-Skript hinzu.
Fehlerbehebung
| Problem | Mögliche Ursache | Lösung |
|---|---|---|
| Screenshot ist schwarz | Fenster nicht bereit nach Navigation | settle-Dauer erhöhen (≥2s) |
| Screenshot zeigt leere Liste | Seed-Daten wurden nicht gefüllt | Prüfen, ob seedIfEmpty() in .task aufgerufen wird |
| App stürzt ab beim Launch | Launch-Argument falsch geparst | #if DEBUG um Capture-Logik prüfen |
| PNG-Datei beschädigt | Simulator ausgelastet | Render.afterScreenUpdates auf true setzen |
simctl kann Container nicht finden | Bundle-ID nicht korrekt oder Gerät nicht verfügbar | Überprüfen mit xcrun simctl list devices |
Siehe auch SwiftUI Rendering Guide für ImageRenderer-spezifische Einschränkungen.