移动/游戏
SwiftUI iOS 应用多语言营销截图与组件 PNG…
当需要为 SwiftUI iOS 应用批量生成多语言 App Store 营销截图,并单独渲染卡片、小组件、图表为 PNG 时使用。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 38/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 55/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 60/100 ×0.15
适用场景
SwiftUI iOS 应用多语言营销截特定
解决思路
- 安装技能可使用 `npx skills add ParthJadhav/ios-marketing-capture`;全局安装加 `-g`,指定 agent 安装加 `-a claude-code`;手动安装用 `git clone https://github.com/ParthJadhav/ios-marketing-capture ~/.claude/skills/ios-marketing-capture`。
- 开始前确认:Xcode 16+、iOS 17+ deployment target、匹配目标 iOS 版本的 simulator runtime、Python 3(用于 shell script 中 JSON parsing)、SwiftUI 且已有明确导航结构。
- 选择 in-app capture 而不是 XCUITest/Fastlane:无需 test target,避免脆弱 pbxproj 编辑;可直接访问 ViewModels、SwiftData、ImageRenderer、UIWindow.drawHierarchy;只需一次 `xcodebuild build`,然后每个 locale 用 `simctl launch`;元素渲染必须在 app process 内跑 `ImageRenderer`。
- 为每张截图实现自包含 `CaptureStep`:包含 `name`(文件名,如 `01-home`)、`navigate`(`@MainActor () -> Void`,让 app 进入正确状态)、`settle`(等待动画的 `Duration`)、`cleanup`(可选,下一步前拆除状态)。
- 收集需求:要截图的屏幕/精确 tab 名或导航路径;要单独渲染的元素(卡片、小组件、图表);locales(显式列表或 xcstrings 中全部);设备(如 iPhone 17);外观(light/dark/两者);seed demo data 要求。
- 创建文件结构:`YourApp/Debug/MarketingCapture.swift`(DEBUG-only 捕获系统)、修改 `ContentView.swift` 加 DEBUG hook、`scripts/capture-marketing.sh`(build + locale loop);输出到 `marketing/<locale>/`,元素到 `marketing/<locale>/elements/`。
- 实现核心捕获系统:用 `#if DEBUG` 包裹,`import SwiftUI`;定义 `CaptureStep`;定义 `@Observable final class MarketingCaptureCoordinator`,含 `isCapturing`、`currentStep`、`steps`、`outputDir`、`elementsDir`;init 中在 Documents 上级创建 `marketing/<locale>` 和 `elements` 目录。
- 实现 `startCapture()`:`@MainActor`,设置 `isCapturing = true`、`currentStep = 0`;遍历 steps:`step.navigate()`;`try? await Task.sleep(for: step.settle)`;`captureWindow(name:)`;`step.cleanup?()`;`currentStep += 1`;结束后 `isCapturing = false`,打印完成路径,`exit(0)`。
- 实现 `captureWindow(name:)`:从 `UIApplication.shared.connectedScenes` 取第一个 `UIWindowScene` 的第一个 window;用 `UIGraphicsImageRenderer(bounds: window.bounds)`;在 renderer 中 `window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)`;把 `image.pngData()` 写到 `outputDir/<name>.png`。
- 实现 `MarketingElementHarness.renderElement`:用 `ImageRenderer(content: content().frame(width: width).background(background).clipShape(RoundedRectangle(cornerRadius: cornerRadius)))`;设置 `renderer.scale = 3.0`;取 `renderer.uiImage`;在 `marketing/<locale>/elements` 创建目录;把 `uiImage.pngData()` 写为 `<name>.png`。
- 实现 TabView 导航:用 `@Observable final class AppCoordinator` 管理 `selectedTab`,提供 `setTab(_:)`;在 `makeSteps(coordinator:)` 中为每个 tab 创建 `CaptureStep`,例如 `01-home` navigate `coordinator.setTab(0)`,settle `.seconds(0.5)`,cleanup nil;`02-shelf` navigate `coordinator.setTab(1)`。
- 实现 NavigationStack with Router:用 `@Observable final class Router` 管理 `path: [Route]`,提供 `push(_:)` 和 `popToRoot()`;步骤中先 `router.popToRoot()` 再 `router.push(.coffeeDetail(coffee))`,settle `.seconds(0.8)`,cleanup `router.popToRoot()`。
- 实现 NavigationSplitView:用 `@Observable final class SplitCoordinator` 管理 `sidebarSelection`、`detailSelection`;步骤中设置 `sidebarSelection = .settings`、`detailSelection = nil`,settle `.seconds(0.6)`。
- 实现 demo data seeding:`#if DEBUG @MainActor func seedMarketingData(modelContext:)`;先 `try? modelContext.delete(model: Coffee.self)` 清空;插入确定性的 demo 数据(如 Coffee 的 name、roaster、origin、notes、roastDate);`modelContext.insert` 后 `try? modelContext.save()`。
- 在 `ContentView` 集成:`#if DEBUG` 下添加 `@State private var captureCoordinator: MarketingCaptureCoordinator?`;在 `.task` 中检查 `ProcessInfo.processInfo.environment["MARKETING_CAPTURE"] == "1"`;若为真则 `seedMarketingData(modelContext:)`;用 `Locale.current.language.languageCode?.identifier ?? "en"` 取 locale;创建 `MarketingCaptureCoordinator(steps: makeMarketingSteps(...), locale: locale)`;`try? await Task.sleep(for: .seconds(1))`;`await captureCoordinator?.startCapture()`。
- 渲染 widget 元素:调用 `MarketingElementHarness.renderElement`;small widget 用 width 158、cornerRadius 16、background `.white`,内容 `PulseWidgetSmallView(entry:)` 手动 `.padding(16)`;medium widget 用 width 338、cornerRadius 16、background `.white`,内容 `PulseWidgetMediumView(entry:)` 手动 `.padding(16)`。
- 渲染 card 元素:遍历 coffees,调用 `renderElement(name: "card-\(index + 1)", width: 380, cornerRadius: 20, background: Color(.systemBackground))`;内容 `CoffeeCard(coffee:)` 加 `.padding(.horizontal, 16)`、`.padding(.vertical, 12)`。
- 渲染 chart 元素:调用 `renderElement(name: "chart-cupping", width: 380, cornerRadius: 16, background: Color(.systemBackground))`;内容 `CuppingRadarChart(scores:)` 加 `.frame(height: 300)`、`.padding(20)`。
- 编写 `scripts/capture-marketing.sh`:`set -e`;设置 `APP_NAME`、`SCHEME`、`DEVICE`(如 `iPhone 17`)、`IOS_VERSION`(如 `18.2`)、`LOCALES`(如 `("en" "de" "es" "fr" "ja")`)。
- 脚本中查找 simulator:用 `xcrun simctl list devices available | grep "$DEVICE ($IOS_VERSION)" | head -1 | grep -o '\[.*\]' | tr -d '[]'` 得到 `SIMULATOR_ID`;为空则报错退出。
- 脚本启动 simulator:`xcrun simctl boot "$SIMULATOR_ID" 2>/dev/null || true`;`sleep 2`。
- 脚本构建一次:`xcodebuild -scheme "$SCHEME" -destination "id=$SIMULATOR_ID" -configuration Debug -derivedDataPath ./build build`。
- 脚本定位 app 和 bundle id:`APP_PATH=$(find ./build -name "${APP_NAME}.app" | head -1)`;`BUNDLE_ID=$(defaults read "$APP_PATH/Info.plist" CFBundleIdentifier)`;`xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"`。
- 脚本逐 locale 捕获:对每个 `LOCALE` 执行 `xcrun simctl launch --terminate-running-process "$SIMULATOR_ID" "$BUNDLE_ID" -AppleLanguages "($LOCALE)" -MARKETING_CAPTURE 1`;`sleep 8`。
- 脚本复制结果:`CONTAINER=$(xcrun simctl get_app_container "$SIMULATOR_ID" "$BUNDLE_ID" data)`;`cp -R "$CONTAINER/Documents/../../tmp/marketing" ./marketing/`;输出 `./marketing/`。
- Gotcha: Live Activities 会跨 launch 持久存在,导致下一 locale 因 stale SwiftData 引用崩溃;在 cleanup 中对 `Activity<PulseAttributes>.activities` 逐个 `await activity.end(nil, dismissalPolicy: .immediate)`。
- Gotcha: 不要每个 step 重新 seed;每 locale 只在 launch 时 seed 一次,避免 CloudKit sync churn 导致崩溃。
- Gotcha: ViewModels 要在 seed 后实例化,否则持有 stale empty snapshots;顺序是 seed data → 等 1s → 实例化 VMs → capture。
- Gotcha: 把触发 binding 设为 nil 不能 dismiss `fullScreenCover`,会截错 sheet;使用专用 dismiss closure,如 `.fullScreenCover(isPresented: $showTimer) { TimerView(onDismiss: { showTimer = false }) }`。
- Gotcha: `NavigationPath` 不能从外部 pop;在 navigate block 中总是先 `popToRoot()` 再 `push()`,否则会在已有 stack 上 push 并截错栈。
- Gotcha: `membershipExceptions` 是 INCLUSION list,widget target membership 容易反向导致 widget 渲染失败;为 DEBUG 文件设置 `membershipExceptions` 以排除 widget target:`MarketingCapture.swift` → Target Membership → Widget ❌。
- Gotcha: `ImageRenderer` + `ProgressView` 会渲染成 prohibited symbol;强制 `.progressViewStyle(.circular)`。
- Gotcha: `.containerBackground` 在 WidgetKit 之外是 no-op,widget 渲染会无背景;在 render harness 中手动加背景,如 `.background(Color.white)`。
- Gotcha: iOS 18+ 没有 iPhone 8 Plus:改用 iPhone 17 Pro Max 或当前可用的大屏设备。
- Gotcha: locale launch argument 格式必须带括号;总是用 `-AppleLanguages "($LOCALE)"`,如 `-AppleLanguages "(de)"` 正确,`-AppleLanguages "de"` 错误。
- Gotcha: SwiftUI 动画在 `ImageRenderer` 中只截 frame 0;渲染非动画状态或显式 `.animation(nil)`。
- Workflow: 捕获倒计时中的 timer:创建 `CaptureStep(name: "05-timer-active", navigate: { router.popToRoot(); coordinator.startTimer(seconds: 180) }, settle: .seconds(1.5), cleanup: { coordinator.stopTimer(); router.popToRoot() })`。
- Workflow: 捕获 presented sheet:用 `@State private var showSheet = false`;步骤 `navigate: { showSheet = true }`,settle `.seconds(0.8)`,cleanup `{ showSheet = false }`;view 上 `.sheet(isPresented: $showSheet) { AddCoffeeView() }`。
- Workflow: 渲染所有 widgets:在 `#if DEBUG @MainActor func renderAllWidgets()` 中列出 `("pulse-small", PulseWidgetSmallView.self, 158)`、`("pulse-medium", PulseWidgetMediumView.self, 338)`、`("pulse-large", PulseWidgetLargeView.self, 338)`;循环调用 `renderElement(name: "widget-\(name)", width: width, cornerRadius: 16, background: .white)`;内容 `viewType.init(entry: sampleEntry).padding(16)`。
- Troubleshooting: capture 立即退出时检查 launch args 是否设置 `MARKETING_CAPTURE=1`,命令示例 `xcrun simctl launch ... -MARKETING_CAPTURE 1`。
- Troubleshooting: locale 没生效时检查 `-AppleLanguages` 是否带括号,用 `-AppleLanguages "(de)"`。
- Troubleshooting: widget 渲染空白时检查是否手动加 padding,如 `WidgetView(entry: entry).padding(16)`。
- Troubleshooting: 截到错误屏幕时检查 settle 是否够长,如 `.seconds(0.8)`,不够则增大。
- Troubleshooting: SwiftData 在第二个 locale 崩溃时检查 cleanup 是否结束 Live Activities,使用 `Task { for activity in Activity<Attrs>.activities { await activity.end(nil, dismissalPolicy: .immediate) } }`。
- Troubleshooting: 元素渲染尺寸错误时检查是否显式设置 frame width,如 `.frame(width: 380)`。
- 配置参考:`CaptureStep` 属性为 `name`(String,无扩展名文件名)、`navigate`(`@MainActor () -> Void`)、`settle`(`Duration`)、`cleanup`(`(@MainActor () -> Void)?`)。
- 配置参考:脚本变量 `APP_NAME`(如 `"YourApp"`)、`SCHEME`(如 `"YourApp"`)、`DEVICE`(如 `"iPhone 17"`)、`IOS_VERSION`(如 `"18.2"`)、`LOCALES`(如 `("en" "de" "es")`)。
- 配置参考:环境变量 `MARKETING_CAPTURE=1` 在 launch args 或脚本中设置。
- 后处理:用 `npx skills add ParthJadhav/app-store-screenshots` 安装 `app-store-screenshots`,把捕获的 PNG 合成为带 device mockups、headlines、gradients 的 Apple 风格营销页;提示词示例:`Composite my marketing screenshots into App Store pages with device frames`。
- 完整示例:在 `#if DEBUG @MainActor func makeMarketingSteps(coordinator:router:viewModel:) -> [CaptureStep]` 中取 `let coffees = viewModel.coffees`,返回步骤:`01-home` setTab(0) settle 0.5 cleanup nil;`02-shelf` setTab(1) settle 0.5;`03-detail` setTab(0) popToRoot push `.coffeeDetail(coffees[0])` settle 0.8 cleanup popToRoot;`04-timer` setTab(0) popToRoot startTimer(seconds: 180) settle 1.2 cleanup stopTimer popToRoot;`05-settings` setTab(2) settle 0.5;`elements` 遍历 coffees 渲染 `card-\(index + 1)` width 380 cornerRadius 20 background systemBackground,内容 `CoffeeCard(coffee: coffee).padding(.horizontal, 16).padding(.vertical, 12)`,再 `renderAllWidgets()`,settle 0.1,cleanup nil。
- 运行完整示例:`chmod +x scripts/capture-marketing.sh`;`./scripts/capture-marketing.sh`;输出结构为 `marketing/<locale>/01-home.png`、`02-shelf.png`、`03-detail.png`、`04-timer.png`、`05-settings.png`、`elements/card-1.png`、`card-2.png`、`widget-pulse-small.png`、`widget-pulse-medium.png` 等。
适用边界 · 注意事项
- UIKit-based app(需要不同的捕获方式)。
- 已有稳定 XCUITest/Fastlane 基础设施,或只需单语言手动截图。
- 应用大量使用 WebView 或外部内容,无法可靠 seed 数据/状态。