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)有一個偵錯的「載入示範數據」按鈕,(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 對應到導航狀態的列舉/切換。 - 示範數據播種器 定義在哪裡?從偵錯按鈕(如果有的話)追蹤程式碼路徑到實際寫入
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:迭代驗證
不要 將腳本交給使用者然後等待。自己在模擬器上執行它,並在宣告完成之前至少驗證一個語言環境。使用「讀取」工具讀取輸出的 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? /// 設為 true 以在計時器上顯示評估疊加層。 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
… [為翻譯而截斷]