Мобильная разработка Платформы: iOS и Android изнутри, отличия, ограничения, публикация
0%

Платформы: iOS и Android изнутри, отличия, ограничения, публикация

Платформы: iOS и Android изнутри, отличия, ограничения, публикация

Мобильный разработчик, пришедший из веба, первые полгода живёт в тихом недоумении. Почему приложение «само» перезапустилось и потеряло заполненную форму? Почему таймер, исправно тикавший в браузере, на телефоне отстаёт на сорок минут? Почему один и тот же код на Pixel работает, а на Xiaomi через сутки перестаёт получать пуши? Почему релиз идёт четыре дня и заканчивается письмом «Guideline 4.2»?

Ответ один: вы больше не хозяин среды исполнения. В вебе платформа — браузер, обязанный выполнить ваш код. На мобильном платформа — операционная система с жёсткой моделью ресурсов, песочницей, правом убить ваш процесс в любой момент и магазином, решающим, попадёт ли ваш код к пользователям вообще. Кроссплатформенный фреймворк эту реальность прячет, но не отменяет: Flutter не отменит Doze, React Native не оформит за вас privacy manifest, KMP не пройдёт ревью. Дальше в треке будут нативная iOS, нативный Android и кроссплатформа — но выбирать между ними, не понимая, что именно вы абстрагируете, значит выбирать вслепую.

Что вообще значит «платформа»

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

Слои платформы: iOS и Android бок о бок, от железа до вашего приложения

Отсюда два вывода, которые дороже любых деталей 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 ещё на вашей машине.

Три свойства, из которых вытекает почти всё остальное:

  1. Обязательная подпись кода. AMFI (Apple Mobile File Integrity) проверяет подпись при каждом exec и при отображении страниц кода в память; страница не бывает одновременно записываемой и исполняемой.
  2. Отсюда — запрет JIT. Сгенерировать машинный код в рантайме нельзя, поэтому JavaScript в React Native на iOS исполняется интерпретатором Hermes: это ограничение платформы, а не фреймворка.
  3. Песочница. У каждого приложения свой контейнер данных; общий доступ — только через явные механизмы: 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 единственное состояние, где уместны анимации
Вкладка в фоне backgroundsuspended 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 повторный отказ включает «больше не спрашивать».

Ключевой паттерн — праймер перед системным диалогом: системный запрос тратится один раз, поэтому до него показывают собственный экран с объяснением выгоды, а запрашивают в момент, когда функция реально нужна (разница в конверсии измеряется десятками процентов). На 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, роуминг, спящий радиомодуль. Три следствия:

  1. Смена сети рвёт соединения. IP меняется, TCP-сессия умирает. Ответ платформ — QUIC с миграцией соединения и MPTCP; ответ приложения — идемпотентные запросы и повтор с экспоненциальной задержкой и джиттером. Ретрай без идемпотентности — это дубли заказов.
  2. Радио дорогое. После передачи модем ещё несколько секунд держится в высоком энергосостоянии (tail energy): десять запросов по одному в секунду сажают батарею сильнее, чем один пакетный запрос того же объёма. Правило — батчить и откладывать всё, что не нужно прямо сейчас.
  3. Оффлайн — не режим отказа, а штатное состояние. Экран, умеющий только спиннер и «Нет сети», воспринимается как поломка; правильный дефолт — локальная база как источник истины.

Отдельная тонкость: платформы дают не «есть ли интернет», а характеристику пути. На iOS NWPathMonitor сообщает isExpensive (сотовая сеть) и isConstrained (Low Data Mode), на Android NetworkCapabilitiesNET_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 и Android

Разница словами: на 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 как единственный настоящий «откат».
  • Выбор между нативом и кроссплатформой определяется объёмом платформенной специфики и наличием нативной экспертизы, а не элегантностью фреймворка.

Источники

Что дальше

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

Нативная iOS-разработка: Swift, SwiftUI, жизненный цикл, экосистема

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

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

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

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