iOS Marketing Capture
Aperçu
Automatisez la capture reproductible de captures d'écran marketing pour une application iOS SwiftUI sur plusieurs locales, avec deux flux de sortie parallèles :
- Captures plein écran — chaque écran pertinent pour le marketing, avec des données déterministes préparées, une barre d'état réelle et le chrome de la zone de sécurité
- Captures d'éléments — rendus isolés de composants spécifiques (cartes, widgets, graphiques) à n'importe quelle échelle, avec un arrière-plan naturel à l'intérieur des coins arrondis et une transparence à l'extérieur
Cette compétence est l'étape de capture. Si l'utilisateur souhaite également des pages marketing de style Apple composées autour des captures (maquettes d'appareils, titres, dégradés), combinez-la avec la compétence app-store-screenshots en tant qu'étape de post-traitement.
Approche principale
Mode de capture intégré à l'application, pas XCUITest. C'est une décision difficile qui fait un compromis avec les conventions Fastlane snapshot / XCUITest, et elle gagne pour presque tous les projets réels.
Pourquoi intégré à l'application plutôt que XCUITest :
- Pas de nouvelle cible de test. Ajouter une cible de test UI à un projet Xcode existant est une manipulation fragile du pbxproj. De nombreux projets n'ont aucune cible de test et pas de xcodegen — en ajouter une à la main est sujet aux erreurs.
- Itération plus rapide. Un test UI prend 30s+ pour se lancer par exécution. La capture intégrée à l'application n'est qu'un relancement du binaire installé.
- Pas de
xcodebuild test. Tout le flux estxcodebuild buildune fois, puissimctl launchpar locale. Pas de surcharge liée au bundle de test. - Accès à l'état réel de l'application. Vous pouvez appeler des ViewModels, SwiftData, ImageRenderer et
UIWindow.drawHierarchydirectement. XCUITest ne peut que taper et lire les éléments d'accessibilité. - Les rendus d'éléments ont besoin du processus interne de toute façon.
ImageRenderersur les vues de widget ou les composants isolés doit s'exécuter dans le processus de l'application — il n'y a pas d'équivalent XCUITest.
Comment ça marche :
- Un fichier
MarketingCapture.swiftréservé au mode DEBUG se trouve dans la cible principale de l'application - Lorsqu'il est lancé avec
-MarketingCapture 1, l'application prépare les données, puis un coordinateur parcourt une liste deCaptureSteps — chaque étape navigue, attend que l'interface se stabilise, prend une capture et nettoie - Les PNG sont écrits dans le répertoire sandbox de l'application
Documents/marketing/<locale>/ - Un script shell construit une fois, installe, puis boucle sur les locales en relançant avec
-AppleLanguages (xx) -AppleLocale xx, en extrayant les fichiers viasimctl get_app_container
Processus
Suivez ces étapes dans l'ordre. Ne sautez pas à l'avance.
Étape 1 : Recueillir les exigences
Posez ces questions à l'utilisateur une par une (ne les regroupez pas — chaque réponse peut invalider les questions suivantes) :
- Écrans à capturer — « Quels écrans souhaitez-vous ? Donnez-moi le chemin de navigation ou le nom de l'onglet pour chacun. » Obtenez une liste concrète, pas « les flux principaux ».
- Éléments isolés — « Des composants que vous souhaitez rendre indépendamment avec des fonds transparents ? (cartes de carrousel, widgets, tuiles hero, graphiques, etc.) »
- Locales — « Quelles locales ? (a) toutes les locales de votre
Localizable.xcstrings, (b) un sous-ensemble App Store que je spécifierai, ou (c) laissez-moi vous donner une liste explicite. » Si (a), utiliser grep sur le fichier.xcstringspour les codes de locale :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))" - Appareil — « Quel simulateur ? (iPhone 17 6,1" recommandé pour les fonctionnalités de conception iOS 26) » — vérifiez que l'appareil est disponible via
xcrun simctl list devices available. - Apparence — « Uniquement le mode clair, uniquement le mode sombre, ou les deux ? »
- Données de démonstration — « Comment les données de démonstration sont-elles peuplées aujourd'hui ? (a) une nouvelle installation les intègre automatiquement, (b) il y a un bouton de débogage 'Charger les données de démonstration', (c) vous les ajoutez manuellement, (d) aucune donnée de démonstration n'existe encore. » Ensuite : « Les données existantes sont-elles suffisamment exhaustives pour que chaque écran que vous avez listé paraisse peuplé pour le marketing ? Auditez-les avec l'utilisateur. »
Étape 2 : Exploration
Avant d'écrire du code, explorez suffisamment le code source pour répondre à :
- Le projet utilise-t-il les groupes de dossiers synchronisés Xcode (Xcode 16+,
PBXFileSystemSynchronizedRootGroup) ? Si oui, les nouveaux fichiers sont automatiquement inclus dans leur cible — pas besoin de modifications du pbxproj. Vérifiez avecgrep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj. - Quel est le modèle de navigation racine ?
TabView(selection:)— le plus courant. Vous avez besoin : de la liaison@State selectedTab, des indices des onglets et des onglets qui ont desNavigationStackimbriqués.NavigationStack(pile unique avec un routeur) — vous avez besoin : de la liaison du chemin ou de l'objet routeur, ainsi que de l'ensemble des typesNavigationLink(value:)/.navigationDestination.NavigationSplitView— vous avez besoin : de la liaison de sélection de la barre latérale, de l'état de navigation de la colonne de détail.- Coordinateur personnalisé / hôte UIKit — vous avez besoin : de la méthode
navigate(to:)du coordinateur ou d'un équivalent.
- Comment les liens profonds sont-ils routés ? Trouvez le gestionnaire
onOpenURLet l'enum/switch qui mappe les URL à l'état de navigation. - Où sont définis les injecteurs de données de démonstration ? Tracez le chemin du code depuis le bouton de débogage (le cas échéant) jusqu'à la fonction qui écrit réellement dans
ModelContext. Si aucun injecteur n'existe, voir « Création d'un injecteur de données de démonstration » ci-dessous. - Les widgets vivent-ils dans une cible séparée ? Les fichiers de vue des widgets et les types d'entrée se trouvent-ils également dans la cible principale de l'application ? (Presque certainement non — ils doivent être ajoutés si vous souhaitez les rendre via ImageRenderer.)
- L'application utilise-t-elle les Live Activities / ActivityKit ? Si oui, signalez-le comme un piège connu (voir ci-dessous).
- L'application utilise-t-elle SwiftData + CloudKit sync (
cloudKitDatabase: .automatic) ? Si oui, signalez-le comme un piège connu. - Une vue doit-elle être capturée dans un état non par défaut ? (par exemple un minuteur en cours de décompte, un formulaire partiellement rempli, un graphique avec des valeurs spécifiques). Si oui, chacun a besoin d'un mécanisme d'amorçage via
static var(voir « Amorçage de l'état de la vue » ci-dessous).
Étape 3 : Présenter la conception à l'utilisateur
Avant d'écrire du code, résumez votre plan dans cette structure. Obtenez une approbation explicite avant de continuer :
- Architecture (mode de capture intégré à l'application, fichier unique, protégé par DEBUG)
- Liste des fichiers (chemins exacts que vous allez créer / modifier)
- Plan de capture écran par écran (comment chaque écran est atteint — index d'onglet, chemin de navigation, déclencheur de feuille)
- Justification de l'ordre de capture (quels écrans doivent venir avant d'autres — voir piège n°5)
- Approche de rendu des éléments (quels composants, comment ils seront encapsulés)
- Disposition de sortie (structure de dossiers, convention de nommage)
- Pièges connus pertinents pour ce projet (signalés à l'étape 2)
- États amorcés nécessaires (quelles vues, quelles variables statiques)
Étape 4 : Implémenter
Utilisez les modèles dans templates/ comme points de départ. Ce sont des schémas de référence, pas un échafaudage copier-coller — chaque projet a une navigation, des modèles et des vues différents. Les modèles montrent les blocs de construction ; vous les composez pour l'application cible.
Fichiers clés à produire :
<AppName>/Debug/MarketingCapture.swift— tout le système de capture, réservé au mode DEBUG. Contient :MarketingCaptureenum (analyse des arguments de lancement, assistants de sortie, capture d'écran de la fenêtre, variables d'amorçage)MarketingCaptureCoordinatorclass (parcourt[CaptureStep]et capture chacun)MarketingElementHarnessenum (rendus ImageRenderer de cartes, widgets, graphiques)
<AppName>/ContentView.swift(ou l'endroit où se trouve la vue racine) — hook DEBUG qui intègre les données et exécute le coordinateur.- Toutes les vues qui nécessitent des états amorcés — hooks
.onAppearprotégés par DEBUG et écouteurs.onReceivede rejet. scripts/capture-marketing.sh— construction + installation + boucle par locale..gitignore— ajoutezmarketing/.
Étape 5 : Vérifier itérativement
Ne remettez pas le script à l'utilisateur en attendant. Exécutez-le vous-même contre un simulateur et vérifiez au moins une locale avant de déclarer la tâche terminée. Lisez les PNG de sortie avec l'outil Read pour vérifier visuellement que chaque écran montre ce que vous attendez. Les problèmes d'exécution courants sont listés dans « Pièges connus » ci-dessous.
Lorsque vous trouvez un problème, corrigez-le, réexécutez tout le script (pas seulement la locale qui échoue — les correctifs peuvent régresser les locales précédentes) et revérifiez visuellement.
Architecture : Capture par étapes
Le coordinateur pilote la capture en parcourant une liste de valeurs CaptureStep. Chaque étape est autonome : elle sait comment naviguer vers son écran, combien de temps attendre et comment nettoyer après elle.
struct CaptureStep {
let name: String // nom du fichier de sortie, par exemple "01-home"
let navigate: @MainActor () -> Void // mettre l'application dans le bon état
let settle: Duration // attendre les animations/chargements
let cleanup: (@MainActor () -> Void)? // nettoyer avant l'étape suivante
}
Le coordinateur est une simple boucle :
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)) // animation de nettoyage
}
Construction d'étapes pour différents modèles de navigation
Application TabView (la plus courante) :
// Changement d'onglet simple — définissez simplement l'index
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)
// Onglet + feuille présentée
CaptureStep(
name: "05-timer-setup",
navigate: {
setTab(3)
pendingBrewRecipe = someRecipe
},
settle: .milliseconds(2000),
cleanup: {
NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
pendingBrewRecipe = nil
}
)
Application NavigationStack + routeur :
// Pousser une route sur la pile
CaptureStep(
name: "02-detail",
navigate: { router.push(.itemDetail(item)) },
settle: .milliseconds(1800),
cleanup: { router.popToRoot() }
)
Application NavigationSplitView :
// Sélectionner un élément de la barre latérale, puis le détail
CaptureStep(
name: "03-detail",
navigate: {
sidebarSelection = .recipes
detailSelection = recipes.first
},
settle: .milliseconds(1800),
cleanup: { detailSelection = nil }
)
Ordre : la règle d'empilement
Capturez tout écran qui a besoin d'un état de navigation « propre » AVANT les écrans qui poussent sur la même pile. Les NavigationPath / @State imbriqués à l'intérieur des vues enfants ne peuvent pas être dépilés depuis le coordinateur. Ainsi :
Bon : Étagère (liste propre) → Détail du café (pousse sur la pile de l'étagère)
Mauvais : Détail du café → Étagère (la pile a toujours le détail poussé)
Si deux écrans partagent un NavigationStack, capturez d'abord la vue de niveau racine.
Amorçage de l'état de la vue
Certains écrans doivent être capturés dans un état non par défaut spécifique — un minuteur en cours de décompte, un graphique avec des valeurs particulières, un formulaire à moitié rempli. Le modèle :
-
Ajoutez une
static varàMarketingCapturepour chaque valeur d'amorçage :/// Défini par le coordinateur avant de présenter la vue du minuteur. /// La vue lit ceci dans .onAppear pour sauter à un temps écoulé spécifique. static var pendingElapsedSeconds: Int? /// Défini sur true pour afficher la superposition d'évaluation sur le minuteur. static var pendingShowAssessment: Bool = false -
Dans la vue cible, ajoutez un
.onAppearprotégé par DEBUG qui lit la valeur d'amorçage :.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 } -
Dans le coordinateur, définissez la variable avant de naviguer :
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) } )
Création d'un injecteur de données de démonstration
Si l'application n'a pas de mécanisme de données de démonstration existant, créez-en un. Placez-le dans <AppName>/Debug/DemoDataSeeder.swift, encapsulé dans #if DEBUG.
Directives :
- Injectez suffisamment de données pour que chaque écran capturé paraisse peuplé. Auditez la liste des écrans par rapport à l'injection.
- Utilisez un contenu réaliste : de vrais noms de lieux, des nombres plausibles, des états variés (certains articles « en rupture de stock », d'autres « frais », certains avec des images, d'autres sans).
- Si l'application utilise SwiftData, écrivez directement dans le
ModelContext. Si Core Data, utilisez le contexte d'objet géré. Si un backend REST, injectez via la couche de cache/magasin local. - Rendez l'injection idempotente — vérifiez si les données existent déjà avant d'insérer. Le magasin persiste entre les relancements du simulateur, et la réinjection par locale provoque des à-coups de synchronisation CloudKit et des crashs.
- Incluez suffisamment de variété pour remplir différents états d'interface : les états vides ne doivent PAS apparaître à moins qu'ils ne soient un écran marketing.
Forme minimale :
#if DEBUG
enum DemoDataSeeder {
static func seedIfEmpty(in context: ModelContext) {
let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
guard existing == 0 else { return }
// Articles avec des états variés
let items = [
Item(name: "...", status: .active, ...),
Item(name: "...", status: .lowStock, ...),
// ...assez pour remplir chaque écran
]
items.forEach { context.insert($0) }
try? context.save()
}
}
#endif
Rendu d'éléments
Les éléments sont rendus via ImageRenderer à l'échelle 3x avec transparence à l'extérieur des coins arrondis.
Cartes / lignes de liste
@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: cardHeightFromContext)
let cgImage = renderer.cgImage
// ... découper les coins arrondis si nécessaire
}
}
Widgets
Pour rendre les widgets, vous aurez besoin des fichiers de vue du widget et des types d'entrée dans la cible principale de l'application. Si ce n'est pas le cas, ajoutez-les là. Le rendu des widgets piloté par SwiftUI peut être effectué en créant une instance de la vue widget avec une entrée factice et en la rendant via ImageRenderer.
Graphiques
Les graphiques peuvent être rendus avec des données factices, en s'assurant que la vue du graphique est configurée avec des valeurs spécifiques. Utilisez le mécanisme d'amorçage (pendingChartData) pour injecter des données factices avant de rendre la vue du graphique.
Script de capture
Créez scripts/capture-marketing.sh :
#!/bin/bash
set -euo pipefail
DEVICE="iPhone 17"
LOCALES=("en" "fr" "de" "ja")
APP_BUNDLE_ID="com.example.app"
# Construire une fois
xcodebuild build -scheme "YourApp" -destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
# Lancer le simulateur
xcrun simctl boot "$DEVICE" || true
xcrun simctl install booted "build/Debug-iphonesimulator/YourApp.app"
for locale in "${LOCALES[@]}"; do
echo "Capturing locale: $locale"
xcrun simctl launch booted "$APP_BUNDLE_ID" -MarketingCapture 1 -AppleLanguages "($locale)" -AppleLocale "$locale"
sleep 10 # attendre la fin de la capture
xcrun simctl get_app_container booted "$APP_BUNDLE_ID" data | xargs -I{} cp -R "{}/Documents/marketing/$locale" "output/$locale"
done
Pièges connus
- SwiftData + CloudKit Sync : Les appels répétés à
context.save()lors de la préparation des données peuvent déclencher des cycles de synchronisation CloudKit, provoquant des délais d'attente et des crashs. Utilisezcontext.save()uniquement après avoir inséré toutes les données, ou injectez de manière conditionnelle uniquement lorsque les données sont vides. Dans le pire des cas, envisagez de désactiver CloudKit pour la construction de capture de débogage. - Live Activities : Les Live Activities en cours d'exécution ne peuvent pas être facilement rejetées à partir du code de débogage. Si l'application a des activités actives, elles peuvent apparaître dans l'îlot dynamique ou sur l'écran de verrouillage sur les captures d'écran. Définissez un indicateur de débogage pour les désactiver.
- Pile de navigation instable : Si vous utilisez
NavigationStackavec.navigationDestination, assurez-vous que la vue de détail est dépilée correctement. Sinon, la pile peut se corrompre. - Animations : Les animations SwiftUI peuvent interférer avec les captures d'écran. Utilisez
.animation(.none)ou désactivez les animations globalement pendant la capture. - Contenu scrollé : Pour les listes ou les vues qui ne sont pas entièrement visibles, vous devrez peut-être faire défiler avant de capturer. Envisagez d'utiliser
UIScrollViewouScrollViewReaderpour défiler vers le bas. - Capture de feuille : L'ouverture d'une feuille peut ne pas être instantanée ; le coordinateur doit attendre que la feuille soit présentée avant de capturer.
- Changement de locale : Le changement de locale via les arguments de lancement fonctionne de manière fiable, mais assurez-vous que l'application gère la mise à jour de la locale sans crash. Testez chaque locale individuellement.