关于 ios-marketing-capture
面向 SwiftUI iOS 应用的营销截图采集技能,可按多个语言环境生成可复现的全屏截图和独立组件渲染。它采用应用内捕获流程,并通过模拟器逐语言环境构建、启动和导出图像。
主要功能
- 01按语言环境采集全屏截图
- 02以确定性种子数据填充界面
- 03独立渲染卡片、组件和图表
- 04通过步骤协调导航、等待与截图
- 05构建并导出多语言截图
适用场景
- 01准备不同语言版本的应用商店截图
- 02制作透明背景的独立组件图像
- 03为营销页面提供一致的应用画面
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ parthjadhav/ ios-marketing-capture --skill ios-marketing-capture安装后在 Claude Code 中输入/ios-marketing-capture确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
LICENSECONTRIBUTING.mdREADME.md.github/FUNDING.ymlpull_request_template.md.github/ISSUE_TEMPLATE/feature_request.ymlbug_report.ymlconfig.ymltemplates/capture-marketing.sh.templateMarketingCapture.swift.template详细介绍
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>/目录 - Shell 脚本只构建一次并安装,然后通过使用
-AppleLanguages (xx) -AppleLocale xx重新启动应用来遍历各语言环境,最后通过simctl get_app_container拉取文件
流程
按顺序完成以下步骤。不要跳过。
步骤 1:收集需求
每次只向用户询问以下问题中的一个(不要一次性全部提问——每个答案都可能影响后续问题):
- 要采集的屏幕 — “你想采集哪些屏幕?请提供每个屏幕的导航路径或标签名称。” 获取明确的列表,不要只写“主要流程”。
- 独立元素 — “有没有希望单独渲染并使用透明背景的组件?(轮播卡片、小组件、主视觉图块、图表等)”
- 语言环境 — “需要哪些语言环境?(a)
Localizable.xcstrings中的全部语言环境,(b) 我指定的 App Store 子集,还是 (c) 由我提供明确列表?”如果选择 (a),请在.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))" - 设备 — “要使用哪个模拟器?(推荐使用 6.1" iPhone 17,以支持 iOS 26 的设计特性)”通过
xcrun simctl list devices available确认设备可用。 - 外观 — “仅浅色、仅深色,还是两者都要?”
- 种子数据 — “目前如何填充演示数据?(a) 全新安装时自动填充,(b) 有调试用的‘Load Demo Data’按钮,(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 步中标记)
- 需要预置的状态(哪些视图,需要哪些静态变量)
第 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 // output filename, e.g. "01-home"
let navigate: @MainActor () -> Void // put the app in the right state
let settle: Duration // wait for animations/loads
let cleanup: (@MainActor () -> Void)? // tear down before next step
}
协调器是一个简单的循环:
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)) // cleanup animation
}
为不同的导航模式构建步骤
TabView 应用(最常见):
// Simple tab switch — just set the index
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)
// Tab + presented sheet
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 + router 应用:
// Push a route onto the stack
CaptureStep(
name: "02-detail",
navigate: { router.push(.itemDetail(item)) },
settle: .milliseconds(1800),
cleanup: { router.popToRoot() }
)
NavigationSplitView 应用:
// Select sidebar item, then detail
CaptureStep(
name: "03-detail",
navigate: {
sidebarSelection = .recipes
detailSelection = recipes.first
},
settle: .milliseconds(1800),
cleanup: { detailSelection = nil }
)
排序:堆栈规则
需要“干净”导航状态的屏幕,必须在会向同一堆栈推入页面的屏幕之前捕获。 子视图中的嵌套 NavigationPath / @State 无法由协调器弹出。因此:
Good: Shelf (clean list) → Coffee Detail (pushes onto shelf's stack)
Bad: Coffee Detail → Shelf (stack still has detail pushed)
如果两个屏幕共享一个 NavigationStack,请先捕获根视图。
准备视图状态
有些屏幕需要以特定的非默认状态进行捕获——例如倒计时中的计时器、显示特定数值的图表、填写了一半的表单。操作方式如下:
-
在
MarketingCapture中为每个准备值添加一个static var:/// Set by the coordinator before presenting the timer view. /// The view reads this in .onAppear to jump to a specific elapsed time. static var pendingElapsedSeconds: Int? /// Set to true to show the assessment overlay on the timer. 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 }
// Items with varied states
let items = [
Item(name: "...", status: .active, ...),
Item(name: "...", status: .lowStock, ...),
// ...enough to fill every screen
]
items.forEach { context.insert($0) }
try? context.save()
}
}
#endif
元素渲染
元素通过 ImageRenderer 以 3x 比例渲染,圆角之外的区域保持透明。
卡片/列表行
@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: nil)
guard let image = renderer.uiImage else { continue }
MarketingCapture.writePNG(image, name: "card-\(slugify(item.name))", subfolder: "elements")
}
}
小组件
小组件视图需要特殊处理,因为它们通常在 WidgetKit 进程中运行,并依赖系统提供的内边距和背景。
@MainActor
static func renderWidget(
name: String,
size: CGSize,
cornerRadius: CGFloat? = nil,
@ViewBuilder content: () -> some View
) {
let isAccessory = size.height <= 80
let radius = cornerRadius ?? (isAccessory ? 8 : 22)
let contentPadding: CGFloat = isAccessory ? 0 : 16
let view = content()
.padding(contentPadding)
.frame(width: size.width, height: size.height)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
.environment(\.colorScheme, .light)
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: size.width, height: size.height)
guard let image = renderer.uiImage else { return }
MarketingCapture.writePNG(image, name: name, subfolder: "elements")
}
// Standard iPhone widget sizes (points, iPhone 14-17 size class)
enum WidgetSize {
static let small = CGSize(width: 170, height: 170)
static let medium = CGSize(width: 364, height: 170)
static let large = CGSize(width: 364, height: 382)
static let accessoryCircular = CGSize(width: 76, height: 76)
static let accessoryRectangular = CGSize(width: 172, height: 76)
static let accessoryInline = CGSize(width: 257, height: 26)
}
// Usage:
renderWidget(name: "widget-pulse-small", size: WidgetSize.small) {
PulseSmallView(entry: PulseEntry(
date: Date(),
count: 2,
streak: 5,
lastItemName: "Morning Routine"
))
}
图表/独立视图
任何 SwiftUI 视图都可以渲染为元素。按相同方式封装——指定明确的尺寸、背景和圆角裁剪:
@MainActor
static func renderChart() {
let chart = MyChartView(values: ChartData.sample)
.frame(width: 420, height: 420)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
let renderer = ImageRenderer(content: chart)
renderer.scale = 3
renderer.isOpaque = false
renderer.proposedSize = .init(width: 420, height: 420)
guard let image = renderer.uiImage else { return }
MarketingCapture.writePNG(image, name: "chart-overview", subfolder: "elements")
}
已知注意事项
这些都是曾在真实项目中造成影响的实际 bug。请务必认真对待。
1. 实时活动会在应用启动后继续存在
ActivityKit 实时活动在进程终止后仍会持续存在。如果应用在捕获期间启动了实时活动(例如通过计时器的 start()),那么下一个语言区域重新启动应用时就会继承该活动。如果此时重新填充数据时删除了过期实时活动所引用的模型,就会触发 SwiftData 持久化属性断言。
修复方法:在营销捕获代码块的最开头调用 <ActivityManager>.shared.endImmediately(),先于任何数据操作。此外,在捕获模式下,于视图的 onDisappear 中调用 timerVM.stop()(或其他能正确结束实时活动的方法)。
2. 不要在每种语言区域下都重新播种
按语言区域为 SwiftData + CloudKit 播种会导致同步频繁变动和崩溃。SwiftData 存储会在重新启动后保留数据——这些数据是与语言区域无关的演示内容,因此只需在首次运行时播种一次,后续运行时跳过:
contentVM.fetchItems()
if contentVM.allItems.isEmpty {
DemoDataSeeder.seedIfEmpty(in: modelContext)
contentVM.fetchItems()
}
3. 在播种前完成设置的 ViewModel 会持有过期快照
如果根视图的 onAppear 在营销内容播种前调用 someVM.setup(modelContext:),VM 就会持有空存储的快照。播种后,对每个需要使用数据的 VM 调用 someVM.refresh()(或等效的 fetch 方法)。
4. 将触发绑定设为 nil 并不会关闭工作表
如果父视图通过 .fullScreenCover(item: $request) 展示内容,而 request 由内部的 @State 驱动,那么设置 触发器 绑定(例如 pendingItem = nil)不会对封面视图产生任何影响。封面视图仍会显示,下一张截图就会捕获它,而不是你导航到的屏幕。
解决方法:通过 NotificationCenter 发送关闭信号,并让展示的视图监听该信号:
// MarketingCapture.swift
static let dismissSheetNotification = Notification.Name("MarketingCapture.dismissSheet")
// In presented view body
.onReceive(NotificationCenter.default.publisher(for: MarketingCapture.dismissSheetNotification)) { _ in
dismiss()
}
然后在步骤的 cleanup 中发送通知,并至少留出 900ms,等待封面视图的动画完成,再开始下一步。
5. 无法从外部弹出 NavigationPath
如果子视图持有 @State private var navigationPath = NavigationPath(),并且深度链接向其中推入页面,协调器就无法访问它并弹出页面。解决方案:重新排列捕获顺序,将会向导航栈推入页面的屏幕,放到需要干净导航栈的屏幕之后。例如:先捕获 Shelf,再进入 Coffee Detail——不要反过来。
6. Widget 视图通常只存在于扩展目标中
如果用户的 widget 视图只位于 widget 扩展目标中,就无法在主应用目标的 MarketingCapture.swift 中引用它们。你需要:
- (a) 将 widget 视图文件(以及它们的入口类型和所有共享辅助项)加入主应用目标。如果项目使用同步文件夹组,就需要编辑
PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions。**重要注意:membershipExceptions是包含列表,而非排除列表。**列在其中的文件是目标的成员,而不是从目标中排除的文件。编辑前请仔细阅读并确认。 - (b) 在捕获流程中跳过 widget 渲染,让用户手动完成。
你还需要从主应用目标中排除 <App>WidgetBundle.swift(它含有 @main,会与应用的 @main 冲突)。
7. ImageRenderer + ProgressView(value:total:) = 禁止符号
如果没有显式指定样式,通过 ImageRenderer 合成时,确定进度的 ProgressView 会渲染成带斜杠的红色圆圈。解决方法:在 ProgressView 上使用 .progressViewStyle(.linear)。这不会影响正常渲染,但能修复渲染问题。
8. .containerBackground(for: .widget) 在 widget 上下文之外不起作用
在应用中通过 ImageRenderer 渲染 widget 视图时,其 .containerBackground 不起作用——widget 的背景是透明的,内容以外的像素会直接裸露出来。你必须为 widget 渲染添加显式背景颜色,并裁剪为圆角矩形:
content()
.padding(16) // widget container normally provides this
.frame(width: size.width, height: size.height)
.background(theme.background)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
iPhone 主屏幕 widget 的圆角半径:约 22pt。锁定屏幕配件的圆角半径:约 8pt。
9. iOS 26 中已不再提供 iPhone 8 Plus
如果用户要求使用“6.5 英寸 iPhone”(旧版 App Store 尺寸),请注意,iOS 26 及更高版本的模拟器不包含 iPhone 8 Plus / iPhone 11 Pro Max。可选方案:(a) 通过 Xcode > Settings > Platforms 安装较旧的 iOS 运行时,或 (b) 对于 iOS 26 的设计功能,改用现代的 6.1 英寸机型,例如 iPhone 17。
10. 语言区域启动参数
每次 simctl launch 都传入 -AppleLanguages (xx) -AppleLocale xx。语言代码两侧的括号是必需的(它是 plist 数组字面量)。使用 Locale.current.language.languageCode?.identifier 进行文件夹命名——它比 Locale.current.identifier 更可靠,因为后者可能包含 en_US 这类地区后缀。
11. ImageRenderer 中的 SwiftUI 动画
ImageRenderer 只会捕获单帧,不会等待动画。如果组件包含 .onAppear 动画(例如图表绘制、数字递增),渲染时可能会捕获初始状态。可以在捕获模式下禁用动画,或在渲染前添加明确的延迟:
try? await Task.sleep(for: .milliseconds(500)) // let onAppear animations finish
let renderer = ImageRenderer(content: view)
输出布局
marketing/
<locale>/ e.g. en, de, es, fr, ja
01-home.png
02-<screen>.png
...
NN-<screen>.png
elements/
card-<name>.png
widget-<family>-<size>.png
chart-<name>.png
将 marketing/ 添加到 .gitignore。这些是输出文件,不是源文件。
验证清单
在确认捕获流程完成之前,请验证:
- 所有语言区域都生成了 N 个文件(其中 N = 屏幕数 + 元素数)
- 各语言区域的文件大小不同(这可以确认翻译确实已渲染——如果
en/settings.png和de/settings.png的字节完全相同,说明语言区域切换未生效) - 查看主要语言区域的 2-3 个屏幕,并确认显示的内容符合预期
- 查看至少一个其他语言区域的相同屏幕,并确认其中包含本地化字符串
- 查看至少一个小组件渲染结果和一个卡片渲染结果,确认背景和圆角显示正常
- 没有截图显示来自不同步骤的屏幕(最常见的错误——上一步中的工作表未关闭)
模板
templates/MarketingCapture.swift.template— 带步骤协调器的捕获文件框架。请参考本技能正文中的模式。templates/capture-marketing.sh.template— Shell 脚本框架。为每个项目替换 bundle ID、scheme 名称和模拟器名称。