iOS 营销截图捕获
概述
为 SwiftUI iOS 应用跨多个语言环境自动化可复现的营销截图捕获,具有两个并行的输出流:
- 全屏捕获 — 每一个与营销相关的屏幕,使用确定的种子数据、真实的状态栏/安全区域边框
- 元素捕获 — 特定组件(卡片、小组件、图表)的独立渲染,在圆角矩形内使用自然背景,外部保持透明
此技能是捕获步骤。如果用户还希望将截图合成到 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 替代方案。
工作原理:
- 一个仅用于 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),通过 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))" - 设备 — “哪个模拟器?(推荐使用 6.1" iPhone 17 以获得 iOS 26 的设计特性)” — 通过
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 标记)
- 所需预置状态(哪些视图,哪些静态变量)
步骤 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? /// 设置此项以在计时器上显示评估覆盖层。 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
元素渲染
元素通过 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
…
[已截断以进行翻译]