· Workflow · 9 мин. чтения

Автоматизация скриншотов App Store для SwiftUI без Fastlane (2026)

Автоматизация скриншотов App Store для SwiftUI без Fastlane (2026)
TL;DR. Fastlane — не единственный способ автоматизировать скриншоты для iOS-магазина, просто самый известный. У Apple есть всё нужное нативно: 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

  1. Захвати исходные кадры нативно. Для детерминированных SwiftUI-экранов отрендери их через ImageRenderer в точных пиксельных размерах (Вариант 3) — это самое быстрое, и симулятор не нужен. Для экранов с платформенными вью или там, где нужно настоящее живое состояние, напиши захват через XCUITest (Вариант 1) или Swift Testing (Вариант 2) и запусти на симуляторе класса 6,9-дюймового iPhone и класса 13-дюймового iPad.
  2. Локализуй захват, если нужно. Добавь конфигурацию test plan на каждый язык, чтобы UI приложения рендерился в каждой локали, или передай локаль во вью, которое отдаёшь ImageRenderer. Это локализует пиксели приложения — не маркетинговый заголовок.
  3. Скомпонуй маркетинговую карусель. Загрузи кадры в Mokbi, добавь стилизованные рамки/подписи/фоны, собери мультипанельную последовательность. Это шаг, который нативный инструментарий сделать не может.
  4. Переведи подписи и экспортируй. Одним кликом переведи текст заголовков на нужные локали App Store, затем пакетно экспортируй все нужные размеры.
  5. При следующем изменении UI — перезахвати и переоткрой. Перезапусти рендерер или UI-тест, открой сохранённый проект Mokbi, замени кадры, переэкспортируй. Компоновка и переводы сохранятся.

Когда можно вообще обойтись без автоматизации

Если выпускаешь от одного до трёх релизов в год, любая обвязка для скриншотов — нативная или через Fastlane — обойдётся дороже, чем когда-либо сэкономит. Запусти приложение в iOS Simulator для нужного класса устройства, перейди на каждый экран, вызови команду скриншота симулятора (она захватывает в точном разрешении устройства) — и через десять минут у тебя готовые для App Store PNG. Затем скомпонуй и локализуй один раз и двигайся дальше. Автоматизация выше окупается ровно тогда, когда «переснять вручную» перестаёт быть десятиминутной задачей — частые релизы, много локалей или несколько приложений в портфолио. Подбирай инструментарий под темп релизов, а не под то, что выглядит солиднее. Та же логика применима и на других платформах — см. версию этой задачи для React Native и более широкий разбор Fastlane vs no-code.

Чем бы ты ни захватывал скриншоты, перед экспортом сверься с целевыми параметрами: точные пиксельные размеры и правила формата (PNG/JPEG, RGB, без альфа-канала) — в гайде по размерам скриншотов App Store. Apple без снисхождения отклоняет размеры, отличающиеся хоть на пиксель, так что это стоит тридцати секунд.

Что читать дальше

Открыть редактор →