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

Скриншоты Flutter App Store: integration_test, golden-тесты и более быстрый путь (2026)

Скриншоты Flutter App Store: integration_test, golden-тесты и более быстрый путь (2026)
TL;DR. Flutter не поставляет никакой встроенной команды «сгенерируй скриншоты для магазина». Реальные варианты: захватить настоящие кадры через integration_test + flutter drive, отрендерить снимки виджетов с помощью golden-тестов (отлично для регрессии, натяжка для ассетов магазина) или обернуть любой из них в Fastlane/Codemagic CI. Ни один из них не компонует маркетинговую карусель — подписи, рамки, фоны, копию для 50 локалей. Это отдельный шаг, и именно здесь в схему вписывается Mokbi: он компонует и локализует скриншоты, которые ты захватил. Сам он их не захватывает.

Если ты разрабатываешь на Flutter и уже публиковал в App Store или Play Store, то наверняка столкнулся с этим пробелом: никакого flutter screenshots не существует. Apple требует попиксельно точные PNG определённых размеров — например, 1320 × 2868 (iPhone 6.9") и 2064 × 2752 (iPad 13"); Flutter даёт тебе движок рендеринга и тестовый стенд, а пайплайн для магазина оставляет на твоё усмотрение. Эта статья — честная карта того, что реально работает в 2026 году, с кодом для копирования и чёткой границей между тем, какой инструмент что делает. Если хочешь сначала получить обзор для конкретного фреймворка, смотри Mokbi для Flutter-разработчиков.

Сразу одно важное разграничение, которое снимает много путаницы: захват кадра (доведение приложения до нужного состояния и сохранение PNG того, что на экране) и компоновка скриншота для магазина (этот кадр внутри мокапа устройства с заголовком, фоном и переведённой копией, экспортированный во всех нужных размерах) — это две разные задачи. Большинство инструментов Flutter ниже решают первую. Ни один не решает вторую.

Вариант 1: integration_test + flutter drive (реальный захват кадров)

Это ближайший аналог официального пайплайна скриншотов во Flutter. Пакет integration_test запускает полное приложение на реальном устройстве или эмуляторе, управляет им тем же API finder/tester, что и виджет-тесты, и предоставляет метод binding.takeScreenshot(). Нюанс: takeScreenshot возвращает байты в тестовый процесс — он не записывает файл — поэтому для получения этих байтов на хосте и их сохранения нужен расширенный драйвер.

Сначала — тест. Обрати внимание на специфику Android: перед захватом нужно вызвать convertFlutterSurfaceToImage() (и прогнать один кадр), потому что Android рендерит Flutter в SurfaceView, который framebuffer не может прочитать напрямую.

// integration_test/screenshot_test.dart
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('home + detail screenshots', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    // Android needs the surface converted to an image before capture.
    if (Platform.isAndroid) {
      await binding.convertFlutterSurfaceToImage();
      await tester.pumpAndSettle();
    }

    await binding.takeScreenshot('01_home');

    await tester.tap(find.byKey(const Key('open-detail')));
    await tester.pumpAndSettle();
    await binding.takeScreenshot('02_detail');
  });
}

Затем — драйвер, который работает на хост-машине и записывает байты PNG, полученные от теста. Этот файл живёт в test_driver/ и использует integration_test_driver_extended.dart (у обычного integration_test_driver.dart нет хука onScreenshot):

// test_driver/integration_test.dart
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';

Future<void> main() async {
  await integrationDriver(
    onScreenshot: (String name, List<int> bytes,
        [Map<String, Object?>? args]) async {
      final file = await File('screenshots/$name.png').create(recursive: true);
      file.writeAsBytesSync(bytes);
      return true; // false fails the test (e.g. on a diff mismatch)
    },
  );
}

Принципиально важно запускать это через flutter drive, а не flutter test — только путь через драйвер вызывает onScreenshot:

flutter drive \
  --driver=test_driver/integration_test.dart \
  --target=integration_test/screenshot_test.dart \
  -d emulator-5554

Что ты получаешь и чего не получаешь:

  • Настоящие пиксели приложения. Это скриншоты реально работающих виджетов, а не перерендер — шрифты, тема, динамические данные — всё нативное.
  • Детерминированные состояния. Ты доводишь приложение ровно до тех экранов, которые нужны, и захватываешь по требованию.
  • Класс устройства = то устройство, на котором запускаешь. Выходное разрешение равно разрешению эмулятора или устройства. Чтобы попасть в точные Apple 1320 × 2868, нужно запускать на iOS-симуляторе класса 6.9"; для набора iPad 13" — на том же симуляторе. Никакого магического флага «изменить размер до App Store» нет.
  • Локаль — твоя забота. Можно передать локаль в приложение при запуске и повторить прогон для каждого языка, но это захватит UI приложения на этой локали — маркетинговые подписи это не напишет.
  • Стоимость поддержки реальна. Матчеры (find.byKey, тапы) ломаются при изменении UI, как и любой UI-тест.

Вариант 2: golden-тесты — и почему они не являются ассетами для магазина

Flutter-golden-тесты (matchesGoldenFile или более высокоуровневый testGoldens из таких пакетов, как golden_toolkit) рендерят дерево виджетов в изображение и сравнивают его побайтово с зафиксированным эталоном. Они превосходны, и созданы для иной цели, чем скриншоты магазина: пиксельная регрессия. Идея — поймать тот день, когда изменение отступа или смена шрифта незаметно сдвигает UI. Golden — это контракт; diff — это упавший тест.

Две причины не использовать raw-golden-тесты как пайплайн для магазина:

  • Golden-тесты рендерят виджеты в headless-окружении, а не на реальной поверхности устройства. Platform views, некоторые шейдеры и всё, что зависит от реального GPU-композитора, могут рендериться иначе (или вовсе не рендериться) по сравнению с тем, что видит пользователь. Для регрессионного контракта это нормально — ты сравниваешь сам с собой. Для ассета магазина нужен реальный кадр — как в варианте 1.
  • Точное попиксельное сравнение работает против тебя. Vanilla-goldens требуют полного совпадения, поэтому различия в сглаживании и хинтинге шрифтов между машинами вызывают нестабильные падения. Это правильная строгость для регрессии и неправильная для задачи «получить красивое маркетинговое изображение».

Впрочем, есть разумный промежуточный путь. Такие пакеты, как golden_screenshot, намеренно переориентируют golden-механизм на вывод для магазина: используют testGoldens, оборачивают экран в настоящие рамки устройств (phone/tablet/desktop), включают нечёткий компаратор, допускающий небольшое (~0.1%, настраивается) расхождение вместо требования попиксельного совпадения, и записывают файлы в директорную структуру стиля Fastlane (metadata/<locale>/images/...). Если твои экраны написаны на чистом Flutter (без platform views) и ты хочешь получить кадры в рамках устройства без поднятия эмулятора — это разумный и быстрый путь. Просто будь честен с собой: ты рендеришь виджеты, а не захватываешь живое приложение, и «маркетинг», который ты получаешь, — это рамка устройства, а не заголовки, фоны или мультипанельная карусель.

Вариант 3: Fastlane / Codemagic для Flutter

Fastlane — это iOS/Android-автоматизация, а не Flutter-специфичная, но он вписывается двумя способами. Собственные инструменты захвата Fastlane (snapshot для iOS через XCUITest, screengrab для Android через Espresso/UI Automator) рассчитаны на нативные UI-тесты, которых в Flutter-приложениях, как правило, нет, — поэтому большинство Flutter-команд не используют snapshot для захвата. Своё место Fastlane зарабатывает на доставке: deliver (iOS) и supply (Android) загружают уже захваченные PNG и метаданные в App Store Connect и Play Console, читая директорную структуру Fastlane — именно ту, которую пишет golden_screenshot и которую можно настроить для записи драйвером из варианта 1.

Codemagic — Flutter-нативный CI, к которому чаще всего обращаются здесь. Реалистичный рецепт CI: запускаешь integration_test-прогон скриншотов на macOS/Android-раннерах для нужных классов устройств, собираешь PNG как артефакты сборки, затем вызываешь deliver/supply для пуша. Это реально полезно, когда скриншоты расходятся с реальным приложением при каждом релизе и ты выпускаешь часто. По более широкому вопросу «автоматизация vs no-code» — включая то, когда эта настройка избыточна — смотри статью fastlane snapshot vs no-code скриншоты App Store.

Вариант 4: ручной захват (по-прежнему вполне рабочий)

Для многих инди Flutter-приложений честный ответ: просто делай скриншоты вручную раз на релиз. Запускай приложение в iOS Simulator с правильным классом устройства (iPhone 6.9", iPad 13"), переходи к каждому экрану и используй команду снимка симулятора — она захватывает в точном разрешении устройства, которого и ждёт App Store Connect. На Android кнопка камеры в эмуляторе делает то же самое. Суммарное время для приложения с пятью экранами: минут десять.

У ручного захвата нет стоимости поддержки, нет нестабильных матчеров и нет CI, за которым нужно следить. Единственный минус — ничего не пересоздаётся автоматически: если изменишь UI — нужно переснять. Для команды, выпускающей несколько раз в год, этот компромисс однозначно стоит того. Автоматизация вариантов 1–3 окупается ровно тогда, когда «переснять вручную» перестаёт быть задачей на десять минут — потому что выпускаешь еженедельно и в многих локалях.

То, чего ни один из вариантов выше не делает: компоновка и локализация на 50 языков

Вот ограниченный и честный абзац. Каждый вариант выше производит сырые кадры — UI приложения в разрешении устройства. Карусели App Store и Play Store, которые реально конвертируют, — это не сырые кадры: это кадр в мокапе устройства, на фоне, под коротким заголовком, зачастую в виде мультипанельной последовательности, повторённой для каждой целевой локали. Шаг компоновки — вот для чего существует Mokbi. Ты приносишь захваченные кадры (из integration_test, golden_screenshot, симулятора — откуда угодно), перетаскиваешь в браузерный редактор, добавляешь рамки устройств, подписи и фоны, переводишь подписи на 50 языков App Store примерно одним кликом и пакетно экспортируешь во всех нужных размерах. Дизайн бесплатен с водяным знаком предпросмотра; безлимитный экспорт и публикация доступны по подписке — Solo за €29.99/mo (1 приложение) или Studio за €49.99/mo (до 5 приложений), без разовой покупки. Чтобы чётко обозначить границу: Mokbi не захватывает исходные скриншоты — он не запускает приложение и не управляет виджетами. Захват остаётся за Flutter-инструментами выше; Mokbi компонует и локализует то, что этот инструментарий производит.

Реалистичный совмещённый Flutter-воркфлоу

  1. Захвати исходные кадры. Напиши integration_test-прогон скриншотов (вариант 1) для нужных в карусели экранов — или, если выпускаешь редко, просто захвати вручную из симулятора. Запускай на устройстве класса iPhone 6.9" и iPad 13", чтобы разрешения были правильными.
  2. Опционально: подключи к CI. Если расхождение — реальная проблема, запускай прогон на Codemagic и собирай PNG как артефакты (вариант 3). Если нет — пропусти полностью.
  3. Скомпонуй маркетинговую карусель. Перетащи кадры в Mokbi, добавь рамки/подписи/фоны, собери мультипанельную последовательность. Это шаг, который Flutter-инструменты выполнить не могут.
  4. Локализуй и экспортируй. Одним кликом переведи подписи на нужные локали App Store, пакетно экспортируй во всех нужных размерах, и — если хочешь — помести результат в Fastlane-папку, чтобы deliver/supply загрузили его.
  5. При следующем изменении UI — перезахвати и переоткрой. Перезапусти прогон (или переснимай вручную), переоткрой сохранённый проект Mokbi, замени кадры, переэкспортируй. Компоновка и переводы сохраняются.

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

Если выпускаешь один-три релиза в год, создание integration_test-стенда для скриншотов и CI-пайплайна обойдётся тебе в больше часов, чем когда-либо сэкономит. Захватывай вручную из симулятора, скомпонуй и локализуй один раз — и двигайся дальше. Автоматизация вариантов 1–3 нужна командам, где расхождение скриншотов — постоянная нагрузка: частые релизы, много локалей или несколько приложений в портфолио. Подбирай инструменты под ритм релизов, а не под то, что выглядит солидно.

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

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

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