Community이미지github.com

ParthJadhav/ios-marketing-capture

SwiftUI iOS 앱의 마케팅 스크린샷 캡처를 여러 로케일, 기기, 외형에서 자동화합니다. 전체 화면, 개별 요소(캐러셀 카드, 위젯) 렌더링, 재현 가능한 출력 명명을 지원합니다.

ios-marketing-capture란 무엇인가요?

이 스킬은 SwiftUI iOS 앱의 마케팅 스크린샷 캡처를 완전 자동화하는 도구입니다. 기존 Fastlane/XCUITest 방식과 달리, 앱 내부 캡처 모드를 사용하여 별도의 테스트 타겟 없이 더 빠르고 안정적으로 동작합니다. 주요 기능으로는 여러 로케일에서의 전체 화면 캡처와 개별 UI 요소(카드, 위젯, 차트)의 투명 배경 렌더링을 지원합니다. 디버그 모드에서만 활성화되는 `MarketingCapture.swift` 파일을 통해 앱의 네비게이션을 자동으로 조작하고, 결정론적 시드 데이터를 사용하여 일관된 스크린샷을 생성합니다. 최종 출력은 PNG 형식으로 기기 시뮬레이터의 샌드박스에 저장되며, 쉘 스크립트가 이를 자동으로 수집합니다. 이 스킬은 Claude Code, Cursor, Codex 등의 AI 에이전트에서 사용할 수 있도록 설계되었으며, 개발자는 사용자와의 대화형 요구사항 수집(캡처할 화면, 로케일, 기기 등)부터 코드 구현, 반복적 검증까지 전 과정을 안내받을 수 있습니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add ParthJadhav/ios-marketing-capture

Installed? Explore more 이미지 skills: steipete/songsee, affaan-m/frontend-design, mattpocock/improve-codebase-architecture · View all 6 →

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

iOS 마케팅 캡처

개요

여러 로케일에 걸친 SwiftUI iOS 앱의 재현 가능한 마케팅 스크린샷 캡처를 자동화하며, 두 가지 병렬 출력 스트림을 제공합니다:

  1. 전체 화면 캡처 — 모든 마케팅 관련 화면, 결정론적 시드 데이터, 실제 상태 표시줄/안전 영역 크롬 포함
  2. 요소 캡처 — 특정 컴포넌트(카드, 위젯, 차트)의 독립적인 렌더링, 모든 배율에서 자연스러운 배경(둥근 모서리 내부)과 외부 투명도 처리

이 스킬은 캡처 단계입니다. 사용자가 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에는 해당하는 것이 없습니다.

작동 방식:

  1. DEBUG 전용 MarketingCapture.swift 파일이 메인 앱 타겟에 위치합니다.
  2. -MarketingCapture 1로 실행되면 앱이 데이터를 시드하고, 코디네이터가 CaptureStep 목록을 순회합니다. 각 단계는 탐색, 안정화 대기, 스냅샷, 정리를 수행합니다.
  3. PNG 파일은 앱의 샌드박스 Documents/marketing/<locale>/ 디렉토리에 기록됩니다.
  4. 쉘 스크립트가 한 번 빌드하고 설치한 다음, -AppleLanguages (xx) -AppleLocale xx로 다시 실행하여 로케일을 순회하며 simctl get_app_container를 통해 파일을 꺼냅니다.

프로세스

다음 단계를 순서대로 진행하세요. 건너뛰지 마십시오.

1단계: 요구사항 수집

사용자에게 한 번에 하나씩 질문하십시오(배치로 하지 마십시오. 각 답변이 이후 질문을 무효화할 수 있습니다):

  1. 캡처할 화면 — "어떤 화면을 캡처하시겠습니까? 각 화면의 탐색 경로 또는 탭 이름을 알려주십시오." 구체적인 목록을 받으십시오. "주요 흐름"과 같은 모호한 답변은 안 됩니다.
  2. 개별 요소 — "투명 배경으로 독립적으로 렌더링할 컴포넌트가 있습니까? (캐러셀 카드, 위젯, 히어로 타일, 차트 등)"
  3. 로케일 — "어떤 로케일을 사용하시겠습니까? (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))"
    
  4. 기기 — "어떤 시뮬레이터를 사용하시겠습니까? (iOS 26 디자인 기능을 위한 6.1" iPhone 17 권장)" — xcrun simctl list devices available로 기기가 사용 가능한지 확인하십시오.
  5. 외형 — "라이트만, 다크만, 아니면 둘 다?"
  6. 시드 데이터 — "현재 데모 데이터는 어떻게 채워집니까? (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단계: 사용자에게 설계 제시

코드를 작성하기 전에, 이 구조로 계획을 요약하십시오. 진행하기 전에 명시적인 승인을 받으십시오:

  1. 아키텍처(앱 내부 캡처 모드, 단일 파일, DEBUG 게이트)
  2. 파일 목록(생성/수정할 정확한 경로)
  3. 화면별 캡처 계획(각 화면에 도달하는 방법 — 탭 인덱스, 탐색 경로, 시트 트리거)
  4. 캡처 순서 근거(어떤 화면이 다른 화면보다 먼저 와야 하는지 — 문제점 #5 참조)
  5. 요소 렌더링 접근법(어떤 컴포넌트, 어떻게 래핑할지)
  6. 출력 레이아웃(폴더 구조, 명명 규칙)
  7. 이 프로젝트와 관련된 알려진 문제점(2단계에서 플래그 지정됨)
  8. 프라이밍된 상태(어떤 뷰, 어떤 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을 공유하는 경우, 루트 수준 뷰를 먼저 캡처하십시오.

뷰 상태 프라이밍

일부 화면은 특정 비기본 상태로 캡처해야 합니다. 예: 카운트다운 중인 타이머, 특정 값이 있는 차트, 반쯤 채워진 양식. 패턴:

  1. 각 프라이밍 값에 대해 MarketingCapture에 static var를 추가하십시오:

    /// 코디네이터가 타이머 뷰를 표시하기 전에 설정합니다.
    /// 뷰는 .onAppear에서 이를 읽어 특정 경과 시간으로 점프합니다.
    static var pendingElapsedSeconds: Int?
    
    /// 타이머에 평가 오버레이를 표시하려면 true로 설정합니다.
    static var pendingShowAssessment: Bool = false
    
  2. 대상 뷰에서 프라이밍 값을 읽는 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
    }
    
  3. 코디네이터에서 탐색 전에 변수를 설정하십시오:

    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

// ...

관련 스킬