iOS Marketing Capture
Resumen
Automatiza la captura reproducible de capturas de pantalla de marketing para una app iOS con SwiftUI en múltiples locales, con dos flujos de salida paralelos:
- Capturas de pantalla completa — cada pantalla relevante para marketing, con datos de semilla deterministas, status bar real y chrome de safe area
- Capturas de elementos — renders aislados de componentes específicos (tarjetas, widgets, gráficos) a cualquier escala, con fondo natural dentro de esquinas redondeadas y transparencia fuera
Esta habilidad es el paso de captura. Si el usuario también quiere páginas de marketing estilo Apple compuestas alrededor de las tomas (maquetas de dispositivos, titulares, degradados), combínala con la habilidad app-store-screenshots como paso de posprocesamiento.
Enfoque Principal
Modo de captura dentro de la aplicación, no XCUITest. Esta es una decisión difícil que se contrapone a las convenciones de Fastlane snapshot / XCUITest, y gana para casi cualquier proyecto real.
Por qué dentro de la aplicación en lugar de XCUITest:
- Sin nuevo target de prueba. Agregar un target de prueba UI a un proyecto Xcode existente es una cirugía frágil de pbxproj. Muchos proyectos tienen cero targets de prueba y ningún xcodegen — agregar uno manualmente es propenso a errores.
- Iteración más rápida. Una prueba UI tarda 30 s o más en lanzarse por ejecución. La captura dentro de la aplicación es simplemente un relanzamiento del binario instalado.
- Sin
xcodebuild test. Todo el flujo esxcodebuild builduna vez, luegosimctl launchpor localidad. Sin la sobrecarga del bundle de prueba. - Acceso al estado real de la app. Se puede llamar a ViewModels, SwiftData, ImageRenderer y
UIWindow.drawHierarchydirectamente. XCUITest solo puede tocar y leer elementos de accesibilidad. - Los renders de elementos necesitan el mismo proceso de todas formas.
ImageRendereren vistas de widget o componentes aislados debe ejecutarse dentro del proceso de la app — no hay equivalente en XCUITest.
Cómo funciona:
- Un archivo
MarketingCapture.swiftsolo para DEBUG reside en el target principal de la app - Cuando se lanza con
-MarketingCapture 1, la app siembra datos, luego un coordinador recorre una lista deCaptureSteps — cada paso navega, espera a que se estabilice, toma la captura y limpia - Los PNG se escriben en el sandbox de la app en
Documents/marketing/<locale>/ - Un script de shell compila una vez, instala, luego recorre las localidades relanzando con
-AppleLanguages (xx) -AppleLocale xx, extrayendo los archivos mediantesimctl get_app_container
Proceso
Trabaja a través de estos pasos en orden. No te saltes ninguno.
Paso 1: Recopilar requisitos
Haz al usuario estas preguntas una a la vez (no las agrupes — cada respuesta puede invalidar preguntas posteriores):
- Pantallas a capturar — "¿Qué pantallas quieres? Dame la ruta de navegación o el nombre de la pestaña para cada una." Obtén una lista concreta, no "los flujos principales".
- Elementos aislados — "¿Algún componente que quieras renderizar de forma independiente con fondos transparentes? (tarjetas de carrusel, widgets, tiles destacados, gráficos, etc.)"
- Locales — "¿Qué locales? (a) todos los locales en tu
Localizable.xcstrings, (b) un subconjunto de App Store que especificaré, o (c) déjame darte una lista explícita." Si (a), busca en el archivo.xcstringslos códigos de localidad: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))" - Dispositivo — "¿Qué simulador? (se recomienda iPhone 17 de 6.1" para funciones de diseño de iOS 26)" — verifica que el dispositivo esté disponible mediante
xcrun simctl list devices available. - Apariencia — "¿Solo modo claro, solo modo oscuro, o ambos?"
- Datos de semilla — "¿Cómo se pueblan los datos de demostración hoy? (a) la instalación nueva los siembra automáticamente, (b) hay un botón de depuración 'Cargar datos de demostración', (c) los agregas manualmente, (d) aún no existen datos de demostración." Luego: "¿Son los datos existentes lo suficientemente exhaustivos como para que cada pantalla que enumeraste se vea poblada para marketing? Audítalo con el usuario."
Paso 2: Exploración
Antes de escribir código, explora la base de código lo suficiente para responder:
- ¿Utiliza el proyecto grupos de carpetas sincronizados de Xcode (Xcode 16+,
PBXFileSystemSynchronizedRootGroup)? Si es así, los nuevos archivos se incluyen automáticamente en su target — no se necesitan ediciones de pbxproj. Verifica congrep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj. - ¿Cuál es el patrón de navegación raíz?
TabView(selection:)— el más común. Necesitas: el binding@State selectedTab, los índices de las pestañas y qué pestañas tienenNavigationStackanidado.NavigationStack(una sola pila con un router) — necesitas: el binding de la ruta o el objeto router, además del conjunto de tiposNavigationLink(value:)/.navigationDestination.NavigationSplitView— necesitas: el binding de selección de la barra lateral, el estado de navegación de la columna de detalle.- Coordinador personalizado / host UIKit — necesitas: el método
navigate(to:)del coordinador o equivalente.
- ¿Cómo se enrutan los deep links? Encuentra el manejador
onOpenURLy el enum/switch que mapea las URLs al estado de navegación. - ¿Dónde están definidos los sembradores de datos de demostración? Traza la ruta del código desde el botón de depuración (si existe) hasta la función que realmente escribe en
ModelContext. Si no existe un sembrador, consulta "Creación de un sembrador de datos de demostración" a continuación. - ¿Los widgets viven en un target separado? ¿Están los archivos de vista del widget y los tipos de entrada también en el target principal de la app? (Casi con certeza no — deben agregarse si deseas renderizarlos mediante ImageRenderer).
- ¿Utiliza la app Live Activities / ActivityKit? Si es así, márcalo como un problema conocido (ver más abajo).
- ¿Utiliza la app SwiftData + CloudKit sync (
cloudKitDatabase: .automatic)? Si es así, márcalo como un problema conocido. - ¿Necesita alguna vista ser capturada en un estado no predeterminado? (por ejemplo, un temporizador a mitad de cuenta regresiva, un formulario parcialmente lleno, un gráfico con valores específicos). Si es así, cada una necesita un mecanismo de
static varde preparación (consulta "Preparación del estado de la vista" a continuación).
Paso 3: Presentar el diseño al usuario
Antes de escribir código, resume tu plan en esta estructura. Obtén la aprobación explícita antes de continuar:
- Arquitectura (modo de captura dentro de la aplicación, archivo único, protegido por compilación condicional DEBUG)
- Lista de archivos (rutas exactas que crearás / modificarás)
- Plan de captura pantalla por pantalla (cómo se alcanza cada pantalla — índice de pestaña, ruta de navegación, disparador de hoja)
- Justificación del orden de captura (qué pantallas deben ir antes que otras — ver problema conocido #5)
- Enfoque de renderizado de elementos (qué componentes, cómo se envolverán)
- Diseño de salida (estructura de carpetas, convención de nomenclatura)
- Problemas conocidos relevantes para este proyecto (marcados en el Paso 2)
- Estados preparados necesarios (qué vistas, qué vars estáticas)
Paso 4: Implementar
Usa las plantillas en templates/ como puntos de partida. Son patrones de referencia, no andamios de copiar y pegar — cada proyecto tiene diferentes navegación, modelos y vistas. Las plantillas muestran los bloques de construcción; los compones para la app objetivo.
Archivos clave a producir:
<NombreDeLaApp>/Debug/MarketingCapture.swift— todo el sistema de captura, solo DEBUG. Contiene:- Enum
MarketingCapture(análisis de argumentos de lanzamiento, ayudantes de salida, captura de ventana, vars de preparación) - Clase
MarketingCaptureCoordinator(recorre[CaptureStep]y captura cada uno) - Enum
MarketingElementHarness(renders de ImageRenderer de tarjetas, widgets, gráficos)
- Enum
<NombreDeLaApp>/ContentView.swift(o donde sea que viva la vista raíz) — hook DEBUG que siembra datos y ejecuta el coordinador.- Cualquier vista que necesite estados preparados — hooks
.onAppearprotegidos por compilación condicional DEBUG y listeners.onReceivede descarte. scripts/capture-marketing.sh— compilar + instalar + bucle por localidad..gitignore— agregarmarketing/.
Paso 5: Verificar de forma iterativa
No entregues el script al usuario y esperes. Ejecútalo tú mismo contra un simulador y verifica al menos una localidad antes de declarar que está listo. Lee los PNG de salida con la herramienta Read para verificar visualmente que cada pantalla muestre lo que esperas. Los problemas comunes de ejecución se enumeran en "Problemas conocidos" a continuación.
Cuando encuentres un problema, arréglalo, vuelve a ejecutar todo el script (no solo la localidad que falla — las correcciones pueden regresar localidades anteriores), y vuelve a verificar visualmente.
Arquitectura: Captura Basada en Pasos
El coordinador impulsa la captura recorriendo una lista de valores CaptureStep. Cada paso es autónomo: sabe cómo navegar a su pantalla, cuánto tiempo esperar y cómo limpiar después.
struct CaptureStep {
let name: String // nombre del archivo de salida, ej. "01-home"
let navigate: @MainActor () -> Void // poner la app en el estado correcto
let settle: Duration // esperar a que terminen animaciones/cargas
let cleanup: (@MainActor () -> Void)? // limpiar antes del siguiente paso
}
El coordinador es un bucle simple:
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)) // animación de limpieza
}
Construcción de pasos para diferentes patrones de navegación
App con TabView (más común):
// Cambio de pestaña simple — solo establece el índice
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)
// Pestaña + hoja presentada
CaptureStep(
name: "05-timer-setup",
navigate: {
setTab(3)
pendingBrewRecipe = someRecipe
},
settle: .milliseconds(2000),
cleanup: {
NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
pendingBrewRecipe = nil
}
)
App con NavigationStack + router:
// Empujar una ruta en la pila
CaptureStep(
name: "02-detail",
navigate: { router.push(.itemDetail(item)) },
settle: .milliseconds(1800),
cleanup: { router.popToRoot() }
)
App con NavigationSplitView:
// Seleccionar elemento de la barra lateral, luego detalle
CaptureStep(
name: "03-detail",
navigate: {
sidebarSelection = .recipes
detailSelection = recipes.first
},
settle: .milliseconds(1800),
cleanup: { detailSelection = nil }
)
Orden: la regla de apilamiento
Captura cualquier pantalla que necesite un estado de navegación "limpio" ANTES de las pantallas que empujan en la misma pila. NavigationPath / @State anidados dentro de vistas hijas no se pueden desapilar desde el coordinador. Por lo tanto:
Bueno: Estante (lista limpia) → Detalle del Café (empuja sobre la pila del estante)
Malo: Detalle del Café → Estante (la pila todavía tiene el detalle empujado)
Si dos pantallas comparten un NavigationStack, captura primero la vista de nivel raíz.
Preparación del Estado de la Vista
Algunas pantallas necesitan ser capturadas en un estado no predeterminado específico: un temporizador a mitad de cuenta regresiva, un gráfico con valores particulares, un formulario medio lleno. El patrón:
-
Agrega un
static varaMarketingCapturepara cada valor de preparación:/// Establecido por el coordinador antes de presentar la vista del temporizador. /// La vista lo lee en .onAppear para saltar a un tiempo transcurrido específico. static var pendingElapsedSeconds: Int? /// Establecido en true para mostrar la superposición de evaluación en el temporizador. static var pendingShowAssessment: Bool = false -
En la vista objetivo, agrega un
.onAppearprotegido por compilación condicional DEBUG que lea el valor de preparación:.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 } -
En el coordinador, establece la var antes de navegar:
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) } )
Creación de un Sembrador de Datos de Demostración
Si la app no tiene un mecanismo de datos de demostración existente, crea uno. Colócalo en <NombreDeLaApp>/Debug/DemoDataSeeder.swift, envuelto en #if DEBUG.
Directrices:
- Siembra suficientes datos para que cada pantalla capturada se vea poblada. Audita la lista de pantallas contra la semilla.
- Usa contenido realista: nombres de lugares reales, números plausibles, estados variados (algunos elementos "quedan pocos", otros "frescos", algunos con imágenes, otros sin).
- Si la app usa SwiftData, escribe directamente en el
ModelContext. Si usa Core Data, usa el contexto de objetos administrados. Si usa un backend REST, siembra a través de la capa de caché/almacenamiento local. - Haz que la siembra sea idempotente — verifica si los datos ya existen antes de insertar. El almacenamiento persiste entre relanzamientos del simulador, y volver a sembrar por localidad causa problemas de sincronización de CloudKit y bloqueos.
- Incluye suficiente variedad para llenar diferentes estados de UI: los estados vacíos NO deben aparecer a menos que sean una pantalla de marketing.
Forma mínima:
#if DEBUG
enum DemoDataSeeder {
static func seedIfEmpty(in context: ModelContext) {
let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
guard existing == 0 else { return }
// Elementos con estados variados
let items = [
Item(name: "...", status: .active, ...),
Item(name: "...", status: .lowStock, ...),
// ...suficientes para llenar cada pantalla
]
items.forEach { context.insert($0) }
try? context.save()
}
}
#endif
Renderizado de Elementos
Los elementos se renderizan mediante ImageRenderer a escala 3x con transparencia fuera de las esquinas redondeadas.
Tarjetas / filas de lista
@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
…
[truncado para traducción]