Платформы: iOS и Android изнутри, отличия, ограничения, публикация
Мобильный разработчик, пришедший из веба, первые полгода живёт в тихом недоумении. Почему приложение «само» перезапустилось и потеряло заполненную форму? Почему таймер, исправно тикавший в браузере, на телефоне отстаёт на сорок минут? Почему один и тот же код на Pixel работает, а на Xiaomi через сутки перестаёт получать пуши? Почему релиз идёт четыре дня и заканчивается письмом «Guideline 4.2»?
Ответ один: вы больше не хозяин среды исполнения. В вебе платформа — браузер, обязанный выполнить ваш код. На мобильном платформа — операционная система с жёсткой моделью ресурсов, песочницей, правом убить ваш процесс в любой момент и магазином, решающим, попадёт ли ваш код к пользователям вообще. Кроссплатформенный фреймворк эту реальность прячет, но не отменяет: Flutter не отменит Doze, React Native не оформит за вас privacy manifest, KMP не пройдёт ревью. Дальше в треке будут нативная iOS, нативный Android и кроссплатформа — но выбирать между ними, не понимая, что именно вы абстрагируете, значит выбирать вслепую.
Что вообще значит «платформа»
Платформа — вертикальный стек: железо, ядро, рантайм, системные фреймворки, UI-слой и ваш код. Ключевое свойство мобильного стека в том, что вы владеете только верхушкой, а всё остальное обновляется отдельно от вас, по чужому расписанию и иногда ломает совместимость.
Отсюда два вывода, которые дороже любых деталей API. Первый. iOS вертикально интегрирована: Apple делает чип, ядро, рантайм, IDE, язык, магазин и обновления. Матрица устройств узкая, обновления ОС ставятся быстро и почти всем сразу, поэтому поддержки двух-трёх последних мажорных версий хватает для основного парка. Цена — вы играете по правилам одного вендора без апелляции. Второй. Android — открытая база AOSP, поверх которой OEM кладут свои ядра, HAL и оболочки. Одинаковый вызов API на Pixel и на устройстве с агрессивной оболочкой ведёт себя по-разному: где-то фоновый сервис живёт, где-то его убивают через десять минут. Цена открытости — тестовая матрица и защитное программирование как норма. Общая теория ядер и изоляции — в треке ОС: архитектуры ядер, процессы и планирование, изоляция.
Общий вектор эволюции обеих платформ один: возможности приложения последовательно сужаются — меньше фона, меньше данных, меньше доступа к файлам и к списку установленных программ. Архитектура, заложенная на «мы всегда сможем крутиться в фоне и знать всё об устройстве», сломается через два-три релиза ОС. Проектируйте от ограничений.
iOS изнутри
Под iOS живёт XNU — гибрид микроядра Mach и BSD-слоя. От Mach — задачи, потоки, порты и IPC; от BSD —
POSIX-вызовы, сокеты, файловая семантика. Процессы стартует launchd, динамические библиотеки грузит
dyld. Ваше приложение — это bundle MyApp.app: каталог с исполняемым файлом, Info.plist,
ресурсами и подписью, упакованный в .ipa. Интерпретатора нет: Swift компилируется AOT в машинный
ARM64 ещё на вашей машине.
Три свойства, из которых вытекает почти всё остальное:
- Обязательная подпись кода. AMFI (Apple Mobile File Integrity) проверяет подпись при каждом
execи при отображении страниц кода в память; страница не бывает одновременно записываемой и исполняемой. - Отсюда — запрет JIT. Сгенерировать машинный код в рантайме нельзя, поэтому JavaScript в React Native на iOS исполняется интерпретатором Hermes: это ограничение платформы, а не фреймворка.
- Песочница. У каждого приложения свой контейнер данных; общий доступ — только через явные механизмы: App Groups, Keychain access groups, share extensions.
Память управляется ARC — подсчётом ссылок, который компилятор расставляет сам. Это не сборщик мусора:
пауз GC нет, зато есть циклы ссылок, которые рвутся вручную — отсюда вездесущий [weak self] в
замыканиях. Практический смысл вертикальной интеграции: предсказуемая производительность, быстрый переход
парка на новые версии ОС, единый набор фреймворков — и единая точка отказа. Если Apple решила, что
ваш способ работы в фоне ей не нравится, обходного пути нет.
Android изнутри
Под Android — Linux (LTS-ядро с патчами вендора), но пользовательское пространство совсем не похоже на десктопное. Три вещи, которых нет на обычном сервере:
- Binder — основной механизм IPC, драйвер в ядре. Почти каждый «системный» вызов (
ActivityManager,PackageManager,LocationManager) — на самом деле удалённый вызов в процессsystem_server. Отсюда лимит транзакций около 1 МБ на процесс: попытка передать большойBundleроняет приложение черезTransactionTooLargeException. - zygote — процесс, который при загрузке прогревает ART и предзагружает системные классы, а затем форкает себя на каждое приложение. Поэтому холодный старт не требует поднимать рантайм заново, а системные классы шарятся через copy-on-write.
- Отдельный Linux UID на каждое приложение. Изоляция — это не только политика SELinux, но и обычные
права файловой системы:
/data/data/<package>принадлежит уникальному пользователю.
Рантайм — ART: гибрид AOT и JIT. Часть кода компилируется при установке и по профилям использования;
профили горячих методов копятся и уходят в Play, чтобы следующие пользователи получали Baseline Profile
заранее. Память управляется сборщиком мусора — главное отличие от iOS в профиле производительности:
паузы GC и аллокационное давление при прокрутке списков. Ещё одно фундаментальное отличие: на Android
приложение — не «программа с main», а набор компонентов, объявленных в манифесте, которые система
запускает независимо друг от друга: Activity (экран), Service (фоновая работа), BroadcastReceiver
(реакция на события), ContentProvider (выдача данных наружу). Система вправе поднять ваш Activity из
середины стека, а ContentProvider проинициализировать раньше Application.onCreate(). Отсюда правило:
никакой инициализации, которая обязана произойти «первой» — она однажды произойдёт не первой.
<!-- AndroidManifest.xml: компоненты и их намерения объявляются декларативно -->
<application android:name=".App" android:allowBackup="false">
<activity android:name=".ui.MainActivity" android:exported="true">
<intent-filter> <!-- «я — точка входа в лаунчере» -->
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Тип foreground-сервиса обязателен начиная с Android 14 -->
<service android:name=".sync.UploadService" android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
Приложение — не веб-страница: жизненный цикл
Отличие мобильного контекста номер один. В вебе вкладка живёт, пока её не закроют; на мобильном система произвольно останавливает и убивает процессы, а пользователь этого не замечает: он видит ту же карточку в переключателе задач и ожидает вернуться ровно туда, где был.
В Suspended процесс лежит в памяти, но не исполняется: таймеры стоят, сокеты рвутся, события смены
сети не приходят. ProcessDeath — самый недооценённый переход: onDestroy не вызывается, переживает
только явно сохранённое. Практическое следствие: состояние экрана обязано переживать смерть процесса
— это про корректность, а не про удобство. Пользователь заполняет длинную форму, уходит в мессенджер за
номером карты, возвращается — и если данные лежали в поле класса, всё пропало.
| Веб | iOS | Android | Что важно |
|---|---|---|---|
| Вкладка открыта | active |
RESUMED |
единственное состояние, где уместны анимации |
| Вкладка в фоне | background → suspended |
STOPPED |
таймеры и сеть не работают |
beforeunload |
нет аналога | нет гарантии | на «прощальный» вызов рассчитывать нельзя |
sessionStorage |
state restoration | SavedStateHandle |
переживает пересоздание процесса |
| Обновление кода — F5 | релиз через App Store | релиз через Play | дни, а не секунды |
// SwiftUI: scenePhase — единая точка наблюдения за жизненным циклом сцены
@main
struct ShopApp: App {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var store = CartStore()
var body: some Scene {
WindowGroup { RootView().environmentObject(store) }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active: store.resumeSync() // вернулись — досинхронизируем
case .inactive: store.persistDraft() // теряем фокус: сохраняем черновик
case .background: store.flushToDisk() // до заморозки остались секунды
@unknown default: break
}
}
}
}
// Android: SavedStateHandle переживает смерть процесса, обычное поле класса — нет
class CheckoutViewModel(private val state: SavedStateHandle) : ViewModel() {
val promoCode: StateFlow<String> = state.getStateFlow(KEY_PROMO, "")
fun onPromoChanged(value: String) { state[KEY_PROMO] = value } // в Bundle, не в поле
private companion object { const val KEY_PROMO = "promo" }
}
// Flutter: фреймворк унифицирует событие, но за ним стоят разные переходы ОС
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed: _sync.resume();
case AppLifecycleState.inactive: _draft.save(); // на iOS приходит чаще
case AppLifecycleState.paused: _sync.pause();
default: break; // detached на Android может не прийти
}
}
Обратите внимание на честную деталь: абстракция протекает уже здесь, на самом базовом уровне.
detached и inactive ведут себя по-разному на двух платформах (в React Native та же история:
AppState знает inactive только на iOS), и кроссплатформенный код всё равно вынужден знать, где он
выполняется. Это первый счёт от кроссплатформы, и далеко не последний.
Убивают вас не случайно: на iOS работает jetsam, на Android — lmkd плюс политика кэширования процессов. Когда системе нужна память для переднего плана, фоновые процессы убиваются по приоритету, и перехватить это нельзя. Отсюда три инженерных требования:
- Бюджет памяти конечен и заметно меньше RAM устройства. На iOS превышение даёт
EXC_RESOURCEи мгновенное убийство — это лимит, а не «крэш из-за бага». Фотография 12 Мп в ARGB занимает около 48 МБ. - Изображения декодируют в целевой размер, а не в исходный:
preparingThumbnailна iOS,inSampleSizeлибо Coil/Glide на Android. - Тест «смерть процесса» обязателен: свернуть приложение, выполнить
adb shell am kill <pkg>(на iOS — остановить процесс из Xcode) и вернуться из переключателя задач. Потеря данных здесь — баг.
Разрешения: пользователь может сказать «нет» навсегда
В вебе отказ в разрешении — деградация функции. На мобильном это ещё и бизнес-риск: диалог показывается один раз, отказ на iOS финален, а на Android повторный отказ включает «больше не спрашивать».
статус надо перепроверить, события не будет end
Ключевой паттерн — праймер перед системным диалогом: системный запрос тратится один раз, поэтому до
него показывают собственный экран с объяснением выгоды, а запрашивают в момент, когда функция реально
нужна (разница в конверсии измеряется десятками процентов). На iOS текст-обоснование обязан лежать в
Info.plist — например NSLocationWhenInUseUsageDescription, иначе приложение падает прямо в момент
запроса. На Android важно различать «отказал» и «отказал навсегда»:
private val requestLocation = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
when {
granted -> startTracking()
// false после отказа означает «больше не спрашивать»: диалога уже не будет
!shouldShowRequestPermissionRationale(ACCESS_FINE_LOCATION) -> openAppSettings()
else -> showRationaleSheet()
}
}
Что ломает продуктовые планы чаще всего: точность геолокации выбирает пользователь («Precise: off» на
iOS, COARSE вместо FINE на Android — приложение обязано работать при грубой точности); фоновая
геолокация — отдельное разрешение и отдельный разговор с ревью, просить её вместе с обычной нельзя;
уведомления на Android 13+ тоже требуют runtime-разрешения (POST_NOTIFICATIONS), так что планы «пуш
вернёт пользователя» надо делить минимум пополам; App Tracking Transparency на iOS обязательна при
связывании данных с идентификаторами других компаний, и согласие даёт меньшинство — рекламная атрибуция
это уже пережила.
Сеть, батарея, оффлайн: почему мобильный код пишут иначе
Мобильная сеть — не «медленный интернет», а интернет с провалами: лифт, метро, переход с Wi-Fi на LTE, роуминг, спящий радиомодуль. Три следствия:
- Смена сети рвёт соединения. IP меняется, TCP-сессия умирает. Ответ платформ — QUIC с миграцией соединения и MPTCP; ответ приложения — идемпотентные запросы и повтор с экспоненциальной задержкой и джиттером. Ретрай без идемпотентности — это дубли заказов.
- Радио дорогое. После передачи модем ещё несколько секунд держится в высоком энергосостоянии (tail energy): десять запросов по одному в секунду сажают батарею сильнее, чем один пакетный запрос того же объёма. Правило — батчить и откладывать всё, что не нужно прямо сейчас.
- Оффлайн — не режим отказа, а штатное состояние. Экран, умеющий только спиннер и «Нет сети», воспринимается как поломка; правильный дефолт — локальная база как источник истины.
Отдельная тонкость: платформы дают не «есть ли интернет», а характеристику пути. На iOS NWPathMonitor
сообщает isExpensive (сотовая сеть) и isConstrained (Low Data Mode), на Android NetworkCapabilities
— NET_CAPABILITY_NOT_METERED; тяжёлую предзагрузку включают только на дешёвом канале, иначе получают
жалобы на трафик. Локальное хранение — в данных и оффлайне,
ограничения фона — в фоновой работе и пушах, энергопрофиль —
в производительности, теория реплик — в моделях
согласованности.
Ограничения платформ: что нельзя в принципе
Полезно держать в голове список вещей, которые не решаются мастерством. Это границы, а не трудности. Общее для обеих платформ:
- Нельзя гарантировать выполнение кода в фоне в заданный момент: планировщики дают окно, а не срок.
- Нельзя обновить нативный код в обход магазина: загрузка исполняемого кода, меняющего назначение приложения, запрещена, а правила про ресурсы и конфиги периодически ужесточаются.
- Нельзя читать чужие данные и инспектировать систему: файловая система закрыта, а список установленных
программ на Android требует
QUERY_ALL_PACKAGESс обоснованием, которое обычно отклоняют. - Нельзя считать устройство доверенным: jailbreak, root, эмуляторы, перехват трафика. Всё критичное проверяется на сервере — см. безопасность мобильного и обзор трека безопасности.
Только iOS: нет JIT, поэтому интерпретируемые рантаймы медленнее своих десктопных версий; браузерные
движки долго были обязаны использовать системный WebKit (в ЕС это меняется под регуляторным давлением);
замена клавиатуры, лаунчера и системного набора номера ограничена. Только Android: технически можно
почти всё, но политика Play ограничивает сильнее, чем ОС — SMS и журнал звонков,
SYSTEM_ALERT_WINDOW, AccessibilityService не по назначению, точные будильники без обоснования.
«Работает на устройстве» и «пройдёт в Play» — разные утверждения. Плюс оболочки OEM убивают фоновые
процессы вопреки документации AOSP; сообщество ведёт каталог такого поведения на
dontkillmyapp.com.
Публикация: где заканчивается разработка
Подпись — самая частая причина «у меня не собирается», и механики платформ устроены по-разному.
Разница словами: на iOS подпись — это разрешение Apple на запуск конкретного бинаря на конкретных
устройствах, а provisioning profile — короткоживущий документ, который истекает и ломает сборку без
единого изменения в коде. На Android подпись — это идентичность издателя: система требует лишь того,
чтобы обновление было подписано тем же ключом, что и установка. Потеря app signing key до эпохи Play App
Signing означала, что обновить приложение нельзя никогда — только публиковать заново под новым
package name, теряя всю базу.
# iOS: архив и экспорт в CI (ключи заранее загружены в keychain агента)
xcodebuild -workspace Shop.xcworkspace -scheme Shop -configuration Release \
-destination 'generic/platform=iOS' -archivePath build/Shop.xcarchive archive
xcodebuild -exportArchive -archivePath build/Shop.xcarchive \
-exportOptionsPlist ci/ExportOptions.plist -exportPath build/ipa
# Android: App Bundle и проверка того, что реально попадёт на устройство
./gradlew bundleRelease && bundletool build-apks --connected-device \
--bundle=app/build/outputs/bundle/release/app-release.aab --output=app.apks
Правило гигиены: ключи не живут в репозитории. Для iOS распространён fastlane match (зашифрованный
приватный репозиторий с сертификатами), для Android — секреты CI плюс Play App Signing; общая практика
поставки — в стратегиях релиза.
Ревью: чего ждать на самом деле
App Review обычно укладывается в сутки-двое, но «обычно» — не гарантия: перед крупными датами очередь растёт. Google Play формально быстрее, однако новые аккаунты и чувствительные категории проходят расширенные проверки, а для новых личных аккаунтов действует требование закрытого тестирования реальными тестерами до публикации. Отказ — норма, а не катастрофа: ревьюер цитирует пункт правил, а вы либо исправляете, либо аргументированно возражаете.
| Причина отказа | Платформа | Как не попасть |
|---|---|---|
| Падает или не запускается у ревьюера | обе | тест на чистом устройстве, без вашего кэша и VPN |
| Нет демо-аккаунта, ревьюер упёрся в логин | обе | демо-логин в заметках, включая способ получить код |
| «Минимальная функциональность»: сайт в обёртке | iOS | нативная ценность, а не WebView поверх сайта |
| Платный контент мимо встроенных покупок | обе | цифровой контент только через IAP и Play Billing |
| Нет удаления аккаунта внутри приложения | обе | обязательный пункт для приложений с регистрацией |
| Декларация приватности расходится с SDK | обе | аудит SDK, privacy manifests, Data safety |
| Устаревший target API | Android | ежегодное обновление targetSdk — жёсткое условие |
Про target API отдельно: планка свежести уровня API поднимается в Play каждый год, и это единственное известное правило, которое гарантированно потребует работы даже от полностью замороженного продукта.
Поэтапная раскатка и откат
На мобильном откатить релиз нельзя: установленную версию не отзовёшь, можно только выпустить новую и ждать обновления. Отсюда три обязательных механизма. Staged rollout — начинать с 1–5% аудитории, следить за crash-free rate и продуктовыми метриками, повышать долю ступенями; раскатка приостанавливается и в Play, и в App Store. Feature flags и remote config — единственный реальный способ «откатить» поведение без релиза (дефолт при отсутствии сети обязан быть безопасным). Kill switch и forced update — экран «обновите приложение» с проверкой минимальной версии. Подробности цикла — в релизе и сторах, фермы устройств — в тестировании.
Деньги платформ: то, что влияет на архитектуру
- Взнос разработчика: Apple Developer Program — 99 USD в год, Google Play — единоразовые 25 USD.
- Комиссия. Базовая ставка исторически 30%; для малого бизнеса — 15% (Apple Small Business Program при обороте до 1 млн USD в год, Google Play — 15% на первый 1 млн USD в год у каждого разработчика); подписки после года у Apple тоже переходят на 15%.
- Что обязано идти через магазин. Цифровой контент внутри приложения — только через IAP и Play Billing; физические товары и офлайн-услуги — нет, поэтому маркетплейсы и такси комиссию не платят.
- Регуляторные сдвиги. DMA в ЕС легализовал сторонние магазины и альтернативную оплату со своей структурой сборов, решения судов в США ослабили запрет на ссылки на внешнюю оплату. Правила меняются ежеквартально — проценты проверяйте в актуальных документах до того, как строить на них финмодель.
Архитектурный вывод: слой монетизации изолируется за интерфейсом — продукт, у которого StoreKit и
BillingClient размазаны по экранам, не переживёт смену правил без крупного рефакторинга.
Нативно или кроссплатформенно: честный счёт
Стоимость. Расчёт «одна кодовая база — вдвое дешевле» неверен. Общими становятся бизнес-логика, сеть, модели и значительная часть UI — для типичного продуктового приложения 60–80% кода. Не становятся общими интеграции с системными API (биометрия, пуши с расширениями, платежи, шаринг, виджеты, часы), платформенные UI-ожидания, сборка и подпись, обход поведения конкретных OEM. Плюс появляется новая статья расходов — сопровождение самого моста: обновление фреймворка вслед за релизами ОС и содержание плагинов. Честная вилка экономии — 30–50% от стоимости двух нативных приложений, а на сложной системной интеграции она стремится к нулю.
Качество. Разрыв в UI сократился до уровня, заметного экспертам и почти незаметного пользователям — если команда соблюдает платформенные соглашения. Он сохраняется там, где важны холодный старт (лишний рантайм — это мегабайты и время инициализации), размер бинаря, камера и графика в реальном времени, доступность (нативные компоненты дают корректную семантику для VoiceOver и TalkBack) и скорость поддержки свежих системных фич.
Найм. Часто решающий фактор, о котором говорят реже всего.
| Ось | Нативно (Swift + Kotlin) | Flutter | React Native | KMP |
|---|---|---|---|---|
| Рынок кандидатов | два отдельных, средние | средний и растущий | большой за счёт React-разработчиков | небольшой, пересекается с Android |
| Порог входа | высокий: два стека | средний: новый язык Dart | низкий при опыте React | средний: Kotlin уже знаком |
| Автобусный фактор | распределён по двум командам | сконцентрирован | сконцентрирован | средний |
| Кто чинит платформенный баг | тот же разработчик | нужен нативный опыт | нужен нативный опыт | нативные части всё равно нативные |
Последняя строка — самая коварная. Кроссплатформенная команда без единого человека с нативным опытом рано или поздно встаёт: баг воспроизводится только на свежей iOS, плагин не обновлён под новую версию, ревью требует privacy manifest, которого нет в зависимости. Переходя на кроссплатформу ради экономии на найме, заложите хотя бы по одному нативному специалисту на платформу. Разбор фреймворков — в кроссплатформе; здесь важен принцип: выбор делается не по языку, а по количеству платформенной специфики в продукте и наличию людей, способных с ней справиться.
Типичные ошибки, которые видно сразу
- Переносить веб-модель жизненного цикла: состояние в полях и надежда на «прощальный» колбэк.
- Проектировать от онлайна: спиннер как единственное состояние загрузки и пустой экран без сети.
- Спрашивать все разрешения на первом запуске — массовые отказы без обратного пути.
- Считать Android однородным: тестировать на Pixel, а про поведение оболочек узнавать из отзывов.
- Оставлять релиз на последний день: ревью, отказ, повторная отправка и раскатка — это неделя.
- Не изолировать платформенные API: прямые вызовы
StoreKit,BillingClientи SDK аналитики из UI превращают смену правил магазина в сквозной рефакторинг. - Обещать бизнесу «одна кодовая база — половина бюджета»: обещание не сбудется, а подорванное доверие стоит дороже разницы в смете.
Мини-итог
- Платформа — вертикальный стек, где вам принадлежат только верхние слои; всё ниже меняется без вашего согласия и не обновляется вместе с релизом приложения.
- iOS: вертикальная интеграция, узкая матрица устройств, обязательная подпись кода, запрет JIT.
- Android: открытая база с надстройками OEM, Binder и zygote, UID на приложение, компоненты вместо единой точки входа и реальная фрагментация поведения.
- Жизненный цикл — главное отличие от веба: состояние обязано переживать смерть процесса.
- Разрешения тратятся один раз, а сеть с провалами, дорогое радио и оффлайн — исходные условия проектирования, а не крайние случаи.
- Публикация — часть инженерии: подпись, ревью, поэтапная раскатка, невозможность отката, ежегодный target API и feature flags как единственный настоящий «откат».
- Выбор между нативом и кроссплатформой определяется объёмом платформенной специфики и наличием нативной экспертизы, а не элегантностью фреймворка.
Источники
- Apple Developer Documentation — фреймворки и StoreKit.
- App Store Review Guidelines — пункты правил.
- Apple Platform Security — подпись, песочница.
- Android Platform Architecture — слои AOSP, ART, HAL.
- Activity lifecycle и процессы.
- Android App Bundle и Play App Signing — ключи и доставка.
- Google Play Developer Policy Center — политики.
- Application Sandbox — модель изоляции в AOSP.
- dontkillmyapp.com — каталог поведения OEM-оболочек в отношении фона.
Что дальше
Обе платформы разобраны, ограничения зафиксированы. Дальше — конкретика одной из них: язык, инструменты, фреймворки, экосистема.
Нативная iOS-разработка: Swift, SwiftUI, жизненный цикл, экосистема