Автоматизация скриншотов App Store для SwiftUI без Fastlane (2026)
XCUITest + XCUIScreenshot для захвата живого приложения, новый API вложений Swift Testing для того же самого с меньшим количеством шаблонного кода, и ImageRenderer для рендеринга SwiftUI-вью прямо в PNG без запуска симулятора. Open source вроде swift-snapshot-testing и AppScreenshotKit оборачивают эти API. Ни один из них не собирает маркетинговую карусель — рамки, подписи, фоны, тексты на 50 локалях. Это отдельный шаг, и именно здесь вписывается Mokbi: он компонует и локализует уже захваченные тобой скриншоты. Он их не захватывает.snapshot из Fastlane — стандартный ответ на «автоматизируй мне скриншоты App Store», и не зря — инструмент зрелый и дружелюбный к CI. Но это ещё и Ruby-тулчейн, Snapfile, SnapshotHelper.swift, который копируешь в тестовый таргет, и немалая настройка ради того, что под капотом — лишь тонкая обёртка вокруг тех же XCUITest API, которые у тебя уже есть. Если ты пишешь на SwiftUI и не хочешь добавлять Ruby-зависимость в iOS-проект, можно автоматизировать скриншоты одними лишь Xcode и Swift. Этот пост — честная карта нативных вариантов 2026 года, с кодом, который можно вставить, и чёткой границей, какой инструмент решает какую задачу. Прямое сравнение — в статье Mokbi vs Fastlane.
Сначала одна формулировка, которая избавит от кучи путаницы: захват кадра (довести приложение до нужного состояния и сохранить PNG того, что на экране) и компоновка скриншота для магазина (этот кадр внутри макета устройства, с заголовком, фоном и переведённым текстом, экспортированный в каждом нужном размере) — две разные задачи. Всё в этом посте решает первую. Ничто не решает вторую.
Зачем вообще пропускать Fastlane?
С Fastlane всё в порядке. Причины, по которым разработчики тянутся к нативному пути, практические, а не идеологические:
- Никакого Ruby в цепочке. Fastlane — это Ruby-гем со своей текучкой версий (Bundler, конфликты гемов, изредка поломка CI после апдейта Ruby). Если проект в остальном чистый Swift, это целый рантайм, который приходится поддерживать ради одной задачи.
- Тестовый таргет уже есть.
snapshotпод капотом генерирует UI-тесты. Если ты и так пишешь UI-тесты, захват скриншота — это две дополнительные строки, без нового инструмента. - SwiftUI изменил расклад. С
ImageRendererможно отрендерить экран в PNG вообще без запуска приложения — Fastlane так не умеет, он всегда управляет симулятором. - Меньше движущихся частей в CI. Обычный
xcodebuild test, который выдаёт скриншоты, проще разбирать, чем Fastlane-лейн, который вызываетxcodebuildчерез shell, а потом постобрабатывает бандл результатов.
Обратная сторона: Fastlane упаковывает всю неприятную часть (сбор вложений из бандла .xcresult, организация по локали × устройству, генерация HTML-превью, выгрузка через deliver) в одну команду. Переходя на нативный путь, часть этой сборочной работы становится твоей проблемой. Этот обмен — вся суть решения, и его стоит принимать осознанно.
Вариант 1: XCUITest + XCUIScreenshot (захват живого приложения)
Это фундамент, на котором построен сам Fastlane. UI-тестовый таргет запускает твоё реальное приложение в симуляторе, управляет им через тот же finder/tap API, что и любой UI-тест, а XCUIScreen.main захватывает весь экран устройства в нативном разрешении. Этот скриншот оборачивается в XCTAttachment, а lifetime выставляется в .keepAlways, чтобы Xcode сохранил его даже при успешном тесте (по умолчанию вложения удаляются при успехе).
// ScreenshotUITests.swift — runs in a UI test target
import XCTest
final class ScreenshotUITests: XCTestCase {
func testCaptureCarouselScreens() {
let app = XCUIApplication()
// A launch argument your app reads to seed demo data + hide
// anything that would make a noisy screenshot (debug banners, etc).
app.launchArguments += ["-FASTLANE_SNAPSHOT", "NO", "-SCREENSHOT_MODE", "YES"]
app.launch()
// Drive the app to the state you want, then capture the WHOLE screen.
snapshot("01_Home")
app.buttons["openLibrary"].tap()
snapshot("02_Library")
}
private func snapshot(_ name: String) {
// XCUIScreen.main captures the full device screen at native
// resolution — exactly what App Store Connect wants.
let shot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: shot)
attachment.name = name
attachment.lifetime = .keepAlways // or Xcode discards it when the test passes
add(attachment)
}
}Скриншоты оказываются внутри бандла .xcresult. Извлекаешь их после прогона — либо открыв бандл результатов в Xcode и перетащив их наружу, либо в CI с помощью инструмента вроде xcparse / xc-screenshot, который обходит бандл и записывает именованные PNG в папку. Этот шаг извлечения — именно то, что Fastlane автоматизирует за тебя, и это главное, что берёшь на себя при переходе на нативный путь.
Локализация без изменений в коде. Аккуратный приём здесь — test plan в Xcode: создаёшь один test plan, добавляешь конфигурацию на каждый язык/регион (каждая задаёт язык и регион приложения), и один и тот же тест запускается по разу на конфигурацию. Получаешь локализованные скриншоты — UI приложения, отрисованный в каждой локали, — не трогая тестовый код. Запускай на симуляторе класса 6,9-дюймового iPhone и класса 13-дюймового iPad, чтобы попасть в разрешения, которые требует App Store Connect.
Плюсы и минусы:
- Настоящие пиксели приложения. Реальные работающие вью — шрифты, тема, динамические данные, всё нативное — в точном разрешении симулятора.
- Никакого Fastlane, никакого Ruby. Чистые Xcode + Swift. Работает под
xcodebuild testв любом CI. - Локаль — это конфигурация test plan, а не код. Добавь конфигурацию на язык и перезапусти; никаких веток по локали в Swift.
- Извлечение — на тебе. Вытаскивание PNG из бандла
.xcresult— твоя задача (одноразовый скрипт, но реальный). - Стоимость поддержки. Finder'ы (
buttons["openLibrary"], тапы) ломаются при изменении UI, как в любом UI-тесте.
Вариант 2: вложения Swift Testing (современная, более лёгкая версия)
Swift Testing (макро-фреймворк Apple, идущий в комплекте с Xcode 26) добавил API Attachment, который делает то же, что делал XCTAttachment, но с меньшим количеством церемоний и более приятным вызовом. Приложением по-прежнему управляешь через XCUIApplication и захватываешь через XCUIScreen.main.screenshot() — это часть XCUITest, — но записываешь изображение через Attachment.record(...) из функции с атрибутом @Test. API вложений не важно, как получено изображение — он принимает любой подходящий тип изображения и записывает его в бандл результатов теста.
// ScreenshotTests.swift — Swift Testing (Xcode 26+)
import Testing
import XCTest // still needed for XCUIApplication / XCUIScreen
@MainActor
struct ScreenshotTests {
@Test func homeAndLibrary() async throws {
let app = XCUIApplication()
app.launchArguments += ["-SCREENSHOT_MODE", "YES"]
app.launch()
// Attachment.record attaches to THIS test's result bundle.
let home = XCUIScreen.main.screenshot()
Attachment.record(home.image, named: "01_Home.png")
app.buttons["openLibrary"].tap()
let library = XCUIScreen.main.screenshot()
Attachment.record(library.image, named: "02_Library.png")
}
}Функционально это захватывает те же пиксели живого приложения, что и Вариант 1 — тот же самый захват экрана под капотом. Что ты получаешь — это более чистая эргономика Swift Testing (параметризованные тесты, async по умолчанию, без наследования от XCTestCase), если проект уже перешёл на Swift Testing. От чего не избавляешься — от того же шага извлечения: PNG по-прежнему живёт в бандле результатов, и его по-прежнему нужно вытаскивать для загрузки. Используй этот вариант, если уже на Swift Testing; в остальном результат идентичен Варианту 1.
Вариант 3: ImageRenderer (вообще без запуска симулятора)
Это вариант, который Fastlane структурно предложить не может. ImageRenderer (SwiftUI, iOS 16+ / macOS 13+) берёт SwiftUI-вью и рендерит его иерархию прямо в UIImage / CGImage — без запуска приложения, без UI-автоматизации симулятора, без finder'ов, которые могут сломаться. Передаёшь ему вью, размером ровно в пиксели App Store, и считываешь обратно байты PNG.
// Render a SwiftUI view straight to a PNG — no simulator UI run.
import SwiftUI
@MainActor
func renderScreen() -> Data? {
let view = HomeScreen() // your real SwiftUI screen
.frame(width: 1320, height: 2868) // 6.9-inch iPhone, exact pixels at 1x
let renderer = ImageRenderer(content: view)
renderer.scale = 1 // size is already in target pixels
renderer.isOpaque = true // App Store rejects alpha channels
#if canImport(UIKit)
return renderer.uiImage?.pngData()
#else
guard let cg = renderer.cgImage else { return nil }
let rep = NSBitmapImageRep(cgImage: cg)
return rep.representation(using: .png, properties: [:])
#endif
}Для вывода в магазин важны две вещи. Выстави isOpaque = true — App Store Connect отклоняет скриншоты с альфа-каналом, а непрозрачный рендер это предотвращает. И следи за scale: если задаёшь размер вью в points, а не в целевых пикселях, выставь renderer.scale под масштаб устройства, иначе вывод получится размытым; если задаёшь фрейм ровно в целевых пикселях (как выше) — оставь scale равным 1.
Главная оговорка — то, что ImageRenderer не может отрендерить. Он рендерит SwiftUI, а не вью на базе UIKit/AppKit: веб-вью, карты, медиаплееры, превью камеры и некоторые системные контролы выходят как заглушки. Для чистого, детерминированного SwiftUI-экрана это отлично — мгновенно, без интерфейса, идеально по размеру, работает где угодно, включая устройство. Для экрана, встраивающего платформенные вью, возвращаешься к Вариантам 1–2, чтобы захватить реальный вывод компоузера.
- Гораздо быстрее. Миллисекунды на изображение, без загрузки симулятора, без управления UI. Отлично в CI.
- Точные размеры, элементарно. Задай фрейму 1320 × 2868 (или любой нужный размер) — и это твой вывод, без гадания «подогнать под App Store».
- Состояние конструируешь сам. Раз приложение не запущено, передаёшь мок-данные во вью напрямую — детерминированно, без launch-аргументов для наполнения демо-данными.
- Только SwiftUI. Платформенные вью рендерятся как заглушки. Проверь свой экран, прежде чем ему доверять.
Вариант 4: open source, оборачивающий всё вышеперечисленное
Постоянно всплывают два открытых проекта, решающих смежные задачи.
pointfree/swift-snapshot-testing — самая популярная библиотека SwiftUI-снапшотов, и о её назначении стоит говорить точно: она создана для регрессионного тестирования, а не для получения ассетов для магазина. Первый прогон записывает эталонный PNG и намеренно проваливается; последующие прогоны сравнивают с этим эталоном, чтобы поймать день, когда изменение отступа или шрифта сдвинуло UI.
// pointfree/swift-snapshot-testing — built for regression, not store assets.
import SnapshotTesting
import SwiftUI
import Testing
@MainActor
struct HomeSnapshotTests {
@Test func home_iPhone16ProMax() {
let view = HomeScreen()
// First run records the reference PNG and FAILS on purpose;
// later runs diff against it. To re-record: record: .all
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13ProMax)))
}
}Записанные эталонные изображения можно переиспользовать как сырые скриншоты, а стратегии .image(layout: .device(...)) рендерят в реальных размерах устройств — но это идёт вразрез со строгостью инструмента (он хочет попиксельного совпадения, а различия сглаживания на разных машинах вызывают нестабильные падения). Тянись к нему, чтобы защитить UI от регрессий, а не как к основному пайплайну скриншотов.
AppScreenshotKit (от shitamori1272) создан именно для этой задачи. Объявляешь SwiftUI-вью с макросом @AppScreenshot, задающим классы устройств и локали, оборачиваешь реальный экран в DeviceView, чтобы он рендерился внутри макета устройства Apple, и экспортируешь из функции Swift Testing. Он раскладывает вывод в аккуратное дерево Screenshots/<locale>/<device>/.
// AppScreenshotKit — declarative SwiftUI screenshots, runs under Swift Testing.
import AppScreenshotKit
import SwiftUI
@AppScreenshot(.iPhone69Inch(), options: .locale([Locale(identifier: "en_US")]))
struct HomeShot: View {
var body: some View {
DeviceView { HomeScreen() } // your real screen, wrapped in a device frame
}
}
// In a test target:
import AppScreenshotKitTestTools
import Testing
@Test @MainActor
func exportScreens() throws {
let out = URL(fileURLWithPath: "Screenshots")
let exporter = AppScreenshotExporter(option: .file(outputURL: out))
try exporter.export(HomeShot.self) // → Screenshots/en_US/iPhone_6_9_inch/...
}Стоит знать и о смежном инструментарии в этой области: CLI вроде storescreens управляют всем пайплайном App Store Connect (захват через XCUITest параллельно на нескольких симуляторах, рендеры в рамках с подписями, загрузка метаданных через API Apple) из одного конфиг-файла. Они по-настоящему полезны, если нужна единая, идемпотентная, дружелюбная к CI цепочка. Они же приносят собственные представления о раскладке подписей — и именно здесь выделенный визуальный редактор обычно выигрывает для маркетинговой поверхности.
Чего не делает ничто из вышеперечисленного: компоновка + локализация на 50 языков
Вот честный абзац с чёткой границей. Каждый вариант выше выдаёт голые кадры — UI приложения в разрешении устройства (или, с AppScreenshotKit и подобными, кадр внутри простого макета устройства). Карусели App Store, которые конвертируют, — это не голые кадры: это кадр внутри стилизованного макета устройства, на фоне, под коротким заголовком, часто как мультипанельная последовательность, повторённая для каждой нужной локали. Этот шаг компоновки — то, для чего существует Mokbi. Ты приносишь захваченные кадры (из XCUITest, ImageRenderer, симулятора — откуда угодно), загружаешь их в браузерный редактор, добавляешь рамки устройств, подписи и фоны, переводишь подписи на до 50 языков App Store примерно за один клик и пакетно экспортируешь во всех нужных размерах. Дизайн бесплатен с водяным знаком предпросмотра; неограниченный экспорт и публикация доступны по подписке — Solo за €29.99/mo (1 приложение) или Studio за €49.99/mo (до 5 приложений), без единоразовой покупки. Чтобы обозначить границу явно: Mokbi не захватывает исходные скриншоты — он не запускает твоё приложение и не рендерит твои вью. Захват остаётся за нативным Swift-инструментарием выше; Mokbi компонует и локализует то, что этот инструментарий производит.
Реалистичный совмещённый воркфлоу для SwiftUI
- Захвати исходные кадры нативно. Для детерминированных SwiftUI-экранов отрендери их через
ImageRendererв точных пиксельных размерах (Вариант 3) — это самое быстрое, и симулятор не нужен. Для экранов с платформенными вью или там, где нужно настоящее живое состояние, напиши захват через XCUITest (Вариант 1) или Swift Testing (Вариант 2) и запусти на симуляторе класса 6,9-дюймового iPhone и класса 13-дюймового iPad. - Локализуй захват, если нужно. Добавь конфигурацию test plan на каждый язык, чтобы UI приложения рендерился в каждой локали, или передай локаль во вью, которое отдаёшь
ImageRenderer. Это локализует пиксели приложения — не маркетинговый заголовок. - Скомпонуй маркетинговую карусель. Загрузи кадры в Mokbi, добавь стилизованные рамки/подписи/фоны, собери мультипанельную последовательность. Это шаг, который нативный инструментарий сделать не может.
- Переведи подписи и экспортируй. Одним кликом переведи текст заголовков на нужные локали App Store, затем пакетно экспортируй все нужные размеры.
- При следующем изменении UI — перезахвати и переоткрой. Перезапусти рендерер или UI-тест, открой сохранённый проект Mokbi, замени кадры, переэкспортируй. Компоновка и переводы сохранятся.
Когда можно вообще обойтись без автоматизации
Если выпускаешь от одного до трёх релизов в год, любая обвязка для скриншотов — нативная или через Fastlane — обойдётся дороже, чем когда-либо сэкономит. Запусти приложение в iOS Simulator для нужного класса устройства, перейди на каждый экран, вызови команду скриншота симулятора (она захватывает в точном разрешении устройства) — и через десять минут у тебя готовые для App Store PNG. Затем скомпонуй и локализуй один раз и двигайся дальше. Автоматизация выше окупается ровно тогда, когда «переснять вручную» перестаёт быть десятиминутной задачей — частые релизы, много локалей или несколько приложений в портфолио. Подбирай инструментарий под темп релизов, а не под то, что выглядит солиднее. Та же логика применима и на других платформах — см. версию этой задачи для React Native и более широкий разбор Fastlane vs no-code.
Чем бы ты ни захватывал скриншоты, перед экспортом сверься с целевыми параметрами: точные пиксельные размеры и правила формата (PNG/JPEG, RGB, без альфа-канала) — в гайде по размерам скриншотов App Store. Apple без снисхождения отклоняет размеры, отличающиеся хоть на пиксель, так что это стоит тридцати секунд.