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

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

Нативная 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-слой не переносится

Три практических правила отсюда:

  1. Продукт, где мобильное приложение — сам бизнес (банк, такси, соцсеть, камера, здоровье, всё с фоном и датчиками), почти всегда уезжает в натив или KMP. Не из-за FPS, а потому что вы годами будете упираться в API, которых нет в мосту.
  2. Продукт, где приложение — витрина к бэкенду (внутренний портал, каталог, b2b-инструмент, MVP гипотезы), почти всегда должен быть кроссплатформенным. Платить двойную цену за списки и формы — расточительство.
  3. Решение обратимо только в одну сторону дёшево. Уйти с натива на 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 сам, объект умирает ровно в момент, когда счётчик доходит до нуля.

Память Swift-процесса, ARC и цикл удержания

Плюс очевиден: нет пауз 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 = ... и пикселем

Бюджет кадра на iOS: от мутации состояния до пикселя

Ключевая деталь схемы: ваш процесс не рисует на экран — он формирует дерево слоёв 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 и инициализация UIApplicationdidFinishLaunching → построение первого дерева вью → транзакция 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; приложение как витрина — кроссплатформа.

Источники

Что дальше

Нативная Android-разработка: Kotlin, Compose, жизненный цикл, экосистема — вторая половина картины: почему тот же по смыслу код на Android пишется иначе, чем на iOS, что делает фрагментация устройств и вендорских прошивок с фоновой работой, и где Compose расходится со SwiftUI не синтаксисом, а моделью исполнения.

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

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

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

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