APK представляет собой пакет, который устройство ставит напрямую; AAB (Android App Bundle) служит форматом публикации, из которого Google Play собирает APK под каждое устройство, и для новых приложений Play требует его. Сборка для стора подписывается: на Android keystore, на iOS сертификат и provisioning profile.
Сборка Android: flutter build appbundle --release кладёт файл в build/app/outputs/bundle/release/app-release.aab; signingConfigs.release в android/app/build.gradle.kts читает keystore из key.properties, который лежит в gitignore
Play App Signing: вы подписываете upload-ключом, а Google переподписывает своим ключом, поэтому потерянный upload-ключ можно сбросить
APK всё ещё нужен: для установки в обход стора, Firebase App Distribution и других магазинов; flutter build apk --split-per-abi делает каждый файл компактнее
Сборка iOS: платный Apple Developer аккаунт, App ID, сертификат распространения и App Store provisioning profile (на вашем Mac ими управляет автоматическая подпись Xcode); flutter build ipa архивирует и экспортирует .ipa, который загружают через Transporter или fastlane
Перед каждой загрузкой: увеличьте номер сборки; если включена обфускация (--obfuscate --split-debug-info), сохраняйте файлы символов, чтобы краш-репорты оставались читаемыми
FlutterSenior
CI/CD и DevOps
GitHub Actions, Fastlane, Firebase Distribution, flavors
Закрыто вопросов
0 / 19
- 0Повторить
- 5Часто спрашивают
Вопросы
version: 1.4.2+87 в pubspec.yaml содержит два числа: 1.4.2 означает версию, которую видят пользователи, а 87 означает номер сборки, а сторы проверяют его по-разному: Google Play требует versionCode больше, чем у любой предыдущей загрузки; App Store Connect требует больший номер сборки для той же версии.
Flutter раскладывает их: в versionName/versionCode на Android и в CFBundleShortVersionString/CFBundleVersion на iOS
Номер сборки только растёт: берите его из одного источника истины, а не от человека: github.run_number одного релизного workflow (он свой у каждого workflow и при перезапуске не меняется, поэтому при загрузке из перезапусков используйте run_number*10+run_attempt) или последний номер из стора через fastlane (latest_testflight_build_number) плюс один
Имя версии следует смыслу semver: patch для исправлений, minor для новых возможностей, major для изменений, которые пользователь воспримет как ломающие
Переопределяйте при сборке: flutter build appbundle --build-name=1.4.2 --build-number=$RUN_NUMBER вместо ручной правки pubspec.yaml перед каждым релизом; имя может задавать git-тег вроде v1.4.2
Классическая ошибка: ветка хотфикса или второй workflow повторно использует старый номер сборки, и загрузку отклоняют; берите следующий номер из единого счётчика
Trunk-based development с релизной веткой на каждую версию в сторе: хотфикс ответвляется от тега сборки, которая стоит у пользователей, получает исправление через cherry-pick из main и выходит новой патч-версией, а main тем временем идёт дальше, и хотфикс его не затрагивает.
Почему не полный GitFlow: ветки develop, release и hotfix рассчитаны на несколько крупных релизов в год; команда, которая выпускается раз в одну-две недели, платит за эту церемонию мержами, а долгоживущий develop уходит всё дальше от того, что лежит в сторе
Тегируйте каждую сборку для стора: v1.4.2 отмечает точный коммит бинарника, который уже в продакшене или на ревью; хотфикс начинается оттуда или в существующей ветке release/1.4, но никогда не в main
**Сначала фикс в main, потом cherry-pick: так исправление не пропадёт из следующего релиза; в GitFlow это классический промах, когда хотфикс вливают обратно поздно или не вливают вовсе
Версии**: хотфикс становится 1.4.3 и берёт следующий номер из того же счётчика, что и все остальные сборки
Недоделанное не мешает: в main оно спрятано за флагами; флаг на const bool.fromEnvironment вырезает код из релизных сборок при компиляции, а флаг в Remote Config выключает уже выпущенную фичу без релиза
Фикс только в Dart: если релиз собран через Shorebird, патч дойдёт до пользователей без ревью, но собирают его всё равно из релизной ветки, а не из main
Flavors задают варианты сборки из одной кодовой базы: dev, staging и production собираются как отдельные приложения, которые могут стоять рядом на одном устройстве, и у каждого свой app id, название, иконка, подпись и бэкенд.
Android: productFlavors внутри flavor dimension в android/app/build.gradle.kts; applicationIdSuffix даёт каждому flavor свой id, а android/app/src/dev/google-services.json свой Firebase-проект
iOS: отдельная схема Xcode на каждый flavor плюс конфигурации сборки с именами Debug-dev, Release-dev и так далее; bundle id, отображаемое имя и GoogleService-Info.plist выбираются по конфигурации
Сборка: flutter run --flavor dev -t lib/main_dev.dart; flavor с таким именем должен существовать на обеих платформах, иначе сборка упадёт на той, где его нет
Сторона Dart: appFlavor из package:flutter/services.dart сообщает, какой flavor запущен; эндпоинты и ключи приходят из --dart-define-from-file, а не из самого flavor
Меньше ручной работы: flutter_flavorizr генерирует настройку Gradle и Xcode, а у записи ассета в pubspec.yaml можно указать flavors:, чтобы он попадал только в эти сборки
--flavor выбирает нативный вариант (app id, подпись, файлы Firebase), а --dart-define и --dart-define-from-file зашивают константы в Dart-код. Это независимые флаги, поэтому команда, где указан один и забыт другой, соберётся без единой ошибки.
Что Dart узнаёт из flavor: только его имя, через appFlavor; ни эндпоинта, ни ключа
Пропущенный define молчит: String.fromEnvironment('API_URL', defaultValue: ...) вернёт значение по умолчанию, обычно чьё-то dev-значение, и production-сборка пойдёт в staging
**Читайте как const**: fromEnvironment гарантированно работает только в константном выражении; не-const вызов в AOT-релизе может просто вернуть значение по умолчанию
Пусть сборка падает явно: никакого defaultValue для обязательных значений, проверка bool.hasEnvironment при старте, а каждый flavor жёстко связан со своим конфиг-файлом в одном скрипте сборки или в одной записи матрицы CI
Это не хранилище секретов: define зашиты в бинарник и видны любому, кто его распакует; используйте их для конфигурации, но не для секретов
fastlane представляет собой инструмент на Ruby, который упаковывает длинную цепочку команд подписи, сборки и загрузки в именованные lanes в Fastfile; после этого fastlane beta делает одно и то же и на ноутбуке, и на любой CI-машине.
Экшены, которые вам понадобятся: match для сертификатов и профилей, build_app (gym) для .ipa, upload_to_testflight (pilot), upload_to_play_store (supply) для треков Play, deliver для метаданных App Store и отправки на ревью
Воспроизводимость: чистая машина с нужными секретами выдаёт тот же артефакт, а lanes лежат в репозитории и проходят ревью как обычный код
Безопаснее с доступами: ключ App Store Connect API вместо Apple ID и 2FA конкретного человека, match в read-only режиме на CI, setup_ci для временного keychain
Переносимость: логика релиза живёт в Fastfile, а не в YAML одного вендора, поэтому переезд с Bitrise на GitHub Actions сводится к правке нескольких строк
Когда он не нужен: небольшому приложению с релизом раз в месяц хватит flutter build и пары экшенов; Codemagic и Xcode Cloud уже закрывают подпись и загрузку
Выбирают по тому, кто поддерживает пайплайн и где собирается iOS: Codemagic и Bitrise дают готовые Mac-машины и мобильные шаги, GitHub Actions даёт полный контроль рядом с кодом, а Xcode Cloud закрывает только сторону Apple.
GitHub Actions: workflow лежат в репозитории, огромный маркетплейс, бесплатные минуты для публичных репозиториев; подпись, кеширование и fastlane вы собираете сами, а минуты размещённых (hosted) macOS-раннеров стоят в несколько раз дороже Linux
Codemagic: сделан вокруг Flutter: предустановленные версии SDK, автоматическая подпись iOS через ключ App Store Connect API, публикация в оба стора из codemagic.yaml; хуже подходит для задач вне мобильной разработки
Bitrise: ориентирован на мобильную разработку, с библиотекой шагов и визуальным редактором; хорошо подходит командам, которые параллельно выпускают нативные и Flutter-приложения
Xcode Cloud: собственный сервис Apple, связанный с App Store Connect и TestFlight; Flutter работает через ci_scripts/ci_post_clone.sh, который ставит Flutter и запускает flutter build ios --config-only, а для Android всё равно нужна вторая система
Что решает: стоимость macOS и время в очереди, где разрешено хранить секреты, чем уже пользуется остальная компания и насколько легко уйти; логика релиза в fastlane или скриптах делает вендора заменяемым
Материалы для подписи живут только в хранилище секретов CI, на время сборки раскодируются во временный файл и удаляются по окончании джобы; ничего связанного с подписью в репозиторий не коммитится.
Android: храните upload-keystore в секрете в Base64, раскодируйте его при сборке, генерируйте key.properties из секретов, а signingConfigs.release пусть его читает
Play App Signing: у CI есть только upload-ключ; ключ подписи приложения хранит Google, поэтому утёкший или потерянный upload-ключ можно сбросить, а не потерять возможность публиковаться
iOS: нужны сертификат распространения (.p12) и provisioning profile; fastlane match хранит оба зашифрованными в приватном репозитории или бакете, а CI запускает его в read-only режиме во временный keychain, созданный setup_ci
Доступ к сторам: ключ App Store Connect API и JSON сервисного аккаунта Play, у каждого минимальная роль, которой хватает для загрузки
Гигиена: секреты маскируются в логах, релизные джобы идут через protected environments с ревьюерами, секреты недоступны для pull request'ов из форков и ротируются, когда уходит человек с доступом
На iOS бета-каналом служит TestFlight; на Android выбирают между Firebase App Distribution для быстрых инженерных сборок и внутренним или закрытым тестовыми треками Play Console, которые доставляются через сам стор.
TestFlight: до 100 внутренних тестировщиков без ревью; до 10 000 внешних после бета-ревью Apple; каждая сборка истекает через 90 дней
Firebase App Distribution: приглашение по email или группе, установка через приложение App Tester или по ссылке; самый быстрый путь для разовых сборок из любой ветки
Внутреннее тестирование в Play: сборка приходит из Google Play с настоящим ключом подписи, поэтому Play Integrity, встроенные покупки и in-app updates ведут себя так же, как в production
Многие команды используют оба: Firebase для каждой сборки из main инженерам, Play internal и TestFlight для QA и продукта
Гигиена: release notes с коммитом и веткой, краши с номером сборки и истечение старых сборок, чтобы никто не тестировал код месячной давности
Оба стора дают API для загрузки, и CI ходит в них с машинными учётными данными: сервисный аккаунт Google Cloud для Play и ключ App Store Connect API для Apple, но никогда не с логином конкретного человека.
Google Play: сервисный аккаунт, приглашённый в Play Console с правами на релизы; его JSON-ключ хранится как секрет CI, а upload_to_play_store или Gradle-плагин Play Publisher загружает .aab
Треки задают этапы релиза: internal, затем закрытое или открытое тестирование, затем production с раскаткой по процентам
App Store: API-ключ (.p8 плюс key id и issuer id) заменяет Apple ID, пароль и 2FA; upload_to_testflight отправляет сборку, deliver отвечает за метаданные и отправку на ревью
Храните метаданные в git: папки метаданных fastlane делают release notes и скриншоты версионируемыми вместе с кодом и доступными для ревью
Страховка: CI автоматически загружает в internal или TestFlight, перевод в production ждёт ручного подтверждения, а артефакт сохраняется, чтобы продвигать протестированную сборку, а не пересобирать её
Большинство отказов вызывает то, что видит ревьюер, а не Dart: вход, который он не может пройти, разрешения или данные, которые плагин принёс без декларации, iOS-метаданные с Android или слишком «тонкое» приложение. Проверка релизной сборки перед каждой отправкой ловит почти всё.
За что отклоняют
Ревьюер не может войти (Apple 2.1): демо-аккаунт в заметках для ревью и живой бэкенд за ним; для входа по SMS добавьте тестовый номер с фиксированным кодом, Firebase Auth это поддерживает
Разрешения из плагинов: плагин может добавить в итоговый манифест READ_MEDIA_IMAGES или QUERY_ALL_PACKAGES, а Play требует для каждого декларацию и ключевой сценарий; лишнее убирают через tools:node="remove"
Purpose strings: API, которое линкует плагин, заставляет App Store Connect требовать его NS…UsageDescription (ITMS-90683), даже если вы его не вызываете, а пункт 5.1.1(ii) требует назвать причину: «Сканируем чеки, чтобы заполнить расходы», а не «Доступ к камере»
Декларации данных: форма Data safety в Play и privacy labels Apple должны покрывать то, что собирает Firebase или рекламный SDK, а не только ваш код; если есть регистрация, нужно и удаление аккаунта внутри приложения
Чужая платформа: Android на iOS-скриншотах или в тексте нарушает 2.3.10; вход через Google на iOS требует равноценного варианта, который бережёт приватность, например Sign in with Apple (4.8)
Слишком тонкое или слишком много копий: обёртка webview_flutter вокруг сайта не проходит 4.2, а почти одинаковые white-label-флейворы не проходят 4.3
Как поймать заранее
iPad: шаблон Flutter нацелен и на iPad (TARGETED_DEVICE_FAMILY = "1,2"), поэтому ревьюер может проверить приложение там; доведите вёрстку или уберите поддержку iPad до первого релиза
Прочитайте то, что уходит в стор: итоговый манифест релизной сборки и Generate Privacy Report для архива в Organizer Xcode
Ранние проверки: pre-launch report в Play на тестовом треке, более лёгкое бета-ревью TestFlight для внешних тестировщиков и чек-лист в git рядом с метаданными fastlane
Релизная сборка даёт другой бинарник: AOT-компиляция, вырезанные assert, сжатие R8, другой ключ подписи и релизный манифест. Каждое из этого может сломать то, что debug никогда не задействовал, а flutter test такой бинарник вообще не собирает.
**Нет разрешения INTERNET**: Flutter добавляет его только в debug- и profile-манифесты; без него в src/main/AndroidManifest.xml релизный APK остаётся без сети
Сжатие R8: класс, к которому плагин обращается через рефлексию, вырезается, если proguard-rules.pro его не сохраняет; проявляется это как ClassNotFoundException или отсутствующий метод только в release
Другой ключ подписи: Google Sign-In, Firebase Auth и Maps проверяют SHA-1; регистрируйте upload-ключ и ключ подписи приложения из Play, а не только debug-ключ
Код только для debug: побочные эффекты внутри assert или за kDebugMode исчезают, а с --obfuscate ломается логика на runtimeType.toString()
Как отлаживать: воспроизводите через flutter run --release (или --profile, чтобы остался DevTools), читайте adb logcat или консоль Xcode, а обфусцированные стектрейсы расшифровывайте через flutter symbolize и сохранённые файлы --split-debug-info
В CI: собирайте релизный артефакт на каждый PR, ставьте его на эмулятор, проверяйте, что он запускается и проходит основной сценарий, и гоняйте интеграционные тесты в profile-режиме
Файлов, которые генерирует flutter pub get: ios/Flutter/Generated.xcconfig и .flutter-plugins-dependencies лежат в gitignore, а Podfile читает этот xcconfig, чтобы найти Flutter SDK и поды плагинов.
Порядок: flutter pub get, затем flutter build ios --config-only с тем же режимом, flavor и define, что и у настоящей сборки, и только потом xcodebuild или build_app из fastlane
**Что делает --config-only**: переписывает xcconfig под текущий режим сборки, flavor и набор define и запускает pod install, если набор плагинов изменился, ничего при этом не компилируя
Если его пропустить: сборка пройдёт, но с не тем режимом, не тем flavor или define прошлой недели
**Кешируйте ios/Pods по Podfile.lock и версии Flutter**: поды плагинов ссылаются на pub-кеш, и устаревшая папка Pods падает с «The sandbox is not in sync with the Podfile.lock»
Фиксируйте Xcode явно: в хостовых macOS-образах несколько версий, и версия по умолчанию меняется при обновлении образа
**Коммитьте Podfile.lock**: и никогда Pods/, Generated.xcconfig или flutter_export_environment.sh
Разделите его на две части: быстрый CI на каждый pull request, который доказывает корректность кода, и CD на main или на тег версии, который собирает подписанные артефакты и доставляет их тестировщикам, а затем в сторы.
На каждый pull request (Linux)
flutter pub get --enforce-lockfile, dart format --set-exit-if-changed ., flutter analyze --fatal-infos, flutter test --coverage
Правила из пакетов на основе custom_lint flutter analyze не запускает; если проект их использует, добавьте dart run custom_lint отдельным шагом
Если сгенерированные файлы не закоммичены, сначала запускайте build_runner; устаревшие запуски отменяйте через группу concurrency
На main или на тег
Собирайте .aab и .ipa с нужным flavor, --dart-define-from-file, номером сборки из CI и --obfuscate --split-debug-info; символы загружайте в систему сбора крашей
Автоматически отправляйте в Firebase App Distribution или TestFlight и во внутренний трек Play; перевод в production ждёт ручного подтверждения
Раннеры и воспроизводимость
Linux для всего, что не требует Xcode; macOS только для iOS-сборок, потому что он медленнее и стоит в несколько раз дороже за минуту
Фиксируйте Flutter SDK через flutter-version-file: pubspec.yaml (точная версия flutter: в environment) и фиксируйте Xcode, чтобы CI не менялся у вас под ногами
Ключи подписи и доступы к сторам хранятся только в protected environments и никогда не доступны для pull request'ов из форков
Готовое к релизу приложение воспроизводимо собирается и подписывается в CI, сообщает вам, когда ломается у пользователей, может быть принудительно обновлено, имеет рискованные функции, которые можно отключить удалённо, и соответствует текущим техническим требованиям и требованиям приватности каждого стора.
Сборка и идентичность
Подписанный релиз из CI с номером сборки, который только растёт; flavors разделяют staging и production
--obfuscate --split-debug-info, а Dart-символы и dSYM для iOS загружены в Crashlytics или Sentry, иначе релизные стектрейсы не прочитать
Иконка, сплэш, отображаемое имя и тексты разрешений (NSCameraUsageDescription и подобные) проверены для production-flavor
Видимость у пользователей
Сбор крашей подключён и к FlutterError.onError, и к PlatformDispatcher.instance.onError
Аналитика по ключевой воронке и мониторинг производительности для времени запуска и медленных кадров
В логах нет токенов и персональных данных
Управление после запуска
Удалённые feature flags или kill switch для рискованных функций
Минимальная поддерживаемая версия проверяется при старте, чтобы можно было принудительно обновить приложение
План раскатки: процентная раскатка в Play, phased release в App Store и ответственный, который следит за crash-free rate
Правила сторов
Google Play: целевой API level, который Play требует сейчас (повышается каждый год), поддержка страниц памяти 16 KB для приложений с targetSdk Android 15+, форма Data safety
App Store: минимальные версии Xcode и SDK, которые Apple принимает к загрузке, privacy manifest PrivacyInfo.xcprivacy (у плагинов свои), метки App Privacy, удаление аккаунта внутри приложения, если пользователи могут его создать
Оба стора: ссылка на политику конфиденциальности и заметки для ревью с демо-аккаунтом
Прежде чем что-то менять, посмотрите время каждого шага. Почти в каждом медленном Flutter-пайплайне ответ один: холодные кеши плюс macOS-раннер, который делает работу, доступную Linux-раннеру.
Сначала кеширование: Flutter SDK, pub-кеш с ключом по pubspec.lock, Gradle по Gradle-файлам, CocoaPods по Podfile.lock; ключ с именем ветки промахивается на каждой новой ветке, так что проверяйте процент попаданий
Разделите линии: форматирование, анализ и юнит-тесты на Linux для каждого PR; платформенные сборки медленные, а минуты macOS в несколько раз дороже, поэтому запускайте их на main, тегах или по метке
Распараллельте: матрица джоб вместо одного длинного скрипта, юнит-тесты с шардированием через --total-shards и --shard-index, golden- и интеграционные тесты в отдельных джобах
Падайте быстро: форматирование и анализ запускайте один раз, а не в каждом шарде, а устаревшие запуски отменяйте группой concurrency
Уберите лишнее: никогда не делайте flutter clean в CI, он выбрасывает только что восстановленный кеш; кодогенерацию запускайте, только если изменились её входы, или коммитьте её результат
Поэтапная раскатка ограничивает число пользователей, до которых дойдёт плохая сборка, а заранее подготовленный план реагирования позволяет остановить ущерб, не дожидаясь ревью стора.
Раскатка
Google Play: production-трек раскатывается по процентам (например, 1%, 5%, 20%, 50%, 100%), с сутками и больше на каждом шаге; остановить и возобновить можно в любой момент
App Store: phased release растягивает автоматические обновления на 7 дней, и его можно поставить на паузу; вручную пользователи обновиться всё равно могут, так что охват ограничивается, но не блокируется
Следите за сигналами: crash-free users в сравнении с прошлой версией, критичные воронки (вход, оформление заказа), доля ошибок бэкенда и новые отзывы; заранее решите, кто следит и какая цифра останавливает раскатку
Если сломано, сначала самый дешёвый шаг
Выключите удалённо: feature flag или kill switch отключает сломанный путь за секунды, без сборки и ревью; именно поэтому рискованные функции выпускают за флагом
Исправьте на сервере: часто это меньше правки на клиенте, и она сразу доходит до всех установленных версий
Остановите раскатку: новые пользователи перестанут получать сборку, но тем, у кого она уже стоит, это не поможет
Выпустите исправление: Dart-патч через code push, если релиз под него собирался, иначе новая сборка с более высоким номером версии (откатиться на старый бинарник сторы не дают); при настоящей поломке попросите у Apple ускоренное ревью
Принудительное обновление: минимальная поддерживаемая версия на сервере как крайняя мера для сборки, которая портит данные или ломает API
Shorebird доставляет новый Dart-код в опубликованное приложение: shorebird release собирает версию для стора, shorebird patch выпускает исправление на следующий запуск. Нативный код, плагины и обновление Flutter требуют релиза в сторе; обновиться заставляет минимальная версия, проверяемая на сервере.
Как это работает
Сборка для стора должна быть сделана через Shorebird, который поставляет модифицированный движок Flutter; сборку из обычного flutter build пропатчить нельзя
Патч привязан к одной версии релиза, поэтому для каждого поддерживаемого релиза выпускается свой патч
На iOS изменённые функции выполняются интерпретатором, чтобы соответствовать правилам App Store, а неизменённый код остаётся AOT-скомпилированным; сильно пропатченные горячие участки могут замедлиться
По умолчанию патч применяется при следующем перезапуске; пакет shorebird_code_push позволяет приложению самому проверить и скачать патч
Ограничения
Правила сторов разрешают исправлять ошибки, а не менять суть приложения; всё крупное по-прежнему идёт через ревью
Это сторонний сервис в вашем релизном пути: учитывайте его цену, доступность и то, как откатить неудачный патч
Принудительное обновление
Храните минимальную поддерживаемую версию на сервере (Remote Config или свой API) и проверяйте её при старте; если версия ниже, показывайте блокирующий экран со ссылкой на стор
На Android у in-app updates из Play есть немедленный режим (пакет in_app_update); на iOS аналога нет, поэтому механизмом служит проверка на сервере
Сохраняйте обратную совместимость API для всех версий выше минимальной, чтобы принуждение оставалось исключением
Фильтруйте по графу зависимостей, а не по папке, которую кто-то тронул: Melos умеет выбирать пакеты, изменённые с определённого git-ref (ветки или коммита), плюс все пакеты, которые от них зависят, и CI анализирует и тестирует только этот набор.
Фильтр: diff: origin/main вместе с includeDependents: true; без зависимых пакетов ломающее изменение в core не протестирует никто, потому что сам core по-прежнему проходит
Workspace: начиная с Dart 3.6 pub workspace (workspace: в корне, resolution: workspace в каждом пакете) даёт один корневой pubspec.lock, который заодно служит ключом кеша; Melos 7 читает свою конфигурацию из корневого pubspec.yaml в секции melos:
**fetch-depth: 0**: actions/checkout по умолчанию клонирует один коммит, поэтому у diff нет базы, и он либо падает, либо вырождается в полный прогон
Изменения вне пакетов: корневой lockfile, корневой pubspec.yaml и файл workflow затрагивают всё, поэтому распознавайте их и переключайтесь на полный прогон
Граф должен быть честным: пакет, который импортируют, не объявив в pubspec.yaml, невидим для фильтра и будет пропущен