iOS 마케팅 캡처
개요
여러 로케일에 걸친 SwiftUI iOS 앱의 재현 가능한 마케팅 스크린샷 캡처를 자동화하며, 두 가지 병렬 출력 스트림을 제공합니다:
- 전체 화면 캡처 — 모든 마케팅 관련 화면, 결정론적 시드 데이터, 실제 상태 표시줄/안전 영역 크롬 포함
- 요소 캡처 — 특정 컴포넌트(카드, 위젯, 차트)의 독립적인 렌더링, 모든 배율에서 자연스러운 배경(둥근 모서리 내부)과 외부 투명도 처리
이 스킬은 캡처 단계입니다. 사용자가 Apple 스타일의 마케팅 페이지(기기 목업, 헤드라인, 그라데이션)를 함께 합성하려면, app-store-screenshots 스킬을 후처리 단계로 결합하세요.
핵심 접근법
앱 내부 캡처 모드, XCUITest가 아닙니다. 이는 Fastlane snapshot / XCUITest 관행을 희생하는 어려운 결정이지만, 거의 모든 실제 프로젝트에서 승리합니다.
XCUITest 대신 앱 내부를 선택하는 이유:
- 새로운 테스트 타겟이 필요 없습니다. 기존 Xcode 프로젝트에 UI 테스트 타겟을 추가하는 것은 깨지기 쉬운 pbxproj 수술입니다. 많은 프로젝트에 테스트 타겟이 없고 xcodegen도 없습니다. 수동으로 추가하는 것은 오류가 발생하기 쉽습니다.
- 더 빠른 반복. UI 테스트는 실행당 30초 이상이 소요됩니다. 앱 내부 캡처는 설치된 바이너리를 다시 실행하기만 하면 됩니다.
xcodebuild test가 필요 없습니다. 전체 흐름은xcodebuild build한 번, 그 다음 로케일별로simctl launch입니다. 테스트 번들 오버헤드가 없습니다.- 실제 앱 상태에 접근 가능. ViewModel, SwiftData, ImageRenderer,
UIWindow.drawHierarchy를 직접 호출할 수 있습니다. XCUITest는 탭하고 접근성 요소를 읽을 수만 있습니다. - 요소 렌더링은 어차피 프로세스 내에서 필요합니다. 위젯 뷰나 독립된 컴포넌트에 대한
ImageRenderer는 앱 프로세스 내에서 실행되어야 합니다. XCUITest에는 해당하는 것이 없습니다.
작동 방식:
- DEBUG 전용
MarketingCapture.swift파일이 메인 앱 타겟에 위치합니다. -MarketingCapture 1로 실행되면 앱이 데이터를 시드하고, 코디네이터가CaptureStep목록을 순회합니다. 각 단계는 탐색, 안정화 대기, 스냅샷, 정리를 수행합니다.- PNG 파일은 앱의 샌드박스
Documents/marketing/<locale>/디렉토리에 기록됩니다. - 쉘 스크립트가 한 번 빌드하고 설치한 다음,
-AppleLanguages (xx) -AppleLocale xx로 다시 실행하여 로케일을 순회하며simctl get_app_container를 통해 파일을 꺼냅니다.
프로세스
다음 단계를 순서대로 진행하세요. 건너뛰지 마십시오.
1단계: 요구사항 수집
사용자에게 한 번에 하나씩 질문하십시오(배치로 하지 마십시오. 각 답변이 이후 질문을 무효화할 수 있습니다):
- 캡처할 화면 — "어떤 화면을 캡처하시겠습니까? 각 화면의 탐색 경로 또는 탭 이름을 알려주십시오." 구체적인 목록을 받으십시오. "주요 흐름"과 같은 모호한 답변은 안 됩니다.
- 개별 요소 — "투명 배경으로 독립적으로 렌더링할 컴포넌트가 있습니까? (캐러셀 카드, 위젯, 히어로 타일, 차트 등)"
- 로케일 — "어떤 로케일을 사용하시겠습니까? (a)
Localizable.xcstrings에 있는 모든 로케일, (b) 제가 지정할 App Store 하위 집합, (c) 명시적인 목록을 알려드리겠습니다." (a)의 경우,.xcstrings파일에서 로케일 코드를 grep하십시오: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))" - 기기 — "어떤 시뮬레이터를 사용하시겠습니까? (iOS 26 디자인 기능을 위한 6.1" iPhone 17 권장)" —
xcrun simctl list devices available로 기기가 사용 가능한지 확인하십시오. - 외형 — "라이트만, 다크만, 아니면 둘 다?"
- 시드 데이터 — "현재 데모 데이터는 어떻게 채워집니까? (a) 새로 설치 시 자동 시드, (b) '데모 데이터 로드' 디버그 버튼 있음, (c) 수동으로 추가, (d) 아직 데모 데이터 없음." 그런 다음: "기존 데이터가 사용자가 나열한 모든 화면을 마케팅에 적합하게 채울 만큼 충분합니까? 사용자와 함께 감사하십시오."
2단계: 탐색
코드를 작성하기 전에, 코드베이스를 충분히 탐색하여 다음 질문에 답하십시오:
- 프로젝트가 Xcode 동기화 폴더 그룹(Xcode 16+,
PBXFileSystemSynchronizedRootGroup)을 사용합니까? 그렇다면 새 파일이 해당 타겟에 자동으로 포함됩니다. pbxproj 편집이 필요 없습니다.grep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj로 확인하십시오. - 루트 네비게이션 패턴은 무엇입니까?
TabView(selection:)— 가장 일반적.@State selectedTab바인딩, 탭 인덱스, 중첩된NavigationStack이 있는 탭이 필요합니다.NavigationStack(라우터가 있는 단일 스택) — 경로 바인딩 또는 라우터 객체,NavigationLink(value:)/.navigationDestination유형 집합이 필요합니다.NavigationSplitView— 사이드바 선택 바인딩, 세부 열의 네비게이션 상태가 필요합니다.- 사용자 정의 코디네이터/UIKit 호스트 — 코디네이터의
navigate(to:)메서드 또는 동등한 것이 필요합니다.
- 딥 링크는 어떻게 라우팅됩니까?
onOpenURL핸들러와 URL을 네비게이션 상태에 매핑하는 열거형/switch를 찾으십시오. - 데모 데이터 시더는 어디에 정의되어 있습니까? 디버그 버튼(있는 경우)에서 실제로
ModelContext에 쓰는 함수까지의 코드 경로를 추적하십시오. 시더가 없으면 아래 "데모 데이터 시더 만들기"를 참조하십시오. - 위젯이 별도의 타겟에 있습니까? 위젯 뷰 파일과 항목 유형이 메인 앱 타겟에도 있습니까? (거의 확실히 없습니다. ImageRenderer로 렌더링하려면 추가해야 합니다.)
- 앱이 Live Activities / ActivityKit을 사용합니까? 그렇다면 알려진 문제점으로 표시하십시오(아래 참조).
- 앱이 SwiftData + CloudKit 동기화(
cloudKitDatabase: .automatic)를 사용합니까? 그렇다면 알려진 문제점으로 표시하십시오. - 기본값이 아닌 상태로 캡처해야 하는 뷰가 있습니까? (예: 카운트다운 중인 타이머, 부분적으로 채워진 양식, 특정 값이 있는 차트). 그렇다면 각각
static var프라이밍 메커니즘이 필요합니다(아래 "뷰 상태 프라이밍" 참조).
3단계: 사용자에게 설계 제시
코드를 작성하기 전에, 이 구조로 계획을 요약하십시오. 진행하기 전에 명시적인 승인을 받으십시오:
- 아키텍처(앱 내부 캡처 모드, 단일 파일, DEBUG 게이트)
- 파일 목록(생성/수정할 정확한 경로)
- 화면별 캡처 계획(각 화면에 도달하는 방법 — 탭 인덱스, 탐색 경로, 시트 트리거)
- 캡처 순서 근거(어떤 화면이 다른 화면보다 먼저 와야 하는지 — 문제점 #5 참조)
- 요소 렌더링 접근법(어떤 컴포넌트, 어떻게 래핑할지)
- 출력 레이아웃(폴더 구조, 명명 규칙)
- 이 프로젝트와 관련된 알려진 문제점(2단계에서 플래그 지정됨)
- 프라이밍된 상태(어떤 뷰, 어떤 static var)
4단계: 구현
templates/ 디렉토리의 템플릿을 시작점으로 사용하십시오. 이는 참조 패턴이지, 복사-붙여넣기 스캐폴딩이 아닙니다. 모든 프로젝트는 네비게이션, 모델, 뷰가 다릅니다. 템플릿은 빌딩 블록을 보여줍니다. 대상 앱에 맞게 구성하십시오.
생성할 주요 파일:
<AppName>/Debug/MarketingCapture.swift— 전체 캡처 시스템, DEBUG 전용. 포함:MarketingCapture열거형(실행 인자 파싱, 출력 헬퍼, 윈도우 스냅샷, 프라이밍 변수)MarketingCaptureCoordinator클래스([CaptureStep]순회 및 각각 스냅샷)MarketingElementHarness열거형(카드, 위젯, 차트의 ImageRenderer 렌더링)
<AppName>/ContentView.swift(또는 루트 뷰가 있는 곳) — 데이터를 시드하고 코디네이터를 실행하는 DEBUG 훅.- 프라이밍된 상태가 필요한 모든 뷰 — DEBUG 게이트된
.onAppear훅 및.onReceive해제 리스너. scripts/capture-marketing.sh— 빌드 + 설치 + 로케일별 루프..gitignore—marketing/추가.
5단계: 반복적으로 검증
스크립트를 사용자에게 넘기고 기다리지 마십시오. 시뮬레이터에서 직접 실행하고, 하나 이상의 로케일을 완료했다고 선언하기 전에 검증하십시오. Read 도구로 출력 PNG를 읽어 각 화면이 예상대로 표시되는지 시각적으로 확인하십시오. 일반적인 런타임 문제는 아래 "알려진 문제점"에 나열되어 있습니다.
문제를 발견하면 수정하고, 전체 스크립트를 다시 실행하십시오(실패한 로케일만이 아닙니다. 수정 사항이 이전 로케일을 퇴행시킬 수 있습니다). 그리고 다시 시각적으로 검증하십시오.
아키텍처: 단계 기반 캡처
코디네이터는 CaptureStep 값 목록을 순회하여 캡처를 구동합니다. 각 단계는 자체적으로 완결됩니다. 화면으로 탐색하는 방법, 대기 시간, 사후 정리 방법을 알고 있습니다.
struct CaptureStep {
let name: String // 출력 파일명, 예: "01-home"
let navigate: @MainActor () -> Void // 앱을 올바른 상태로 설정
let settle: Duration // 애니메이션/로드 대기 시간
let cleanup: (@MainActor () -> Void)? // 다음 단계 전 정리
}
코디네이터는 간단한 루프입니다:
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)) // 정리 애니메이션
}
다양한 네비게이션 패턴에 대한 단계 구축
TabView 앱(가장 일반적):
// 간단한 탭 전환 — 인덱스만 설정
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)
// 탭 + 표시된 시트
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 + 라우터 앱:
// 스택에 경로 푸시
CaptureStep(
name: "02-detail",
navigate: { router.push(.itemDetail(item)) },
settle: .milliseconds(1800),
cleanup: { router.popToRoot() }
)
NavigationSplitView 앱:
// 사이드바 항목 선택 후 세부 항목 선택
CaptureStep(
name: "03-detail",
navigate: {
sidebarSelection = .recipes
detailSelection = recipes.first
},
settle: .milliseconds(1800),
cleanup: { detailSelection = nil }
)
순서: 스태킹 규칙
"깨끗한" 네비게이션 상태가 필요한 화면을 동일한 스택에 푸시하는 화면보다 먼저 캡처하십시오. 자식 뷰 내부의 중첩된 NavigationPath / @State는 코디네이터에서 팝할 수 없습니다. 따라서:
좋음: 선반 (깨끗한 목록) → 커피 상세 (선반 스택에 푸시)
나쁨: 커피 상세 → 선반 (스택에 상세가 여전히 푸시됨)
두 화면이 NavigationStack을 공유하는 경우, 루트 수준 뷰를 먼저 캡처하십시오.
뷰 상태 프라이밍
일부 화면은 특정 비기본 상태로 캡처해야 합니다. 예: 카운트다운 중인 타이머, 특정 값이 있는 차트, 반쯤 채워진 양식. 패턴:
-
각 프라이밍 값에 대해
MarketingCapture에static var를 추가하십시오:/// 코디네이터가 타이머 뷰를 표시하기 전에 설정합니다. /// 뷰는 .onAppear에서 이를 읽어 특정 경과 시간으로 점프합니다. static var pendingElapsedSeconds: Int? /// 타이머에 평가 오버레이를 표시하려면 true로 설정합니다. static var pendingShowAssessment: Bool = false -
대상 뷰에서 프라이밍 값을 읽는 DEBUG 게이트된
.onAppear를 추가하십시오:.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 } -
코디네이터에서 탐색 전에 변수를 설정하십시오:
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) } )
데모 데이터 시더 만들기
앱에 기존 데모 데이터 메커니즘이 없는 경우, 새로 만드십시오. <AppName>/Debug/DemoDataSeeder.swift에 배치하고 #if DEBUG로 래핑하십시오.
지침:
- 캡처하는 모든 화면이 채워진 것으로 보일 만큼 충분한 데이터를 시드하십시오. 화면 목록을 시드와 비교하여 감사하십시오.
- 현실적인 콘텐츠 사용: 실제 장소 이름, 그럴듯한 숫자, 다양한 상태(일부 항목 "재고 부족", 일부 "신선", 일부는 이미지 있음, 일부는 없음).
- 앱이 SwiftData를 사용하는 경우
ModelContext에 직접 쓰십시오. Core Data인 경우 관리 객체 컨텍스트를 사용하십시오. REST 백엔드인 경우 로컬 캐시/저장소 계층을 통해 시드하십시오. - 시드를 멱등적으로 만드십시오. 삽입 전에 데이터가 이미 있는지 확인하십시오. 저장소는 시뮬레이터 재실행 간에 유지되며, 로케일별로 다시 시드하면 CloudKit 동기화 변동 및 충돌이 발생합니다.
- 다양한 UI 상태를 채울 수 있도록 충분한 다양성을 포함하십시오. 빈 상태는 마케팅 화면이 아닌 이상 나타나지 않아야 합니다.
최소 형태:
#if DEBUG
enum DemoDataSeeder {
static func seedIfEmpty(in context: ModelContext) {
let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
guard existing == 0 else { return }
// 다양한 상태의 항목
let items = [
Item(name: "...", status: .active, ...),
Item(name: "...", status: .lowStock, ...),
// ...모든 화면을 채울 만큼 충분히
]
items.forEach { context.insert($0) }
try? context.save()
}
}
#endif
요소 렌더링
요소는 3배율로 ImageRenderer를 통해 렌더링되며, 둥근 모서리 외부는 투명하게 처리됩니다.
카드 / 목록 행
@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
// ...