Нативная iOS-разработка: Swift, SwiftUI, жизненный цикл, экосистема
Зачем вообще отдельная статья про натив, если есть Flutter
Начнём с неудобного. Нативная iOS-разработка — не «правильный» способ делать мобильные приложения и не «устаревший». Это один из четырёх примерно равноправных инженерных вариантов, у каждого своя цена; статья объяснит, за что конкретно вы платите, чтобы решение принималось не по вкусу тимлида. Ключевая мысль: натив — это не «быстрее» и не «красивее», это прямой доступ к платформе без посредника и нулевой лаг за новыми возможностями ОС. Всё остальное — производные от этого свойства, включая производительность и качество анимаций.
Честная сводка по трём осям, которые реально влияют на решение. Цифры — порядки, а не константы: они зависят от продукта, но соотношения устойчивы.
| Ось | Нативный iOS | Kotlin Multiplatform | Flutter / React Native |
|---|---|---|---|
| Стоимость первой версии на две платформы | ~1,7–2,0× от одной платформы: два UI, две команды, два релизных процесса | ~1,3–1,5×: общая логика, UI дважды | ~1,1–1,3×: один UI, один релизный конвейер |
| Стоимость владения через 3 года | предсказуемо линейная, ломается только миграциями Apple | средняя, риск в связывании двух рантаймов | ниже на фичах, выше на «а на iOS это выглядит не так» |
| Доступ к новому API ОС | в день релиза беты | в день релиза беты через expect/actual |
через мост или плагин: недели–месяцы, иногда «никогда» |
| Потолок качества UI | системный, включая новые визуальные материалы | системный | очень высокий, но чужая имитация системного |
| Старт и вес приложения | минимальный: нет второго рантайма | +рантайм Kotlin/Native, немного | +рантайм Flutter или JS, десятки МБ и своя инициализация |
| Найм | средний по России и СНГ пул, дороже Android на 5–15% | узкий пул, требует обеих платформ в голове | широкий пул, но глубокая экспертиза по платформам дефицитна |
| Рефакторинг «мы выбрали не то» | болезненно, но локально | умеренно | тяжело: UI-слой не переносится |
Три практических правила отсюда:
- Продукт, где мобильное приложение — сам бизнес (банк, такси, соцсеть, камера, здоровье, всё с фоном и датчиками), почти всегда уезжает в натив или KMP. Не из-за FPS, а потому что вы годами будете упираться в API, которых нет в мосту.
- Продукт, где приложение — витрина к бэкенду (внутренний портал, каталог, b2b-инструмент, MVP гипотезы), почти всегда должен быть кроссплатформенным. Платить двойную цену за списки и формы — расточительство.
- Решение обратимо только в одну сторону дёшево. Уйти с натива на Flutter — переписать UI. Уйти с Flutter на натив — переписать всё. Развёрнутое сравнение — в Кроссплатформа: Flutter, React Native, KMP.
Про сами платформы и их устройство — Платформы: iOS и Android изнутри; базовые различия мобильного контекста разобраны в обзоре трека.
Swift: язык, спроектированный под ограниченное устройство
Swift появился в 2014 году не как «Objective-C без скобок», а как ответ на конкретный набор проблем: безопасность памяти без сборщика мусора, предсказуемая производительность, выразимость ошибок в типах.
Значения по умолчанию, ссылки по необходимости
В Swift struct и enum — типы-значения: присваивание копирует. class — тип-ссылка: присваивание делит объект. Это не синтаксический сахар, а способ убрать целый класс багов, знакомых любому, кто ловил «кто-то изменил мой массив из другого потока».
struct Money: Equatable, Sendable { let amount: Decimal; let currency: String }
struct CartItem: Identifiable, Sendable { // значение: копия при присваивании
let id: UUID
let sku: String
var quantity: Int // меняется только у владельца конкретной копии
let price: Money
}
final class CartViewModel { // ссылка: есть идентичность и время жизни
private(set) var items: [CartItem] = []
}
var b = a; b.quantity = 5 // a.quantity всё ещё 1 — состояние не «утекло»
Массив структур в куче держит один буфер, а не N объектов с заголовками: меньше аллокаций, меньше промахов кэша, меньше давления на подсистему освобождения памяти. Копирование при этом не наивное — коллекции и строки используют copy-on-write: физическая копия делается только при записи и только если буфер разделяют несколько владельцев (проверка — isKnownUniquelyReferenced(&buffer)).
ARC вместо GC: детерминированно, но без амнистии за циклы
iOS не имеет сборщика мусора. Время жизни объектов класса управляет ARC (Automatic Reference Counting): компилятор расставляет retain/release сам, объект умирает ровно в момент, когда счётчик доходит до нуля.
Плюс очевиден: нет пауз GC, а значит нет случайных подёргиваний прокрутки, которыми исторически страдал Android. Расплата тоже понятна: циклы ARC не разбирает. Два объекта, держащие друг друга сильно, живут до конца процесса.
final class ImageLoader {
private var cache: [URL: Data] = [:]
private var task: Task<Void, Never>?
func loadBad(_ url: URL) { // ОШИБКА: self держит задачу, задача держит self
task = Task { self.cache[url] = try? await URLSession.shared.data(from: url).0 }
}
func load(_ url: URL) { // ПРАВИЛЬНО: слабый захват, владелец умер — выходим
task = Task { [weak self] in self?.cache[url] = try? await URLSession.shared.data(from: url).0 }
}
}
Разница между weak и unowned: weak обнуляется автоматически и потому всегда Optional, доступ идёт через side table объекта и стоит дороже; unowned дешевле, но обращение к освобождённому объекту — падение. Правило: weak для колбэков и делегатов с непредсказуемым временем жизни, unowned — когда владелец гарантированно переживает зависимого.
Утечка в мобильном приложении опаснее, чем в веб-странице: страницу перезагрузят через минуту, а приложение держат открытым часами, и каждый открытый-закрытый экран добавляет к RSS. Когда процесс упрётся в лимит, его убьёт jetsam — без единого колбэка и без записи в крэш-репорт как «крэш». Смежная теория — Управление памятью.
Опционалы, ошибки и типы, которые не дают соврать
enum CartError: Error { case outOfStock(sku: String), priceChanged(Money), notAuthorized }
protocol CartService: Sendable {
func load() async throws -> [CartItem]
func add(sku: String, quantity: Int) async throws -> [CartItem]
}
func describe(_ error: Error) -> String {
switch error {
case CartError.outOfStock(let sku):
return "Товар \(sku) закончился"
case is CancellationError:
return "" // экран закрыли — это не ошибка, показывать нечего
case let e as URLError where e.code == .notConnectedToInternet:
return "Нет сети. Показываем последнее сохранённое."
default:
return "Не получилось. Повторите позже."
}
}
Три вещи, которые здесь стоит заметить. Optional — обычный enum с двумя случаями, а не магия компилятора: nil нельзя «случайно разыменовать». Ошибки — обычные значения, throws в сигнатуре виден в точке вызова, а try обязателен синтаксически, поэтому проглотить ошибку молча труднее, чем в JS или Java. И отдельная деталь мобильного контекста: CancellationError и URLError.notConnectedToInternet — не ошибки, а нормальные состояния. В вебе такой код часто пишут «на потом», в мобильном без него приложение выглядит сломанным каждый день.
Протоколы с ассоциированными типами, дженерики и some/any — отдельная большая тема; для архитектуры важно, что абстракции описываются протоколами, а не наследованием, и это естественно ложится на инверсию зависимостей (см. Слоистая, гексагональная и чистая архитектура).
Конкурентность: от GCD к акторам и Swift 6
Мобильное приложение конкурентно по природе: главный поток обязан отдавать кадр каждые 8,3 мс при 120 Гц, а всё остальное — сеть, диск, декодирование, аналитика — должно уйти в сторону. Исторически это делали через GCD (DispatchQueue) и колбэки, что порождало пирамиды вложенности и гонки, невидимые компилятору. Современный Swift даёт структурированную конкурентность: async/await, Task, группы задач и акторы, а с языковым режимом Swift 6 проверка изоляции данных стала обязательной — компилятор отказывается собирать код, где неизолированное изменяемое состояние может пересечь границу потока.
// Актор — единица изоляции: внутрь попадает только одна задача за раз
actor TokenStore {
private var token: String?
private var refreshTask: Task<String, Error>?
func valid() async throws -> String {
if let token, !isExpired(token) { return token }
// дедупликация: десять экранов, проснувшихся разом, не дадут десять запросов
if let refreshTask { return try await refreshTask.value }
let task = Task { try await self.refreshFromNetwork() }
refreshTask = task
defer { refreshTask = nil }
token = try await task.value
return token!
}
}
Параллельные независимые запросы собираются в withThrowingTaskGroup: задачи стартуют вместе, результаты приходят по мере готовности, а ошибка или отмена гасит всю группу — ровно то, что нужно для экрана, собираемого из трёх ручек бэкенда.
Два свойства, за которые это стоит любить именно на мобильном. Отмена распространяется по дереву задач: пользователь закрыл экран — Task отменён, дочерние запросы отменены, трафик и батарея сэкономлены. И @MainActor — часть типа, а не соглашение: класс, помеченный @MainActor, физически нельзя случайно тронуть с фонового потока, компилятор не даст.
@MainActor @Observable
final class CartViewModel {
enum State { case idle, loading, loaded([CartItem]), failed(String) }
private(set) var state: State = .idle
private let service: any CartService
private var loadTask: Task<Void, Never>?
init(service: any CartService) { self.service = service }
func onAppear() {
loadTask?.cancel() // экран открыли снова — старая загрузка не нужна
loadTask = Task { [weak self] in
guard let self else { return }
state = .loading
do {
let items = try await service.load() // await — уход с main и возврат на него
try Task.checkCancellation()
state = .loaded(items)
} catch is CancellationError { // экран закрыли — молча выходим
} catch { state = .failed(describe(error)) }
}
}
func onDisappear() { loadTask?.cancel() }
}
Типичные ошибки, которые я вижу в ревью чаще всего: Task { } без сохранения ссылки и без отмены (запрос переживает экран, ответ прилетает в мёртвую вьюху), Task.detached «чтобы точно параллельно» (теряется наследование приоритета и отмены — почти всегда не нужен), и await внутри цикла там, где нужна группа. Сравнение с моделями конкурентности других языков — Конкурентность в TypeScript и Паттерны конкурентности.
SwiftUI: декларативный UI и что осталось от UIKit
SwiftUI описывает интерфейс как функцию состояния: body возвращает дерево описаний, фреймворк сравнивает его с предыдущим и меняет только то, что изменилось. Если вы приходили из React — идея та же, что в React, но реализация принципиально другая: вместо виртуального DOM SwiftUI строит граф атрибутов и по нему точечно понимает, чей body вообще нужно пересчитывать.
struct CartScreen: View {
@State private var model: CartViewModel
init(service: any CartService) { _model = State(initialValue: .init(service: service)) }
var body: some View {
NavigationStack {
content
.navigationTitle("Корзина")
.task { model.onAppear() } // отменяется сам при исчезновении вью
.refreshable { model.onAppear() } // pull-to-refresh — одна строка
}
}
@ViewBuilder
private var content: some View {
switch model.state {
case .idle, .loading:
ProgressView().controlSize(.large)
case .loaded(let items) where items.isEmpty:
ContentUnavailableView("Пусто", systemImage: "cart", description: Text("Добавьте товары"))
case .loaded(let items):
List(items) { CartRow(item: $0) } // Identifiable даёт строке стабильную идентичность
.listStyle(.plain)
case .failed(let message):
ContentUnavailableView { Label("Ошибка", systemImage: "exclamationmark.triangle") }
description: { Text(message) }
actions: { Button("Повторить") { model.onAppear() } }
}
}
}
Что здесь важно понять, а не просто скопировать.
Идентичность важнее вёрстки. SwiftUI решает «это та же вью или новая» по структурной позиции и по id. Неверная идентичность даёт сброшенное состояние, потерянный фокус клавиатуры и странные анимации; List(items) работает правильно ровно потому, что CartItem: Identifiable. И помните, что body может вызываться десятки раз в секунду: форматирование дат, сортировка и парсинг внутри него — прямой путь к пропуску кадров.
Наблюдение стало точечным. Макрос @Observable из фреймворка Observation (iOS 17+) отслеживает, какие именно свойства прочитала конкретная вью, и перерисовывает только её. Старый ObservableObject с @Published уведомлял весь объект целиком: одна вью читает title, а перерисовывается всё, что подписано. Разница на насыщенном экране — десятки процентов времени кадра.
UIKit никуда не делся. Он лежит под SwiftUI и остаётся нужен при тонкой работе с прокруткой и жестами, точном контроле транзакций анимации, поддержке старых версий iOS, большой существующей кодовой базе или когда конкретный компонент SwiftUI ещё сырой. Мост двусторонний и дешёвый:
struct ScannerView: UIViewControllerRepresentable { // UIKit внутрь SwiftUI
let onCode: (String) -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let vc = ScannerViewController(); vc.onCode = onCode; return vc
}
func updateUIViewController(_ vc: ScannerViewController, context: Context) {}
}
// и обратно: UIHostingController(rootView: CartScreen(service: service))
Здоровая стратегия в 2026-м для нового приложения: SwiftUI по умолчанию, UIKit точечно там, где упёрлись. Для приложения с историей — новые экраны на SwiftUI внутри старой навигации, без «большого переписывания». Подробнее про паттерны экранов и навигацию — Мобильный UI и навигация.
Что происходит между вашим state = ... и пикселем
Ключевая деталь схемы: ваш процесс не рисует на экран — он формирует дерево слоёв Core Animation и отправляет транзакцию в отдельный процесс-композитор. Отсюда два следствия. Простые анимации трансформаций и прозрачности почти ничего не стоят вашему приложению: они целиком на стороне композитора. И наоборот — если вы задержали главный поток на 30 мс, композитору нечего показать, он повторит старый кадр, а пользователь увидит рывок. Метрики и инструменты — в статье Производительность мобильного.
Жизненный цикл: приложение, которым распоряжается не оно само
Это главное, что ломает голову пришедшим из веба. Веб-страница живёт, пока открыта вкладка, и умирает целиком. Мобильное приложение переходит между состояниями по решению ОС, и в некоторых из них у вас нет ни процессорного времени, ни возможности что-либо сохранить.
Практические выводы, которые обязательно попадают в код.
Сохранять надо в didEnterBackground, а не «когда-нибудь». Переход из Suspended в NotRunning происходит без единого вызова: всё, что не записано на диск к моменту ухода в фон, потеряно навсегда. Отсюда же — восстановление состояния обязательно: пользователь ушёл в мессенджер, вернулся через час, а система выгрузила процесс. Приложение, открывшееся на главном экране вместо заполненной наполовину формы, воспринимается как «всё потеряло».
Есть жёсткие таймауты. Запуск дольше примерно 20 секунд — принудительное завершение сторожевым таймером с кодом 0x8badf00d. Файловый лок или открытая транзакция SQLite в момент засыпания — 0xdead10cc. Перегрев — 0xc00010ff. Эти коды стоит знать наизусть: они регулярно всплывают в отчётах App Store Connect, и «крэш» с ними лечится совсем не там, где обычный.
@main
struct ShopApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var container = AppContainer()
var body: some Scene {
WindowGroup { RootView().environment(container) }
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active: container.sync.resume()
// .inactive — и звонок, и переход в фон: момент для быстрого сохранения черновиков
case .inactive: container.draftStore.flush()
case .background:
container.sync.pause()
container.scheduleRefreshTask() // BGTaskScheduler
@unknown default: break
}
}
}
}
Отдельная тонкость iOS: с iOS 13 приложение может иметь несколько сцен (особенно на iPad и в Stage Manager). «Приложение ушло в фон» и «эта сцена ушла в фон» — разные события. Состояние, привязанное к экрану, должно жить в сцене, глобальное — в приложении.
Холодный старт: где утекают миллисекунды
Путь запуска фиксирован: fork/exec от SpringBoard → dyld грузит динамические библиотеки и связывает символы (pre-main) → main и инициализация UIApplication → didFinishLaunching → построение первого дерева вью → транзакция Core Animation → первый кадр. Ориентир — уложиться в 400 мс; сторожевой таймер бьёт около 20 секунд.
Три типичные причины медленного старта. Слишком много динамических фреймворков — время dyld растёт линейно, и десяток SDK от вендоров легко даёт лишние 200–300 мс до вашей первой строчки кода. Синхронная инициализация чужих SDK прямо в didFinishLaunching — каждый вендор считает свой SDK важнейшим, а суммарно это полсекунды белого экрана. И чтение большого файла состояния до первого кадра вместо ленивой загрузки. Измерять — Instruments App Launch и раздел Launch в Xcode Organizer, где видны реальные времена с устройств пользователей, а не с вашего флагмана.
Чем мобильный контекст отличается от веба — на конкретных API iOS
Этот раздел — сердцевина всего трека, и на iOS он выражается очень конкретно.
Батарея: вы тратите чужой ресурс, и это видно в настройках
В браузере энергопотребление вкладки — абстракция. В iOS пользователь открывает «Аккумулятор» и видит процент рядом с названием вашего приложения. Основные пожиратели по убыванию: радио (сотовая связь дороже Wi-Fi в разы), GPU и дисплей, GPS, пробуждения CPU. Ключевой неочевидный эффект — хвост радиомодуля: после передачи данных модем не выключается сразу, а держит повышенное энергосостояние ещё несколько секунд, поэтому двадцать мелких запросов раз в секунду тратят кратно больше, чем один пакетный. Отсюда практика: батчить телеметрию и отдавать системе право выбрать момент.
// Дискреционная фоновая загрузка: ОС сама выберет момент — Wi-Fi, зарядка, простой
let config = URLSessionConfiguration.background(withIdentifier: "com.shop.sync")
config.isDiscretionary = true // «не срочно» — экономит батарею и трафик
config.sessionSendsLaunchEvents = true // приложение разбудят по завершении
config.allowsExpensiveNetworkAccess = false // не тратим сотовую сеть на некритичное
config.allowsConstrainedNetworkAccess = false // уважаем режим экономии данных
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
Сеть: не «есть или нет», а весь спектр между
Веб-приложение при потере сети показывает ошибку и ждёт обновления страницы. Мобильное живёт в лифте, метро, роуминге и на «Wi-Fi, который подключился, но никуда не пускает».
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
// Правильная реакция на изменение пути — не «показать ошибку», а сменить политику:
// отложить префетч картинок, урезать качество, включить очередь отложенных операций.
Policy.current = .init(online: path.status == .satisfied,
saveData: path.isExpensive || path.isConstrained)
}
monitor.start(queue: .init(label: "net.path"))
let config = URLSessionConfiguration.default // не падать на первой секунде без сети
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 60
Это архитектурное следствие мобильного контекста: источник истины для UI — локальная база, а не сетевой ответ. Сеть лишь обновляет базу. Развёрнуто — Данные и оффлайн.
Разрешения: пользователь может сказать «нет», и это нормальный путь
В вебе разрешения тоже есть, но их мало и цена отказа обычно низкая. На iOS отказ по геолокации, камере, уведомлениям, фото, Bluetooth, микрофону, трекингу — штатный сценарий, и приложение обязано остаться полезным.
import AVFoundation
func ensureCameraAccess() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: return true
// Системный диалог показывается ОДИН раз за всю жизнь установки:
// спрашиваем в момент, когда польза очевидна, а не на старте приложения.
case .notDetermined: return await AVCaptureDevice.requestAccess(for: .video)
default: return false // ведём в Настройки, но не блокируем остальное приложение
}
}
Три правила, которые экономят конверсию: спрашивать в контексте действия, а не при первом запуске; заранее объяснять зачем — своим экраном перед системным диалогом; иметь работающий сценарий при отказе. И организационное: строка назначения в Info.plist (NSCameraUsageDescription и родня) обязательна — без неё приложение падает при обращении к API, а формальная отписка вроде «нужно для работы приложения» регулярно ловит отказ на ревью.
Ревью в сторах: релиз — это не деплой
Самое глубокое отличие от веба. В вебе плохой релиз откатывается за минуту. В App Store между «код готов» и «пользователь получил» стоит человек и очередь, а откатить уже установленную у пользователей версию невозможно — можно только выпустить новую и снова пройти ревью. Что это меняет: фича-флаги с серверным управлением становятся обязательными (выключить сломанную функцию без релиза — единственный доступный «откат»), поэтапная раскатка на 1/2/5/10/20/50/100% в течение недели становится нормой, а форматы данных и API проектируются с расчётом, что старая версия приложения проживёт на устройствах ещё год. Детали — Релиз и сторы.
Экосистема: чем вы будете пользоваться каждый день
Практические заметки по узлам, которые редко пишут в документации.
Xcode — единственный вариант, и он требует macOS. Это статья расходов: маки разработчикам, маки или облачные раннеры для CI; открытых альтернатив для сборки iOS-приложения нет. Сюда же — Apple Developer Program: 99 USD в год для распространения через App Store, 299 USD для внутреннего корпоративного.
Swift Package Manager выиграл. Он встроен в Xcode и toolchain, не требует Ruby и не переписывает файл проекта. CocoaPods с конца 2024 года в режиме поддержки без развития — новые проекты на нём начинать не стоит. Отдельная боль — вендоры, поставляющие только бинарные XCFramework: их нельзя пересобрать, отладить и часто нельзя проверить на соответствие требованиям приватности.
Instruments — не «профайлер», а набор специализированных линз. Медленный экран смотрят Time Profiler, рывки прокрутки — Animation Hitches, рост памяти — Allocations и Leaks, расход батареи — Energy Log. Ключевая дисциплина: профилировать только на устройстве и только в конфигурации Release — симулятор использует ресурсы мака и врёт в обе стороны. Для нового кода тесты пишут на Swift Testing (@Test, #expect, параметризация, нормальный async), XCTest остаётся для UI и старых наборов; про фермы и матрицу устройств — Тестирование мобильного и Мобильное и совместимость.
MetricKit — недооценённый инструмент. Подписавшись на MXMetricManager одним классом, вы раз в сутки получаете агрегированные метрики запуска, зависаний, энергии и памяти плюс MXDiagnosticPayload с диагностикой завершений — включая те, что не являются крэшами и потому отсутствуют в обычных крэш-репортерах: jetsam, watchdog, термальные. Это единственный способ узнать, что происходит на iPhone SE в поле, а не на вашем Pro Max.
Приватность стала частью сборки. Файл PrivacyInfo.xcprivacy описывает, какие данные собирает приложение и зачем используются определённые системные API; сторонние SDK из списка Apple обязаны поставлять свой манифест и подпись. Отсутствие манифеста — отказ на этапе загрузки билда, а не мягкое предупреждение. Смежные темы — Безопасность мобильного и Аутентификация.
Как выглядит один и тот же экран в четырёх стеках
Полезное упражнение против стековой мифологии: базовая структура «состояние → UI» сегодня одинакова везде. Различается не философия, а инфраструктура вокруг.
// iOS, SwiftUI + Observation
@Observable @MainActor
final class CartViewModel { private(set) var items: [CartItem] = [] }
struct CartScreen: View {
@State private var model = CartViewModel()
var body: some View {
List(model.items) { CartRow(item: $0) }.task { await model.load() }
}
}
// Android, Jetpack Compose + StateFlow
class CartViewModel(private val repo: CartRepository) : ViewModel() {
private val _items = MutableStateFlow<List<CartItem>>(emptyList())
val items: StateFlow<List<CartItem>> = _items.asStateFlow()
fun load() = viewModelScope.launch { _items.value = repo.load() }
}
@Composable
fun CartScreen(vm: CartViewModel = viewModel()) {
val items by vm.items.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { vm.load() }
LazyColumn { items(items, key = { it.id }) { CartRow(it) } }
}
// Flutter
class CartScreen extends StatelessWidget {
const CartScreen({super.key});
@override
Widget build(BuildContext context) => Consumer<CartModel>(
builder: (context, model, _) => ListView.builder(
itemCount: model.items.length,
itemBuilder: (context, i) => CartRow(item: model.items[i])));
}
// React Native, TanStack Query + FlashList
export function CartScreen() {
const { data = [], isLoading } = useCart();
if (isLoading) return <ActivityIndicator />;
return <FlashList data={data} keyExtractor={(i) => i.id}
renderItem={({ item }) => <CartRow item={item} />} />;
}
Четыре стека, одна идея: состояние наблюдаемо, список рисуется лениво, элементы имеют стабильные ключи. Навык переносится почти целиком — переносится не навык, а платформенное знание: жизненный цикл, разрешения, фон, поведение при убийстве процесса, требования сторов. Именно поэтому «мы возьмём Flutter, чтобы не нанимать мобильщиков» — самая дорогая ошибка в этой области. Про состояние в целом — Управление состоянием.
Архитектура нативного iOS-приложения: минимальный скелет
Вью не знает о сети — только про state. ViewModel зависит от протокола, а не от реализации, поэтому тестируется без сети и без базы. Репозиторий — единственное место, где встречаются кэш и сеть, и именно он реализует политику «сначала локальное, потом обновление». Хранилище — актор, потому что к нему обращаются из разных задач. Внедрение зависимостей на iOS обычно делают руками или через @Environment: тяжёлые DI-контейнеры с рантайм-резолвом здесь редко окупаются, а компилятор Swift неплохо ловит забытые зависимости в конструкторе. Подробнее про слои, MVVM и MVI — Архитектура мобильного приложения.
Типичные ошибки нативных iOS-команд
- Вся работа в
didFinishLaunching. Аналитика, реклама, пуши, ремоут-конфиг, прогрев кэшей — и старт уезжает за секунду. Инициализируйте лениво, по первому обращению. - Игнорирование
Sendableи предупреждений конкурентности. Отключить строгий режим проще, чем разобраться, но гонки в мобильном приложении проявляются как «редкие невоспроизводимые крэши у 0,3% пользователей» — самый дорогой класс багов. - Синхронный доступ к диску на главном потоке.
UserDefaultsдля больших объектов, чтение JSON, миграции Core Data вviewDidLoad. На флагмане незаметно, на бюджетном устройстве с забитым накопителем — зависания и watchdog. - Тестирование только на симуляторе и только на своём телефоне. Симулятор быстрее любого iPhone и не имеет ни термального троттлинга, ни малой памяти, ни плохой сети.
- Изображения без даунсемплинга. Фото 4000×3000 в ячейке списка высотой 100 пунктов — это 48 МБ распакованного растра. Классическая причина jetsam-завершений.
Task { }вместо.task { }в SwiftUI и баннер «нет интернета» вместо оффлайн-режима. Оба — симптомы того, что жизненный цикл экрана и сеть считаются надёжными.- Хранение секретов в
UserDefaultsили в коде. Это обычный plist в контейнере приложения, извлекаемый из бэкапа. Секреты — только Keychain, а лучше вообще не на клиенте. - Запрос всех разрешений на первом экране и отсутствие фича-флагов. Отказ по разрешению необратим без похода в Настройки, а флаги — единственный доступный вам способ «откатить» релиз.
Как это выглядит в проде
Зрелая нативная iOS-команда обычно приходит к похожему набору практик. Модульность через локальные Swift-пакеты: фичи изолированы, собираются и тестируются отдельно, время инкрементальной сборки не растёт квадратично. Бюджеты производительности, зафиксированные в CI: время до первого кадра, размер бандла, число хитчей на эталонном сценарии — регрессия ломает сборку. Поэтапная раскатка с автоматическим стопом по падению crash-free rate. Мониторинг из двух источников: MetricKit плюс сторонний крэш-репортер, потому что первый агрегирует и запаздывает, а второй даёт стек прямо сейчас. И, что часто недооценивают, отдельный контур бета-канала — внутренний TestFlight с ежедневными сборками, где ловится всё, что не воспроизводится на симуляторе.
Отдельный организационный факт: обновления iOS ставит сама Apple, поэтому доля пользователей на свежей мажорной версии за год обычно превышает три четверти. Это позволяет двигать deployment target вперёд смелее, чем на Android, и пользоваться новыми API без обходных путей — реальное преимущество платформы, которое многие команды не используют из осторожности.
Мини-итог
- Нативная iOS даёт прямой доступ к платформе без посредника и нулевой лаг за новыми API; всё остальное, включая производительность, — производные.
- Swift выбирает значения по умолчанию и детерминированный ARC вместо GC: нет пауз сборщика, но циклы удержания — ваша ответственность, и в долгоживущем процессе они дороже.
- Структурированная конкурентность с акторами и
@MainActorпревращает потокобезопасность в свойство типа, а отмену — в бесплатную экономию батареи и трафика. - SwiftUI — декларативный UI поверх графа атрибутов; UIKit остаётся живым фундаментом и точечным инструментом, а не «легаси, которое скоро уберут».
- Жизненный цикл — главное отличие от веба: система вправе заморозить и убить процесс без предупреждения. Отсюда сохранение в фоне, восстановление при запуске и оффлайн-первый подход к данным. Батарея, сеть с провалами, разрешения и ревью в сторах — не «нефункциональные требования», а ограничения, формирующие архитектуру с первого дня.
- Выбор между нативом и кроссплатформой решается природой продукта, а не эстетикой: приложение как бизнес — натив или KMP; приложение как витрина — кроссплатформа.
Источники
- The Swift Programming Language — официальная книга по языку.
- Swift Concurrency Migration Guide — переход на строгую проверку изоляции данных.
- SE-0304: Structured Concurrency и SE-0306: Actors.
- SwiftUI, Observation, UIApplicationDelegate и BackgroundTasks — UI, наблюдение, жизненный цикл и фон.
- NWPathMonitor, MetricKit и Analyzing a Crash Report — сеть, метрики поля, разбор кодов завершения.
- Human Interface Guidelines, App Store Review Guidelines и Privacy manifest files.
- Swift Package Manager, WWDC-сессии, Swift by Sundell, objc.io и Point-Free.
Что дальше
Нативная Android-разработка: Kotlin, Compose, жизненный цикл, экосистема — вторая половина картины: почему тот же по смыслу код на Android пишется иначе, чем на iOS, что делает фрагментация устройств и вендорских прошивок с фоновой работой, и где Compose расходится со SwiftUI не синтаксисом, а моделью исполнения.