Community图像github.com

ParthJadhav/ios-marketing-capture

用于自动化捕获 SwiftUI iOS 应用的多语言、多设备和外观的市场营销截图。支持全屏截图、独立元素渲染(轮播卡片、小组件)以及可复现的输出命名。

ios-marketing-capture 是什么?

iOS Marketing Capture 是一个 AI 代理技能(适用于 Claude Code、Cursor 等),旨在简化 SwiftUI iOS 应用为 App Store 制作营销截图的流程。它通过在应用内实现捕获模式(而非 XCUITest),支持跨多个语言环境、设备和外观的自动化截图。核心功能包括全屏截图和独立元素渲染(如轮播卡片、图表和小组件),后者支持透明背景,非常适合营销素材。该技能会生成一个可重复的执行脚本,并提供一个分步工作流:收集需求、探索代码库、设计方案、实现捕获逻辑(利用 DEBUG 门控的 MarketingCapture.swift 和协调器),以及迭代验证输出 PNG。关键决策是避免使用 XCUITest,因为其测试目标脆弱、迭代缓慢,且无法访问真实应用状态;采用轻量级方法,只需构建一次应用,然后通过 simctl 在不同语言环境下重新启动。它提供了导航模式(TabView、NavigationStack、NavigationSplitView)、视图状态预置、示例数据种子和已知问题处理的模板,使其对于追求高质量自动化营销截图的开发者而言高度稳健且省时。

兼容平台~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 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

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。没有测试包的开销。
  • 访问真实的应用状态。 你可以直接调用 ViewModels、SwiftData、ImageRenderer 和 UIWindow.drawHierarchy。XCUITest 只能点击和读取辅助功能元素。
  • 元素渲染需要在进程内进行。 对小组件视图或独立组件进行 ImageRenderer 操作必须在应用进程内部运行 — 没有对应的 XCUITest 替代方案。

工作原理:

  1. 一个仅用于 DEBUG 的 MarketingCapture.swift 文件位于主应用目标中
  2. 当通过 -MarketingCapture 1 启动时,应用会植入数据,然后一个协调器遍历一个 CaptureStep 列表 — 每个步骤导航、等待稳定、截图并清理
  3. PNG 文件写入应用沙盒的 Documents/marketing/<locale>/ 目录
  4. 一个 shell 脚本构建一次,安装,然后通过 -AppleLanguages (xx) -AppleLocale xx 重新启动来循环语言环境,通过 simctl get_app_container 拉取文件

流程

按顺序执行这些步骤。不要跳过。

步骤 1:收集需求

一次一个地向用户提出这些问题(不要批量提问 — 每个答案都可能使后面的问题失效):

  1. 要捕获的屏幕 — “你想要哪些屏幕?请告诉我导航路径或每个屏幕对应的标签页名称。” 获取一个具体的列表,而不是“主要流程”。
  2. 独立元素 — “哪些组件需要以透明背景独立渲染?(轮播卡片、小组件、英雄磁贴、图表等)”
  3. 语言环境 — “哪些语言环境?(a) Localizable.xcstrings 中的所有语言环境,(b) 我将指定的 App Store 子集,或 (c) 让我给出明确列表。” 如果选择 (a),通过 grep 从 .xcstrings 文件中提取语言环境代码:
    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. 设备 — “哪个模拟器?(推荐使用 6.1" iPhone 17 以获得 iOS 26 的设计特性)” — 通过 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. 所需预置状态(哪些视图,哪些静态变量)

步骤 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?
    
    /// 设置此项以在计时器上显示评估覆盖层。
    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

元素渲染

元素通过 ImageRenderer 以 3 倍比例渲染,在圆角矩形外部保持透明。

卡片/列表行

@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

…

[已截断以进行翻译]

相关技能