Мобильная разработка Релиз и сторы: подписи, ревью, поэтапная раскатка, аналитика и крэши
0%

Релиз и сторы: подписи, ревью, поэтапная раскатка, аналитика и крэши

Релиз и сторы: подписи, ревью, поэтапная раскатка, аналитика и крэши

В вебе релиз — это операция над сервером, который принадлежит вам. Выкатили, увидели всплеск ошибок, откатили: пять минут, и в проде снова старая версия для всех без исключения. Вся культура непрерывной доставки построена на этом свойстве — дешёвом и полном откате. Оно настолько привычно, что его перестают замечать, и именно поэтому первый мобильный релиз ломает людям картину мира.

На телефоне ваш код физически лежит на чужом устройстве. Вы не можете его удалить, не можете заменить без согласия пользователя и системы обновлений, не можете даже гарантированно узнать, что именно у него сейчас установлено. Между вашим git tag и запуском кода стоят: сборочный конвейер, криптографическая подпись, посредник-магазин с собственным ревью, механизм постепенной выдачи, настройки автообновления на устройстве и, наконец, желание человека нажать «Обновить». Каждое из этих звеньев добавляет задержку и может отказать.

Отсюда единственный тезис статьи, из которого выводится всё остальное: мобильный релиз необратим, поэтому вся инженерия релиза — это управление риском до выпуска и наличие рубильников после. Не «как быстро выкатить», а «как выкатить так, чтобы ошибка стоила процент аудитории, а не всю».

Эта статья закрывает трек. Она опирается на платформы (там разобрано, как устроены .ipa и .aab и базовая механика подписи), на тестирование (что должно упасть до сабмита) и на производительность (что именно мы будем мерить на раскатке).

Чем мобильный релиз отличается от веб-деплоя

Свойство Веб-деплой Мобильный релиз
Откат секунды, полный, для всех невозможен: только новая версия вперёд
Посредник нет App Review / Play policy review, от часов до дней
Скорость доставки исправления минуты часы (в лучшем случае) до дней
Версий в проде одновременно одна, иногда две при канареечном деплое десятки, хвост тянется годами
Контроль над артефактом полный, можно пересобрать артефакт неизменен после подписи
Наблюдаемость серверные логи по каждому запросу телеметрия по сети, с потерями и задержкой
Стоимость плохого релиза минуты простоя однозвёздочные отзывы, которые не удаляются
Обязательная работа без изменений продукта нет ежегодное повышение targetSdk, обновления SDK

Последняя строка удивляет новичков сильнее всего: замороженный мобильный продукт всё равно требует релизов. Google Play ежегодно поднимает минимальный targetSdk для обновлений, Apple периодически требует сборки новым SDK, сертификаты истекают, а сторонние SDK выпускают обязательные обновления безопасности. Мобильное приложение — это не «сделали и забыли», это подписка на поддержку.

Инвариант первый: у вас в проде всегда живёт N версий

Через месяц после релиза 3.0 у вас в бою будут 3.0, 2.9, 2.8 и хвост из версий двухлетней давности — на устройствах, где выключено автообновление, где не хватает места или где ОС больше не получает обновлений. Типичная картина: за первые 72 часа обновляются 40–60% активных пользователей, за две недели — 80–90%, оставшиеся 10% размазаны на месяцы.

Из этого следуют два жёстких инженерных правила.

Правило API-совместимости. Сервер обязан отвечать всем версиям, которые вы когда-либо выпускали, пока их доля не станет пренебрежимой. Ломающее изменение контракта — это не «релиз бэкенда», это операция, которая начинается с проверки статистики версий и заканчивается forced update. Аддитивные изменения, версионирование эндпоинтов и толерантный разбор ответов (см. безопасность API) на мобильном перестают быть теорией, потому что клиента нельзя «просто обновить».

Правило минимальной версии. В приложении с первого дня должен быть механизм принудительного обновления: сервер отдаёт минимально поддерживаемую версию, клиент сравнивает и показывает блокирующий экран. Добавить это в версии 4.0 бесполезно — вам понадобится заблокировать 3.x, а в ней механизма нет.

// Android: проверка минимальной версии — первый сетевой вызов приложения.
// Ответ кэшируется, но при отсутствии сети действует последний известный ответ,
// а не «пропустить проверку»: иначе offline становится обходом рубильника.
data class VersionGate(val minSupported: Long, val recommended: Long, val message: String)

fun evaluate(current: Long, gate: VersionGate): GateDecision = when {
    current < gate.minSupported -> GateDecision.Block(gate.message)   // экран «обновитесь»
    current < gate.recommended  -> GateDecision.Suggest(gate.message) // мягкое предложение
    else                        -> GateDecision.Proceed
}

На Android у принудительного обновления есть штатный путь — In-App Updates API: IMMEDIATE-режим показывает системный экран обновления прямо в приложении, FLEXIBLE качает в фоне. На iOS аналога нет — только свой экран со ссылкой в App Store.

Версии и номера сборок: скучное, что ломает всё

Две платформы, две пары чисел, разная семантика — и это источник половины релизных инцидентов.

Платформа Маркетинговая версия Номер сборки Ограничения
iOS CFBundleShortVersionString, например 3.4.1 CFBundleVersion, например 3.4.1.2841 номер сборки строго возрастает в пределах маркетинговой версии
Android versionName, строка любого вида versionCode, целое число монотонно возрастает, максимум 2 100 000 000

Практика, которая избавляет от целого класса проблем: человек редактирует только маркетинговую версию, номер сборки генерирует CI. Удобная схема для Android — versionCode как монотонный счётчик сборок CI или закодированная дата (YYMMDDNN), но никогда — «увеличу вручную перед сабмитом».

// build.gradle.kts — номер сборки приходит из CI, версия из файла в репозитории
val buildNumber = (System.getenv("CI_BUILD_NUMBER") ?: "0").toInt()
android {
    defaultConfig {
        versionName = providers.fileContents(
            layout.projectDirectory.file("version.txt")
        ).asText.get().trim()                    // 3.4.1
        versionCode = 1_000_000 + buildNumber    // монотонность гарантирует CI
    }
}
# iOS: номер сборки из CI, версия из файла — оба фиксируются в Info.plist перед архивом
agvtool new-version -all "${CI_BUILD_NUMBER}"
agvtool new-marketing-version "$(cat version.txt)"

Ключевой артефакт релиза — отображение «версия + номер сборки → git commit». Без него крэш-репорт из прода невозможно сопоставить с кодом, а «воспроизвели на main» превращается в гадание. Записывайте коммит в сборку и отправляйте его в аналитику и крэш-сервис как атрибут релиза.

Подписи: две разные модели доверия

Подпись — это ответ на вопрос «почему устройство должно доверять этому файлу». Платформы отвечают на него по-разному, и разница определяет, что именно вас погубит.

iOS: подпись как разрешение Apple. Цепочка: корневой сертификат Apple → ваш distribution-сертификат (приватный ключ у вас) → provisioning profile, подписанный Apple и перечисляющий App ID, entitlements и устройства → codesign, который кладёт хеши всех файлов в _CodeSignature. Профиль истекает через год, и сборка ломается «сама собой», без единого коммита. Ключ можно потерять и перевыпустить — это неприятно, но не смертельно.

Android: подпись как идентичность издателя. Система требует одного: обновление подписано тем же ключом, что и установка. Никакого центрального удостоверяющего центра нет. До появления Play App Signing потеря keystore означала конец приложения: обновить нельзя, только публиковать заново под новым applicationId, потеряв всю базу, отзывы и позиции. Сейчас рабочий ключ хранит Google, а вы держите upload key — его потеря решается заявкой в поддержку.

// build.gradle.kts: секреты только из окружения, никогда из репозитория
android {
    signingConfigs {
        create("release") {
            storeFile = file(System.getenv("UPLOAD_KEYSTORE_PATH") ?: "/dev/null")
            storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD")
            keyAlias = System.getenv("UPLOAD_KEY_ALIAS")
            keyPassword = System.getenv("UPLOAD_KEY_PASSWORD")
            enableV3Signing = true   // v3 нужен для ротации ключа через proof-of-rotation
            enableV4Signing = true   // v4 — быстрая инкрементальная установка
        }
    }
}
# Проверить, чем реально подписан артефакт, — до того, как это сделает пользователь
apksigner verify --print-certs app-release.apk
codesign -dv --entitlements :- Shop.app     # iOS: какие entitlements реально в бинаре
security cms -D -i embedded.mobileprovision # что внутри профиля и когда он истекает

Практики, которые отделяют работающий конвейер от «собирается только у Пети».

  • Ключи не живут в репозитории. Для iOS распространён fastlane match: сертификаты и профили лежат в отдельном зашифрованном репозитории, агент CI расшифровывает их в временный keychain и удаляет после сборки. Для Android — секреты CI и Play App Signing. Общий подход к секретам — в статье про управление секретами.
  • Доступ к магазину — по API-ключу, а не по логину человека. App Store Connect API key и Google Play service account. Учётка, привязанная к сотруднику, — это гарантированный инцидент в день его увольнения или отпуска.
  • Срок годности в календаре. Provisioning profile, distribution-сертификат, APNs-ключ, ключ подписи push — у всего есть дата истечения. Заведите напоминание за месяц; иначе узнаете в момент, когда нужен хотфикс.
  • Bus factor ключей. Ответ на вопрос «кто может выпустить релиз, если завтра ключевой инженер недоступен» должен быть записан и проверен, а не предполагаться.

Типичная ошибка, о которой стоит знать заранее: debug- и release-сборки подписаны разными ключами, а многие сервисы (Firebase, карты, вход через соцсети) привязываются к отпечатку SHA-1/SHA-256. При Play App Signing отпечаток рабочего ключа отличается от upload-ключа — забыли добавить его в консоль сервиса, и авторизация работает везде, кроме прода.

Конвейер: от коммита до магазина

Отдельно стоит подчеркнуть узел HOLD. И App Store Connect, и Play Console умеют «одобрить, но не публиковать»: вы отправляете сборку заранее, проходите ревью, и жмёте кнопку публикации, когда готовы вы, а не когда закончил ревьюер. Это превращает непредсказуемое ревью в предсказуемое окно и стоит ровно одну галочку в настройках релиза.

Артефакты и символы: что уезжает пользователю, а что остаётся у вас

Путь артефакта от вашей сборки до конкретного устройства и отдельный маршрут символов

Два практических следствия из этой схемы.

Размер, который видит пользователь, — не размер вашего файла. Play собирает split APK под конкретное устройство, App Store делает app thinning. Мерить нужно download size из консоли магазина, а не вес артефакта в CI. Размер — не эстетика: исследование Google Play показывало, что каждые примерно 6 МБ размера установки стоят около 1% конверсии установки, и на развивающихся рынках эффект сильнее. Проверять свою реальную сборку нужно так:

# Android: собрать ровно те APK, которые поедут на подключённое устройство
bundletool build-apks --bundle=app-release.aab --output=app.apks \
  --connected-device --ks="$UPLOAD_KEYSTORE_PATH" --ks-key-alias="$UPLOAD_KEY_ALIAS"
bundletool get-size total --apks=app.apks --dimensions=SDK,ABI,SCREEN_DENSITY,LANGUAGE

Символы уходят другим маршрутом, и их легко потерять навсегда. Релизная сборка оптимизирована и обфусцирована: имена классов заменены на a.b.c, кадры стека — на адреса. Восстановить исходные имена можно только файлом символов, соответствующим именно этой сборке. Потеряли — крэш-репорт превращается в бесполезный шум.

# iOS: dSYM для сборки; при включённом Bitcode-подобном пересборе символы забирают из App Store Connect
fastlane run upload_symbols_to_crashlytics dsym_path:"Shop.app.dSYM.zip"

# Android: R8 mapping и нативные символы NDK
./gradlew :app:uploadCrashlyticsMappingFileRelease
zip -r symbols.zip app/build/intermediates/merged_native_libs/release/out/lib/

# Flutter: обфускация возможна только вместе с сохранением символов
flutter build appbundle --obfuscate --split-debug-info=build/symbols
// React Native: без source map стек Hermes нечитаем.
// Карты генерируются при релизной сборке и грузятся в сервис крэшей вместе с версией.
// sentry-cli releases files "com.shop@3.4.1+2841" upload-sourcemaps ./dist --rewrite
export const releaseId = `${appVersion}+${buildNumber}`; // тот же ключ, что и в сборке

Правило простое: выгрузка символов — шаг конвейера, а не ручная операция, и конвейер должен падать, если выгрузка не удалась. Иначе вы узнаете о проблеме в момент первого серьёзного крэша.

Каналы доставки: где приложение живёт до релиза

Канал Платформа Аудитория Задержка Ревью
Internal App Sharing Android по ссылке, без лимита треков минуты нет
Internal testing Android до 100 тестеров минуты нет
Closed / Open testing Android список или все желающие часы политики, легче полного
Production (staged) Android вся аудитория, доля задаётся часы да
TestFlight internal iOS до 100 членов команды минуты нет
TestFlight external iOS до 10 000 тестеров часы–сутки облегчённое beta review
Ad Hoc / Enterprise iOS по UDID / внутри компании минуты нет
Firebase App Distribution обе своя, кросс-платформенная минуты нет

Практические заметки. TestFlight-сборка живёт 90 дней и потом перестаёт запускаться — не делайте её основой долгих пилотов. Для новых личных аккаунтов Google Play действует требование закрытого тестирования реальными тестерами в течение продолжительного периода перед первой публикацией — это планируется заранее, а не обнаруживается за день до запуска. Internal App Sharing — самый быстрый способ отдать сборку QA: ссылка, без версий, без ожидания.

Для кроссплатформенных команд Firebase App Distribution удобен тем, что даёт один канал для обеих платформ и не заставляет тестировщиков заводить учётки в двух консолях.

Ревью: процесс, а не лотерея

Ревью ощущается как случайность, но ведёт себя как процесс с известными входами. Соответственно, к нему готовятся.

Что реально влияет на исход.

  • Демо-аккаунт и инструкция. Ревьюер не должен упереться в SMS-код на номер, которого у него нет. Логин, пароль, способ получить одноразовый код, видео сложного сценария — в заметках для ревью.
  • Метаданные и скриншоты — часть сабмита. Отказ за скриншот, не соответствующий приложению, или за описание с обещаниями, которых нет, встречается не реже отказа за код.
  • Декларации приватности. Apple требует privacy manifest и точный ответ на вопросы о собираемых данных, а обращение к рекламному идентификатору — через ATT-диалог. Google Play требует раздел Data Safety. Расхождение декларации с фактическим поведением SDK — не замечание, а снятие. Аудит того, что собирают ваши зависимости, — обязательная часть релизного чеклиста; смежная тема — цепочка поставки.
  • Ускоренное рассмотрение существует. Apple даёт expedited review, его берегут для настоящих инцидентов: попросить трижды за месяц по пустякам — потерять инструмент.
  • Отказ — не катастрофа. Ревьюер цитирует пункт руководства; вы либо исправляете, либо возражаете в Resolution Center с аргументами. Апелляция иногда работает, особенно когда правило применено к случаю, для которого не предназначалось.
  • Календарь. Не сабмитьте в пятницу и перед крупными праздниками: очередь ревью растёт, а команда, которая будет чинить последствия, уйдёт на выходные.

Актуальные тексты правил живут в App Review Guidelines и Google Play Developer Policy — их читают не «когда отказали», а до того, как проектировать функциональность вроде внешней оплаты или загрузки пользовательского контента.

Поэтапная раскатка и метрические гейты

Ступени раскатки, гейты по метрикам и то, чего не умеет остановка раскатки

Механика различается. App Store phased release — фиксированное расписание на 7 дней (1-2-5-10-20-50-100%), действует только на автообновления, пользователь может обновиться вручную в любой момент; раскатку можно поставить на паузу до 30 дней или выпустить на всех сразу. Google Play staged rollout — доля задаётся вами вручную, повышается когда захотите, останавливается кнопкой halt.

Ключевой вопрос ступеней — статистический. Смысл первой ступени в том, чтобы регрессия стала видна раньше, чем её увидит вся аудитория. Если у вас 5 тысяч активных пользователей в сутки, то 1% — это 50 человек, и рост крэшей с 0,3% до 0,8% в этой выборке неотличим от шума: ждать придётся дни, за которые вы всё равно раскатаете дальше. Практический вывод: малым продуктам первая ступень нужна крупнее (10–20%), крупным — мельче (0,5–1%). Ориентир простой: ступень должна давать хотя бы несколько тысяч сессий в сутки, иначе гейт не гейт, а ритуал.

Что проверяется на гейте — минимум:

Метрика Откуда Ориентир
Crash-free sessions сервис крэшей не хуже предыдущей версии более чем на 0,1 п.п.
Crash-free users сервис крэшей ключевая метрика для продукта, менее шумная
ANR rate Android Vitals «плохой» порог Play — 0,47% пользователей
Crash rate Android Vitals «плохой» порог Play — 1,09% пользователей
Hang rate, время старта MetricKit, App Store Connect не хуже предыдущей версии
Конверсия ключевого экрана продуктовая аналитика сравнение когорт по версии
Доля ошибок API от новой версии серверные логи всплеск 4xx/5xx на новых эндпоинтах

Пороги Play — не абстракция: превышение «bad behaviour thresholds» бьёт по видимости приложения в магазине, то есть по установкам и деньгам, а не только по совести.

Важно понимать границу возможностей halt (она же — главная мысль схемы выше). Остановка раскатки замораживает выдачу: новые пользователи не получат сборку. Те, кто уже обновился, останутся на сломанной версии до следующего релиза. Поэтому реальный набор инструментов реагирования выглядит так:

Вывод, который стоит запомнить: единственные быстрые рубильники — те, что вы выпустили заранее. Feature flag, remote config, серверная логика, версия-гейт. Флаг, добавленный в хотфикс, не помогает в инциденте — он помогает в следующем.

Дисциплина флагов на мобильном строже, чем в вебе, из-за оффлайна:

// Значение по умолчанию — то, что работает без сети и без конфига.
// Новая функциональность по умолчанию ВЫКЛЮЧЕНА: если конфиг не пришёл,
// пользователь получает поведение прошлой версии, а не полуфабрикат.
struct FeatureFlags {
    private let remote: RemoteConfigReading
    private let defaults: [String: Bool] = [
        "new_checkout": false,      // раскатывается постепенно
        "legacy_fallback": true     // страховочный путь оставляем включённым
    ]

    func isEnabled(_ key: String) -> Bool {
        remote.cachedBool(key) ?? defaults[key] ?? false
    }
}

И обязательная гигиена: у каждого флага есть срок жизни. Флаг, доживший до третьего релиза после полной раскатки, — это ветвь кода, которую никто не тестирует, и она однажды сработает.

Аналитика релиза: что мерить и как не убить батарею

Мобильная аналитика отличается от веб-аналитики тремя вещами: события уходят с задержкой, часть теряется навсегда, и за каждый лишний запрос платит батарея пользователя. Отсюда — стандартная архитектура: события пишутся в локальную очередь, батчатся, отправляются по сети когда уместно, подтверждаются и удаляются. Ровно та же машинерия, что и в данных и оффлайне, включая ключи идемпотентности, чтобы повтор отправки не удвоил метрики.

// Dart: очередь событий с батчингом. Отправка — не «сразу», а по триггеру:
// накопилось N событий, прошло T секунд, приложение уходит в фон.
class AnalyticsQueue {
  final EventStore _store;   // локальная БД: переживает перезапуск процесса
  final AnalyticsApi _api;

  Future<void> track(String name, Map<String, Object?> props) async {
    await _store.append(Event(
      id: uuidV7(),                       // ключ идемпотентности
      name: name, props: props,
      clientTs: DateTime.now().toUtc(),   // время клиента — недоверенное
      appVersion: buildInfo.versionWithBuild, // разрез по версии обязателен
      sessionId: session.id,
    ));
    if (await _store.count() >= 50) unawaited(flush());
  }

  Future<void> flush() async {
    final batch = await _store.take(50);
    if (batch.isEmpty) return;
    final accepted = await _api.send(batch); // сервер вернёт принятые id
    await _store.deleteByIds(accepted);      // удаляем только подтверждённые
  }
}

Четыре нюанса, которые отличают работающую мобильную аналитику от бесполезной.

  1. Часы клиента врут. Пользователь переводит время, устройство лежало в самолёте три дня. Храните и клиентское время события, и серверное время приёма; для когортного анализа используйте серверное, для последовательности внутри сессии — монотонный счётчик.
  2. Флаш при уходе в фон обязателен. Иначе события последней сессии перед удалением приложения не дойдут никогда — а это ровно та сессия, которая интересна.
  3. Версия — обязательный атрибут каждого события. Без разреза по версии сравнить релизы невозможно, а именно это вы и делаете на каждом гейте.
  4. Согласие и PII. Сбор до получения согласия — регуляторный риск и повод для снятия с публикации; персональные данные в свойствах событий — утечка через третью сторону. То, что не нужно для решения, не собирается вовсе.

Модель данных для анализа здоровья релиза выглядит примерно так — и она объясняет, почему «версия» должна быть отдельной сущностью, а не строкой в свойстве:

Разрез crash_group × build с флагом regression — главный экран релиз-менеджера: он отвечает не на вопрос «сколько у нас крэшей», а на вопрос «что сломала именно эта версия».

Отдельная тема — атрибуция установок. На iOS она работает через SKAdNetwork и AdAttributionKit с агрегированными, задержанными и зашумлёнными данными; на Android — через Play Install Referrer и Privacy Sandbox. Практический вывод для инженера: точных пользовательских воронок «клик по рекламе → покупка» больше нет, и продуктовые решения строятся на агрегатах. Связь технических метрик с продуктовыми разобрана в продуктовых метриках.

Крэши: от падения на устройстве до строчки кода

Из этой диаграммы следует контринтуитивное: отчёт о крэше приходит не в момент крэша, а при следующем запуске. Пользователь, который после падения удалил приложение, в статистику не попадёт никогда — реальный процент хуже наблюдаемого. По той же причине всплеск крэшей виден с задержкой в десятки минут, и гейт «подождём 15 минут и раскатим дальше» бесполезен.

Что именно ловится и чем:

Тип отказа Видно в Особенность
Необработанное исключение Crashlytics, Sentry, консоли магазинов самый простой случай, стек есть
Нативный сигнал (SIGSEGV, SIGABRT) те же нужны символы .so и системные символы
ANR (Android) Play Vitals, ApplicationExitInfo главный поток заблокирован свыше 5 с; стек всех потоков
Watchdog termination (iOS) MetricKit, Xcode Organizer «0x8badf00d»: система убила за долгий старт или зависание
OOM / jetsam MetricKit, Play Vitals это не крэш: обработчик не сработает, ищите по отсутствию корректного завершения
Fatal в кроссплатформенном слое Sentry с source maps / Dart symbols стек в двух слоях: JS/Dart и нативный
// Android: причины прошлых завершений процесса — единственный надёжный способ
// узнать про ANR и убийства системой, включая low memory kill
getSystemService(ActivityManager::class.java)
    .getHistoricalProcessExitReasons(packageName, /* pid = */ 0, /* maxNum = */ 10)
    .forEach { info ->
        when (info.reason) {
            ApplicationExitInfo.REASON_ANR -> track("exit_anr", info.description)
            ApplicationExitInfo.REASON_LOW_MEMORY -> track("exit_oom", info.rss)
            ApplicationExitInfo.REASON_CRASH_NATIVE -> track("exit_native", info.status)
        }
    }
// iOS: MetricKit раз в сутки отдаёт агрегированные диагностики —
// зависания, время старта, дисковые операции и отчёты о падениях
final class MetricsReceiver: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for p in payloads {
            p.hangDiagnostics?.forEach { report("hang", $0.hangDuration, $0.callStackTree) }
            p.crashDiagnostics?.forEach { report("crash", $0.exceptionCode, $0.callStackTree) }
        }
    }
}

Политика реагирования, которая работает на практике: пороги задаются заранее и в цифрах. Например: crash-free sessions ниже 99,5% — раскатка останавливается автоматически; новая группа крэшей, затронувшая свыше 0,1% пользователей, — алерт дежурному; ANR выше 0,4% — блокирующий гейт. Дежурство и устройство алертов ничем принципиально не отличаются от серверного — см. наблюдаемость и дежурства; отличие в том, что кнопки «откатить» у дежурного нет, и в раннере лежат только флаги.

Хотфикс: что можно починить без релиза

Порядок эскалации от дешёвого к дорогому:

  1. Серверное изменение. Контракт, фича-тоггл, конфигурация — минуты, доходит до всех версий.
  2. Remote config / feature flag. Минуты–часы (зависит от политики кэширования), доходит до всех, у кого есть сеть и кто уже получил флаг в бинаре.
  3. Forced update через version gate. Работает, но требует, чтобы пользователь обновился.
  4. Хотфикс-сборка с ускоренным ревью. Часы, дальше — раскатка и ожидание установки.
  5. OTA-обновление кода (React Native / Expo Updates и аналоги). Позволяет заменить JS-бандл без ревью, но: правила Apple разрешают загружаемый интерпретируемый код, только если он не меняет назначение приложения и не обходит ревью; Google Play формулирует похожие ограничения. Нативный код так не обновить в принципе, а Flutter в релизной сборке компилируется в машинный код — OTA для него неприменим. OTA — инструмент быстрого исправления, а не способ жить без ревью.

Регламент, который стоит записать до первого инцидента: кто объявляет инцидент, кто имеет право дёрнуть флаг, кто общается с поддержкой и магазинами, где ведётся хронология. Отдельный пункт — однозвёздочные отзывы: их не удаляют, но и Apple, и Google дают отвечать публично, и корректный ответ с датой исправления заметно смягчает падение рейтинга.

Релизный поезд: практика

Регулярный ритм побеждает героические выпуски: чем чаще релизы, тем меньше в каждом изменений и тем дешевле ошибка. Типовой двухнедельный цикл:

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

Чеклист перед сабмитом, который экономит больше всего времени:

  • сборка собрана из release-ветки, номер сборки уникален, git-коммит записан в артефакт;
  • символы (dSYM / mapping.txt / .so / Dart / source maps) выгружены и проверены на тестовом крэше;
  • смоук на минимальной поддерживаемой версии ОС и на самом слабом устройстве из поддерживаемых;
  • проверены: чистая установка, обновление поверх предыдущей версии, миграция локальной БД, вход и восстановление сессии, поведение без сети;
  • метаданные, скриншоты, что нового, демо-аккаунт и заметки для ревью актуальны;
  • privacy manifest / Data Safety соответствуют фактическому набору SDK;
  • флаги новых функций выключены по умолчанию, дефолты безопасны при отсутствии сети;
  • пороги гейтов и дежурный на период раскатки назначены.

Натив и кроссплатформа глазами релиз-инженера

Стек влияет на разработку, но релизный контур он почти не сокращает: два магазина, две подписи, два ревью, два набора метаданных остаются при любом выборе.

Аспект релиза Натив (Swift + Kotlin) Кроссплатформа (Flutter / RN / KMP)
Число конвейеров сборки два, независимые два (сборки всё равно платформенные), общий шаг компиляции
Число сабмитов два два
Рассинхрон версий естественный: платформы релизятся отдельно требует дисциплины — иначе фича «есть на Android»
Символикация штатные инструменты платформ плюс слой: Dart symbols, Hermes source maps
Отладка релизного крэша один стек два стека, поиск начинается с вопроса «чей это кадр»
Размер артефакта минимальный плюс рантайм: обычно единицы–десятки МБ сверху
OTA-исправления невозможны возможны только для JS-стеков, в границах правил
Кто нужен в команде релизный процесс знают обе платформенные команды нужен инженер, понимающий обе платформы, — узкое место найма

Честный вывод по стоимости: кроссплатформа экономит на разработке функциональности и почти не экономит на релизе и эксплуатации. По найму картина двойственная: Flutter- и RN-разработчиков на рынке много и они дешевле в среднем, но человек, который умеет разбирать релизный крэш в нативном слое под кроссплатформенным рантаймом, стоит дороже нативного специалиста и встречается реже. Команда из пяти Flutter-разработчиков без единого человека с нативным опытом рано или поздно упирается в задачу, которую некому решить, — и это не аргумент против кроссплатформы, а требование к составу команды. Подробное сравнение стоимости и найма — в кроссплатформе.

Типичные ошибки

  • Нет version gate с первого релиза. Обнаруживается в момент, когда нужно срочно отключить старых клиентов, и добавлять уже некуда.
  • Ключи и сертификаты у одного человека. Отпуск или увольнение превращаются в инцидент.
  • Символы не выгружаются автоматически. Первый серьёзный крэш прода приходит в виде адресов.
  • Раскатка «до утра». Гейт без назначенного дежурного и без порогов — это не гейт.
  • Первая ступень 1% при малом DAU. Статистики нет, время потрачено, ложное чувство безопасности.
  • Флаг по умолчанию включён. Первый же пользователь без сети получает недоделанную функцию.
  • Аналитика без разреза по версии. Сравнивать релизы нечем, регрессии не видны.
  • Сабмит в пятницу перед праздниками. Одобрят в субботу, чинить будет некому.
  • Декларация приватности «примерно как есть». Расхождение с поведением SDK — снятие с публикации.
  • Флаги живут вечно. Ветвление кода, которое никто не тестирует, и мина замедленного действия.
  • Один и тот же versionCode в двух сборках. Загрузка отклоняется, обычно в самый неподходящий час.
  • Забыли, что замороженный продукт требует релизов. Год без обновлений — и targetSdk больше не позволяет выпустить даже хотфикс.

Мини-итог

Мобильный релиз — это доставка неизменяемого артефакта через посредника на устройства, которые вам не принадлежат. Из необратимости следует всё: номера сборок генерирует CI, а не человек; ключи живут в хранилище секретов и доступны хотя бы двоим; символы выгружаются шагом конвейера, иначе крэш-репорты бесполезны; сборка отправляется на ревью заранее и держится до вашего окна публикации; выкатка идёт ступенями с метрическими гейтами, размер первой ступени определяется вашим DAU, а не модой; halt останавливает только выдачу, поэтому единственные быстрые рубильники — те, что уже выпущены: флаги, remote config, серверная логика и version gate. Аналитика обязана резаться по версии сборки, а крэши — приходить символицированными и сгруппированными, с явным флагом «регрессия этой версии». И, наконец, выбор нативного или кроссплатформенного стека меняет стоимость разработки, но почти не меняет стоимость релиза: два магазина, два ревью и одна необратимость остаются при любом решении.

Источники

Что дальше

Трек «Мобильная разработка» на этом закончен: от устройства платформ и жизненного цикла приложения — до архитектуры, оффлайна, фоновой работы, производительности, безопасности, тестирования и релиза. Куда идти дальше, зависит от того, что вы почувствовали своим слабым местом.

  • Слабое место — доставка и инфраструктура сборки. DevOps: CI/CD, облака и эксплуатация — конвейеры, стратегии релиза, наблюдаемость и дежурства; мобильный конвейер строится из тех же деталей.
  • Слабое место — качество до релиза. Тестирование и, в частности, мобильное и совместимостное тестирование.
  • Слабое место — граница с сервером. Распределённые системы — идемпотентность, доставка, согласованность; телефон в этой картине просто самый ненадёжный узел.
  • Слабое место — защита клиента и данных. Безопасность.
  • Слабое место — решения о продукте. Продуктовый менеджмент — метрики, эксперименты, приоритизация: то, ради чего вы и раскатываете релизы ступенями.
  • Хочется вглубь клиентской инженерии. Frontend — многие паттерны состояния и навигации общие, а различия объясняют, почему мобильный контекст жёстче.

И общая карта портала, если хочется выстроить дальнейший маршрут осознанно, — Роадмап: там видно, какие треки складываются в связные цепочки и в каком порядке их проходить.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов