Релиз и сторы: подписи, ревью, поэтапная раскатка, аналитика и крэши
В вебе релиз — это операция над сервером, который принадлежит вам. Выкатили, увидели всплеск ошибок, откатили: пять минут, и в проде снова старая версия для всех без исключения. Вся культура непрерывной доставки построена на этом свойстве — дешёвом и полном откате. Оно настолько привычно, что его перестают замечать, и именно поэтому первый мобильный релиз ломает людям картину мира.
На телефоне ваш код физически лежит на чужом устройстве. Вы не можете его удалить, не можете заменить без согласия пользователя и системы обновлений, не можете даже гарантированно узнать, что именно у него сейчас установлено. Между вашим 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); // удаляем только подтверждённые
}
}
Четыре нюанса, которые отличают работающую мобильную аналитику от бесполезной.
- Часы клиента врут. Пользователь переводит время, устройство лежало в самолёте три дня. Храните и клиентское время события, и серверное время приёма; для когортного анализа используйте серверное, для последовательности внутри сессии — монотонный счётчик.
- Флаш при уходе в фон обязателен. Иначе события последней сессии перед удалением приложения не дойдут никогда — а это ровно та сессия, которая интересна.
- Версия — обязательный атрибут каждого события. Без разреза по версии сравнить релизы невозможно, а именно это вы и делаете на каждом гейте.
- Согласие и PII. Сбор до получения согласия — регуляторный риск и повод для снятия с публикации; персональные данные в свойствах событий — утечка через третью сторону. То, что не нужно для решения, не собирается вовсе.
Модель данных для анализа здоровья релиза выглядит примерно так — и она объясняет, почему «версия» должна быть отдельной сущностью, а не строкой в свойстве:
Разрез crash_group × build с флагом regression — главный экран релиз-менеджера: он отвечает не на вопрос «сколько у нас крэшей», а на вопрос «что сломала именно эта версия».
Отдельная тема — атрибуция установок. На iOS она работает через SKAdNetwork и AdAttributionKit с агрегированными, задержанными и зашумлёнными данными; на Android — через Play Install Referrer и Privacy Sandbox. Практический вывод для инженера: точных пользовательских воронок «клик по рекламе → покупка» больше нет, и продуктовые решения строятся на агрегатах. Связь технических метрик с продуктовыми разобрана в продуктовых метриках.
Крэши: от падения на устройстве до строчки кода
никакой сети, никаких аллокаций SDK->>Disk: записать минимальный отчёт (стек, версия, устройство) Note over App: процесс умирает App->>SDK: следующий запуск приложения SDK->>Disk: найден незавершённый отчёт SDK->>Svc: отправить отчёт (уже есть сеть) Svc->>Sym: запросить dSYM / mapping.txt по версии и сборке alt символы выгружены Sym-->>Svc: таблица соответствия Svc->>Svc: символикация и дедупликация в группу Svc->>Team: алерт «новая регрессия в 3.4.1, 240 пользователей» Team->>Team: флаг или хотфикс else символы потеряны Sym-->>Svc: ничего Svc->>Team: набор адресов, отладке не подлежит end
Из этой диаграммы следует контринтуитивное: отчёт о крэше приходит не в момент крэша, а при следующем запуске. Пользователь, который после падения удалил приложение, в статистику не попадёт никогда — реальный процент хуже наблюдаемого. По той же причине всплеск крэшей виден с задержкой в десятки минут, и гейт «подождём 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% — блокирующий гейт. Дежурство и устройство алертов ничем принципиально не отличаются от серверного — см. наблюдаемость и дежурства; отличие в том, что кнопки «откатить» у дежурного нет, и в раннере лежат только флаги.
Хотфикс: что можно починить без релиза
Порядок эскалации от дешёвого к дорогому:
- Серверное изменение. Контракт, фича-тоггл, конфигурация — минуты, доходит до всех версий.
- Remote config / feature flag. Минуты–часы (зависит от политики кэширования), доходит до всех, у кого есть сеть и кто уже получил флаг в бинаре.
- Forced update через version gate. Работает, но требует, чтобы пользователь обновился.
- Хотфикс-сборка с ускоренным ревью. Часы, дальше — раскатка и ожидание установки.
- 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. Аналитика обязана резаться по версии сборки, а крэши — приходить символицированными и сгруппированными, с явным флагом «регрессия этой версии». И, наконец, выбор нативного или кроссплатформенного стека меняет стоимость разработки, но почти не меняет стоимость релиза: два магазина, два ревью и одна необратимость остаются при любом решении.
Источники
- Apple. App Store Connect: Distribute your app — сабмит, TestFlight, phased release, held-публикация.
- Apple. App Review Guidelines — актуальный текст правил, включая пункт о загружаемом коде.
- Apple. MetricKit — hang rate, время старта, диагностики падений от системы.
- Apple. Privacy manifest files и App Tracking Transparency.
- Apple. SKAdNetwork — агрегированная атрибуция установок.
- Android Developers. Sign your app — upload key, Play App Signing, ротация ключей.
- Android Developers. About Android App Bundles и bundletool — сплиты и измерение реального размера загрузки.
- Android Developers. Release with confidence: staged rollouts и In-App Updates API.
- Android Developers. Android vitals — пороги крэшей и ANR, влияющие на видимость в Play.
- Android Developers. ApplicationExitInfo — причины завершения процесса, включая ANR и OOM.
- Google Play. Developer Program Policy и Data safety.
- fastlane и match — автоматизация подписи и выкладки.
- Firebase. Crashlytics и App Distribution — крэши, символы, бета-каналы.
- Sentry for Mobile — символикация, source maps для Hermes, релизы и регрессии.
- Google Play. Shrinking APKs, growing installs — связь размера установки и конверсии.
- Forsgren N., Humble J., Kim G. «Accelerate» — метрики доставки; на мобильном частота релизов и время восстановления читаются иначе, но принципы те же.
Что дальше
Трек «Мобильная разработка» на этом закончен: от устройства платформ и жизненного цикла приложения — до архитектуры, оффлайна, фоновой работы, производительности, безопасности, тестирования и релиза. Куда идти дальше, зависит от того, что вы почувствовали своим слабым местом.
- Слабое место — доставка и инфраструктура сборки. DevOps: CI/CD, облака и эксплуатация — конвейеры, стратегии релиза, наблюдаемость и дежурства; мобильный конвейер строится из тех же деталей.
- Слабое место — качество до релиза. Тестирование и, в частности, мобильное и совместимостное тестирование.
- Слабое место — граница с сервером. Распределённые системы — идемпотентность, доставка, согласованность; телефон в этой картине просто самый ненадёжный узел.
- Слабое место — защита клиента и данных. Безопасность.
- Слабое место — решения о продукте. Продуктовый менеджмент — метрики, эксперименты, приоритизация: то, ради чего вы и раскатываете релизы ступенями.
- Хочется вглубь клиентской инженерии. Frontend — многие паттерны состояния и навигации общие, а различия объясняют, почему мобильный контекст жёстче.
И общая карта портала, если хочется выстроить дальнейший маршрут осознанно, — Роадмап: там видно, какие треки складываются в связные цепочки и в каком порядке их проходить.