Мобильная разработка: карта трека и чем мобильный контекст отличается
Что такое мобильное приложение с точки зрения инженера
Определение «программа для телефона» бесполезно: оно описывает форм-фактор, а не работу. Работающее определение звучит так.
Мобильное приложение — это долгоживущий процесс на чужом устройстве, который делит дефицитные ресурсы с другими приложениями, работает в сети без гарантий, исполняется под управлением ОС, имеющей право убить его в любой момент, и обновляется только через посредника, способного отказать в публикации.
Каждая часть определения порождает класс задач, которых нет ни в бэкенде, ни в вебе:
- Долгоживущий процесс на чужом устройстве. Приложение не перезапускается с каждым запросом: утечка памяти проявится через час использования, а не на следующем реквесте. Состояние копится, и его надо уметь и сохранять, и выбрасывать.
- Дефицитные общие ресурсы. Батарея не пополняется по вашему запросу, память делится с десятком приложений, CPU троттлится при нагреве. Вы не «оптимизируете производительность» — вы тратите чужой бюджет, и пользователь видит цифру расхода в настройках рядом с названием вашего приложения.
- Сеть без гарантий. Не «есть интернет или нет», а весь спектр между: 3G в метро, Wi-Fi, отвечающий на ping, но не пропускающий TLS, переключение сети посреди загрузки.
- ОС как арбитр. Система решает, когда усыпить процесс, когда разбудить, сколько секунд дать на фоновую работу и стоит ли убить приложение ради того, что сейчас на экране.
- Посредник в дистрибуции. Между
git pushи пользователем — подпись, ревью, поэтапная раскатка и главное: откатить уже установленную версию невозможно.
Если из веба вы приносите привычку «сервер починит», мобильная разработка её отбирает: код уже на устройствах, и до конца их жизни вы живёте с тем, что выпустили. Трек — про платформу, клиентскую инженерию и эксплуатацию мобильного продукта; принципы клиентской архитектуры пересекаются с треком Frontend, но именно пересекаются, а не совпадают — ниже видно, почему.
Откуда взялись сегодняшние правила
Каждое ограничение, которое сегодня кажется произволом, было ответом на конкретную боль.
Три вывода на весь трек. Ограничения ОС растут, а не ослабевают: каждая версия iOS и Android отбирает возможности у фона, трекинга и доступа к данным — закладывайте архитектуру, которая переживёт очередное ужесточение. Кроссплатформа не победила и не проиграла: она заняла нишу, и спор «натив против Flutter» без контекста продукта бессмыслен. Декларативный UI победил везде: SwiftUI, Compose, Flutter и React Native описывают интерфейс как функцию состояния, и навык переносится между стеками почти целиком.
Стек: где живёт ваш код и кто им управляет
Ценность схемы диагностическая: симптом «приложение тормозит» лечится по-разному на разных слоях.
| Слой | Типичный симптом | Чем смотреть |
|---|---|---|
| Код приложения | лишние перерисовки списка, синхронный I/O в главном потоке | Layout Inspector, SwiftUI _printChanges, Time Profiler |
| Фреймворк и рантайм | долгая инициализация, GC-паузы, забитый JS-поток | Perfetto, Instruments, профайлер Hermes |
| SDK платформы | системный диалог блокирует поток, депрекейт API | системные логи, os_signpost, systrace |
| ОС-арбитр | приложение убито в фоне, задача не выполнялась сутки | Android vitals, dumpsys, MetricKit, Battery Historian |
| Железо | троттлинг после трёх минут, разброс устройств в 10 раз | Firebase Test Lab, Xcode Organizer по моделям |
Правило то же, что в любой оптимизации: чем ниже слой, тем дороже ошибка и тем меньше рычагов, поэтому начинают сверху — с вопроса «нужна ли эта работа вообще». Как ОС раздаёт CPU и память, разобрано в статье про процессы и планирование; здесь важно следствие: вы конкурируете за ресурсы, а не владеете ими.
Отличие первое: энергия — чужой ресурс, который нельзя пополнить
В вебе энергопотребление не метрика продукта. В мобильном это метрика, которую пользователь видит по имени вашего приложения в настройках батареи и по которой удаляет приложения. Ключевая интуиция: энергия — площадь под графиком мощности во времени, а не «количество запросов». Самый дорогой компонент почти всегда радиомодем, и дорог он не в момент передачи, а после неё.
Сотовый модем не выключается сразу после последнего байта: он держит канал ещё несколько секунд, чтобы не платить полсекунды на повторное соединение. Этот tail energy и есть главный источник расхода. Отсюда правила, одинаковые для всех стеков: батчите сеть (десять аналитических событий в минуту — десять пробуждений модема, одна отправка раз в 15 минут по Wi-Fi — одно); отдавайте планирование системе, потому что WorkManager и BGTaskScheduler склеивают задачи разных приложений в общие окна пробуждения, а свой таймер этого не умеет; не опрашивайте, а подписывайтесь — polling каждые 30 секунд убивает батарею, пуш будит устройство только когда есть новость; помните, что непрерывный GPS стоит единицы процентов заряда в час, а значимые изменения местоположения и геозоны — доли процента.
// Android: синхронизацию планирует система, а не вы
val sync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // только не-мобильный трафик
.setRequiresBatteryNotLow(true) // не добиваем севшую батарею
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
// KEEP не плодит дубли задачи при каждом запуске приложения
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("catalog-sync", ExistingPeriodicWorkPolicy.KEEP, sync)
// iOS: та же идея — просим систему разбудить нас, когда ей удобно
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.example.shop.refresh", using: nil) { task in
task.expirationHandler = { SyncCoordinator.shared.cancel() } // окно измеряется секундами
Task {
let ok = await SyncCoordinator.shared.refreshCatalog()
task.setTaskCompleted(success: ok)
scheduleNextRefresh() // повторную заявку подаём сами, автоповтора нет
}
}
Общий мотив: вы не запускаете фоновую работу — вы подаёте заявку, а система решает, когда и запустит ли вообще. Квоты и ограничения — в статье про фоновую работу и пуши.
Отличие второе: сеть, которая врёт
Веб-страница при потере сети просто не открывается — и это честно. Мобильное приложение открывается всегда и обязано вести себя разумно в четырёх состояниях сети, а не в двух.
| Состояние | Что видит код | Что должно делать приложение |
|---|---|---|
| Быстрая сеть | RTT 20–60 мс | грузить нормально, префетчить осторожно |
| Медленная сеть | RTT 200–600 мс, потери | показать локальные данные сразу, догрузить в фоне |
| lie-fi | соединение есть, ответов нет | жёсткий таймаут 10–15 с и переход в оффлайн-режим |
| Оффлайн | системный флаг «нет сети» | полноценный экран из локальной БД, запись в очередь |
Худшее из этого — lie-fi: капча отельного Wi-Fi, забитая базовая станция, NAT, молча выбросивший ваше соединение. Флаг «подключено» есть, данные не идут, а приложение, верящее флагу, показывает вечный спиннер. Второе, что ломается только на мобильном: сеть меняется под ногами. Пользователь вышел из дома — Wi-Fi сменился на LTE, IP изменился, все открытые TCP-соединения оборваны. Отсюда любовь мобильных команд к QUIC с миграцией соединений и к идемпотентным запросам.
// iOS: конфигурация сессии под мобильную реальность
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true // ждём появления сети, а не падаем сразу
config.timeoutIntervalForRequest = 15 // висеть дольше на плохой сети бессмысленно
config.timeoutIntervalForResource = 120 // общий бюджет на ресурс вместе с ретраями
config.allowsExpensiveNetworkAccess = false // фоновую синхронизацию не тянем через сотовую
let session = URLSession(configuration: config)
// React Native: «подключено» и «интернет работает» — разные вещи
NetInfo.addEventListener(state => {
// isConnected говорит про линк, isInternetReachable — про реальную достижимость
syncQueue.setOnline(state.isConnected === true && state.isInternetReachable === true);
// на дорогой сети шлём только действия пользователя, аналитику копим до Wi-Fi
syncQueue.setMetered(state.details?.isConnectionExpensive === true);
});
Каждый ретрай идёт с экспоненциальной задержкой, джиттером и ключом идемпотентности, иначе двойное списание — вопрос времени. Механика разобрана в статье про идемпотентность и гарантии доставки: мобильный клиент — это просто ещё один ненадёжный узел распределённой системы, с самой плохой связью из всех.
Отличие третье: оффлайн — не режим, а состояние по умолчанию
Правильная модель: локальная база — источник правды для UI, сеть — механизм её обновления. Не наоборот. Экран читает из локального хранилища и рисуется за миллисекунды, синхронизация идёт отдельно и обновляет то же хранилище. Запись работает симметрично, через outbox: изменение сразу применяется локально и одновременно кладётся в очередь на отправку.
Три ошибки первого оффлайн-проекта. Очередь в памяти: процесс убили — операции пропали, поэтому очередь обязана лежать в БД, а не в списке внутри ViewModel. Нет ключа идемпотентности: ретрай после таймаута создаёт второй заказ, а UUID должен генерировать клиент до отправки, а не сервер после. «Побеждает последний»: last-write-wins тихо теряет данные и в деньгах или документах недопустим.
Локальное хранилище на обеих платформах — почти всегда SQLite (Room и SQLDelight на Android, Core Data, SwiftData и GRDB на iOS, Drift и Isar во Flutter). Свойства и подводные камни встраиваемых БД — в статье про SQLite, стратегии синхронизации и разрешения конфликтов — в данных и оффлайне.
Отличие четвёртое: разрешения и приватность
В вебе разрешения — исключение. В мобильном это основной способ взаимодействия приложения с миром, и отозвать их можно в любой момент, не убивая приложение. Три правила, за нарушение которых наказывают и сторы, и пользователи. Спрашивать в момент нужды, а не на старте: экран приветствия с пятью системными диалогами подряд — гарантированный отказ. Показывать «зачем» до системного диалога: системный запрос выдаётся один раз, отказ отправляет пользователя в настройки навсегда, и свой экран-объяснение перед ним поднимает согласие в разы. Деградировать, а не ломаться: нет геолокации — ручной ввод адреса, нет камеры — выбор из галереи; приложение, падающее на отказе, отклонит ревью.
// iOS: ветвление по статусу, а не «просто спросить»
func requestNearby(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization() // первый и единственный шанс
case .denied, .restricted:
showManualCityPicker() // не уговариваем, даём альтернативу
case .authorizedWhenInUse, .authorizedAlways:
manager.requestLocation()
@unknown default:
showManualCityPicker() // новые статусы появляются каждый год
}
}
// Android: три ветки — уже есть, надо объяснить, надо спросить
private val requestLocation = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) viewModel.onGranted() else viewModel.onDenied() }
private fun onNearbyClicked() = when {
checkSelfPermission(ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED -> viewModel.onGranted()
// система сама подсказывает, что пользователь уже отказывал: нужен экран-объяснение
shouldShowRequestPermissionRationale(ACCESS_COARSE_LOCATION) ->
showRationale { requestLocation.launch(ACCESS_COARSE_LOCATION) }
else -> requestLocation.launch(ACCESS_COARSE_LOCATION)
}
Отдельный слой — декларативная приватность. Apple требует privacy manifest с описанием собираемых данных и причин обращения к чувствительным API, а запрос рекламного идентификатора идёт через отдельный диалог App Tracking Transparency. Google Play требует раздел Data Safety, соответствующий фактическому поведению приложения. Расхождение декларации и реальности — не замечание, а снятие с публикации; детали в платформах и релизе и сторах.
Отличие пятое: процесс, который убивают
Самое неочевидное для пришедших из веба и бэкенда: ОС убивает ваш процесс без предупреждения и без возможности возразить. Не «может в теории» — убивает регулярно, как штатную операцию: пользователь свернул приложение, открыл камеру, системе понадобилась память.
Терминология разная, суть одна:
| Что происходит | iOS | Android |
|---|---|---|
| Экран виден и активен | active |
RESUMED |
| Частично перекрыт | inactive |
STARTED, не RESUMED |
| Ушёл в фон | background |
STOPPED |
| Исполнение остановлено | suspended |
процесс кэширован |
| Убит системой | jetsam | low-memory killer |
| Что переживает смерть | NSUserActivity, state restoration |
SavedStateHandle, onSaveInstanceState |
Вывод один и очень жёсткий: любое состояние, которое нельзя восстановить с сервера или из локальной БД, должно быть записано на диск до ухода в фон. Наполовину заполненная форма, позиция скролла, черновик сообщения теряются, если живут только в объекте в куче.
// Android: SavedStateHandle переживает смерть процесса, обычное поле — нет
class CheckoutViewModel(repo: OrderRepository, private val state: SavedStateHandle) : ViewModel() {
var promoCode: String // сохраняется в Bundle и восстанавливается
get() = state["promo"] ?: ""
set(value) { state["promo"] = value }
// подписка живёт, только пока экран виден: WhileSubscribed отменяет её в фоне
val cart: StateFlow<Cart> = repo.observeCart()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Cart.EMPTY)
}
// iOS: единая точка реакции на смену фазы сцены
@main
struct ShopApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup { CatalogView() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active: SyncCoordinator.shared.resume() // экран виден
case .inactive: SyncCoordinator.shared.pauseTimers() // звонок или свитчер
case .background: SyncCoordinator.shared.flushToDisk() // счёт идёт на секунды
@unknown default: break
}
}
}
}
Кроссплатформенные стеки пробрасывают тот же контракт: во Flutter это didChangeAppLifecycleState с состояниями resumed, inactive, paused, hidden, detached, в React Native — AppState.addEventListener('change', ...). Заведите в команде проверку с первого спринта: на Android включите «Не сохранять действия» в настройках разработчика, на iOS убивайте приложение из Xcode при свёрнутом состоянии. Если после возврата пользователь оказался на главном экране с пустой формой — это баг, который в проде выглядит как «приложение всё теряет».
Отличие шестое: релиз через посредника
В вебе плохой релиз откатывается за минуты. В мобильном не откатывается вовсе: у пользователя стоит конкретная версия, и удалить её оттуда вы не можете.
Три следствия закладывают в архитектуру до первого релиза, а не после первого инцидента. Фича-флаги и удалённый конфиг обязательны — единственный способ откатить поведение без нового ревью; флаг читается из локального кэша, чтобы работать оффлайн, и имеет безопасное значение по умолчанию. Механизм принудительного обновления: сервер должен уметь сказать старому клиенту «дальше нельзя», иначе древние версии будут стучаться в API годами. Версионирование API с расчётом на долгий хвост: через год заметная доля пользователей сидит на старых сборках, и ломающее изменение контракта ломает их всех.
| Аспект | Веб | Мобильное приложение |
|---|---|---|
| Выкатка | минуты, свои правила | часы или дни, ревью посредника |
| Откат | предыдущий билд за минуту | невозможен, только флаг или новая версия |
| Версии в проде | одна | все, что вы когда-либо выпускали |
| Кто может запретить релиз | вы сами | Apple, Google, регулятор |
| Диагностика у пользователя | DevTools открыты всем | только ваша телеметрия и крэш-репорты |
Общие практики поэтапных выкаток разобраны в стратегиях доставки — в мобильном они те же по смыслу, но без права на быстрый откат.
Нативная или кроссплатформа: честный разбор
Самый частый и самый плохо задаваемый вопрос индустрии. Плохо — потому что спрашивают «что лучше», а отвечать можно только на «что дешевле для этого продукта, при этой команде и на этом горизонте».
Что реально шарится, а что нет
Иллюзия «пишем один раз» разбивается о факт: общий код — это логика, а не приложение. Всё платформенное придётся делать дважды в любом стеке.
| Часть работы | Два натива | Flutter или React Native | KMP с нативным UI |
|---|---|---|---|
| Экраны и навигация | дважды | один раз | дважды |
| Доменная логика, модели, сеть, кэш, БД | дважды | один раз | один раз |
| Пуши, deep links, разрешения, биометрия | дважды | дважды плюс отладка моста | дважды |
| Виджеты, часы, системные интеграции | дважды | дважды, часто нативно | дважды |
| Сборка, подпись, сторы, ревью | дважды | дважды | дважды |
| Дизайн, продукт, бэкенд, аналитика | один раз | один раз | один раз |
Реалистичная оценка: кроссплатформа экономит 25–40% усилий клиентской команды, а не 50%, и почти ничего не экономит на релизном цикле и работе со сторами. Экономия тем выше, чем больше в продукте типовых экранов со списками и формами, и тем ниже, чем больше системных интеграций.
Стоимость: полная, а не только разработка
| Статья | Натив | Flutter | React Native | KMP |
|---|---|---|---|---|
| Старт разработки | медленнее, две кодовые базы | быстро | быстро при React-опыте | средне |
| Поддержка через два года | предсказуемая | зависит от плагинов | зависит от плагинов и мостов | предсказуемая |
| Новая версия ОС осенью | доступна сразу | ждёте фреймворк | ждёте библиотеки | UI сразу, логика не затронута |
| Отладка «странных» багов | инструменты платформы | плюс слой фреймворка | плюс мост и JS-поток | инструменты платформы |
Ключевая мысль: дешёвый старт и дешёвая поддержка — разные оси. Кроссплатформа выигрывает старт почти всегда; поддержку — только там, где продукт остаётся в рамках типовых экранов.
Качество: механика важнее маркетинга
- Flutter рисует свои виджеты собственным рендерером (Impeller, ранее Skia). Плюс — пиксель в пиксель одинаково везде и полный контроль анимаций. Минус — это не системные элементы управления: выделение текста, специальные возможности, системные меню и клавиатурные тонкости приходится догонять, а обновление ОС может незаметно разойтись с вашим отрисованным UI.
- React Native после перехода на новую архитектуру (Fabric, TurboModules, JSI, Hermes) рендерит настоящие нативные вью, и внешне это натив. Цена — отдельный JS-поток: тяжёлая логика в нём даёт задержки ввода, а сложные жесты требуют аккуратной работы с Reanimated и Gesture Handler.
- Kotlin Multiplatform делит только логику: сеть, БД, модели, валидацию; UI остаётся нативным на обеих платформах. Максимальное качество интерфейса при меньшей доле общего кода; Compose Multiplatform поднимает эту долю, приближая компромисс к Flutter.
- Натив — потолок по качеству и доступу к платформе по определению, потому что это и есть платформа. Всё новое появляется здесь первым.
Найм: то, о чём забывают на этапе выбора
- iOS и Android разработчики — самый предсказуемый рынок: понятно, как собеседовать, понятен грейд, легко заменить человека.
- Flutter — растущий, но перекошенный пул: много джунов, мало тех, кто чинил сложные проблемы производительности и писал платформенные каналы.
- React Native — пул велик за счёт React-разработчиков, и это ловушка: веб-React-разработчик не равен мобильному инженеру, он не знает про жизненный цикл процесса, разрешения, сторы, фон и энергию — то есть ровно про всё, о чём эта статья.
- KMP — самый узкий рынок: нужен человек, сильный в Kotlin и понимающий iOS. Зато такие люди обычно сильные. Отдельный фактор — автобусный номер: один энтузиаст Flutter в компании, где остальные пишут натив, это риск, а не оптимизация.
Как выбирать
Начинайте с вопроса «сколько процентов экранов — это списки и формы?». Больше 80% — кроссплатформа почти наверняка окупится. Меньше 50% — считайте честно, экономия может оказаться отрицательной. Если продукт стоит на системных возможностях (камера, фон, NFC, аудио, ML на устройстве), кроссплатформа не сэкономит: эти части всё равно нативные.
Второе правило: решение обратимо только дорого. Переезд с кроссплатформы на натив — это переписывание клиента, обратно — тоже. Поэтому выбор стека — архитектурное решение уровня выбора базы данных, и его документируют как ADR, а не принимают по симпатии к языку. Разбор — в кроссплатформе.
Один экран на четырёх стеках
Насколько похожи современные декларативные подходы — на примере одного списка товаров:
// SwiftUI
struct CatalogView: View {
@State private var model = CatalogModel()
var body: some View {
List(model.items) { ProductRow(product: $0) }
.overlay { if model.isOffline { OfflineBanner() } }
.task { await model.load() } // задача отменяется при уходе с экрана
}
}
// Jetpack Compose
@Composable
fun CatalogScreen(vm: CatalogViewModel = viewModel()) {
val state by vm.state.collectAsStateWithLifecycle() // отписка при уходе в фон
Column {
if (state.isOffline) OfflineBanner()
LazyColumn { items(state.items, key = { it.id }) { ProductRow(it) } }
}
}
// Flutter
class CatalogScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final state = context.watch<CatalogModel>();
return Column(children: [
if (state.isOffline) const OfflineBanner(),
Expanded(child: ListView.builder( // ленивый список, а не Column со всем сразу
itemCount: state.items.length,
itemBuilder: (_, i) => ProductRow(product: state.items[i]),
)),
]);
}
}
// React Native: тот же экран, обязательно на виртуализированном списке
export function CatalogScreen() {
const { items, isOffline } = useCatalog();
return (
<View style={styles.root}>
{isOffline && <OfflineBanner />}
<FlatList data={items} keyExtractor={i => i.id} initialNumToRender={10}
renderItem={({ item }) => <ProductRow product={item} />} />
</View>
);
}
Четыре стека, одна модель мышления: состояние сверху, декларативное описание, ленивый список, явная обработка оффлайна. Именно поэтому переход между стеками стоит недорого, а переход из веба в мобильный — дорого: сложность не в синтаксисе, а в контексте.
По каким метрикам судят мобильное приложение
| Метрика | Разумный ориентир | Где смотреть |
|---|---|---|
| Холодный старт до интерактивности | p90 меньше 1,5–2 с | Macrobenchmark и Play Vitals, MetricKit и Instruments |
| Плавность | доля проблемных кадров меньше 1% | Perfetto, JankStats, hitch-метрики MetricKit |
| ANR на Android | ниже порога Play: 0,47% пользовательских сессий | Android vitals |
| Сессии без крэшей | выше 99,5% | Crashlytics, Sentry, Xcode Organizer |
| Размер загрузки и расход батареи | меньше мегабайт, нет лишних пробуждений | Play Console, App Store Connect, Battery Historian |
| Оффлайн-готовность | первый экран рисуется без сети | проверки в авиарежиме и на lie-fi |
Два числа стоит запомнить отдельно. Google Play помечает приложение как проблемное при доле пользовательских ANR от 0,47% и крэшей от 1,09% — превышение бьёт по видимости в сторе, а не только по совести. И исследование Google Play показывало, что каждые дополнительные 6 МБ размера установки стоят примерно 1% конверсии установки: прямая связь между решением «подключим ещё одну библиотеку» и деньгами продукта. Как связывать техническое с продуктовым — в статье про продуктовые метрики.
Типичные ошибки на входе в мобильную разработку
- Считать сеть надёжной. Спиннер без таймаута, ретрай без джиттера, запись без ключа идемпотентности.
- Держать состояние только в памяти. Работает на симуляторе, разваливается на реальном устройстве через день.
- Спрашивать все разрешения на старте. Конверсия падает, ревью придирается, пользователи отказывают навсегда.
- Тестировать на флагмане. Ваш телефон в 5–10 раз быстрее медианного устройства аудитории — держите бюджетный аппарат.
- Игнорировать размер приложения. Каждая «удобная» библиотека — мегабайты и просадка конверсии установки.
- Считать, что фон работает. Doze, App Standby, вендорские «оптимизаторы» и жёсткие окна iOS думают иначе.
- Выпускать без фича-флагов. Первый серьёзный баг покажет, что откатывать нечего.
- Переносить веб-паттерны буквально. Polling, тяжёлые WebView вместо экранов, скролл, ломающий системные жесты.
- Не читать гайдлайны сторов до разработки. Требования к приватности, платежам и удалению аккаунта проще заложить, чем переделать.
Карта трека
Трек состоит из четырёх блоков: платформа (как устроены обе ОС и их правила), клиентские стеки (натив, кроссплатформа, UI и навигация), инженерия приложения (архитектура, данные и оффлайн, фон и пуши) и эксплуатация (производительность, безопасность, тестирование, релиз). Порядок статей — от контекста к деталям и дальше к эксплуатации:
- Платформы: iOS и Android изнутри — устройство систем, песочница, форматы приложений, чем правила Apple отличаются от правил Google.
- Нативная iOS-разработка — Swift, SwiftUI и UIKit, жизненный цикл, инструменты, экосистема.
- Нативная Android-разработка — Kotlin, Compose, компоненты приложения, Jetpack, инструменты.
- Кроссплатформа — Flutter, React Native, KMP: механика, границы применимости, цена решения.
- Мобильный UI и навигация — паттерны навигации, жесты, адаптация под экраны.
- Архитектура мобильного приложения — слои, состояние, MVVM и MVI, внедрение зависимостей, модуляризация.
- Данные и оффлайн — локальные БД, стратегии синхронизации, разрешение конфликтов.
- Фоновая работа и пуши — ограничения ОС, планировщики, уведомления и их доставка.
- Производительность — холодный старт, плавность кадров, батарея, трафик, профилирование.
- Безопасность — хранение секретов, биометрия, пиннинг, защита от реверса.
- Тестирование — юнит и UI, устройства и фермы, бета-каналы.
- Релиз и сторы — подписи, ревью, поэтапная раскатка, аналитика и крэш-репортинг.
Смежные треки: архитектурные паттерны для слоёв и границ, тестирование для мобильных и совместимостных проверок, распределённые системы для синхронизации, TypeScript — если идёте в React Native. Материалы по сетям и безопасности на портале живут в отдельных треках; здесь мы берём из них только мобильную специфику.
Мини-итог
Мобильная разработка — не «фронтенд, только на телефоне». Это инженерия клиента, который живёт на чужом устройстве месяцами, тратит невосполнимую энергию, работает в сети без гарантий, может быть убит ОС в любой момент и обновляется лишь с разрешения посредника. Шесть отличий из этой статьи — энергия, сеть, оффлайн, разрешения, жизненный цикл процесса и релиз через стор — определяют почти все архитектурные решения дальше по треку. Выбор между нативом и кроссплатформой сводится к доле платформенной специфики в продукте и к тому, кого вы способны нанять и удержать. Практический старт: поставьте оба инструмента (Xcode и Android Studio), возьмите бюджетное физическое устройство, а первый учебный проект делайте оффлайн-первым и обязательно проверяйте его в авиарежиме и через сетевой троттлинг — большинство мобильных багов живут именно там.
Источники
- Apple. App and environment, жизненный цикл — https://developer.apple.com/documentation/uikit/app-and-environment
- Apple. BackgroundTasks — https://developer.apple.com/documentation/backgroundtasks
- Apple. App Review Guidelines и Human Interface Guidelines — https://developer.apple.com/app-store/review/guidelines/
- Android. Processes and app lifecycle — https://developer.android.com/guide/components/activities/process-lifecycle
- Android. Doze and App Standby — https://developer.android.com/training/monitoring-device-state/doze-standby
- Android. WorkManager — https://developer.android.com/topic/libraries/architecture/workmanager
- Android vitals, пороги ANR и крэшей — https://developer.android.com/topic/performance/vitals
- Flutter architectural overview — https://docs.flutter.dev/resources/architectural-overview
- React Native, новая архитектура — https://reactnative.dev/architecture/landing-page и Kotlin Multiplatform — https://kotlinlang.org/docs/multiplatform.html
- Ilya Grigorik. High Performance Browser Networking, глава про мобильные сети — https://hpbn.co/mobile-networks/
- Google Play. Правила для разработчиков и Data safety — https://play.google.com/about/developer-content-policy/
Что дальше
Платформы: iOS и Android изнутри, отличия, ограничения, публикация — разберём, как устроены обе системы, что такое песочница на практике, из чего состоят .ipa и .aab, чем модель приложения на iOS отличается от компонентной модели Android и какие правила публикации реально влияют на архитектуру.