Приложение бывает not running (не запущено), inactive (на экране, но без событий), active (на экране и получает события), background (не на экране, код ещё работает) или suspended (в памяти, ничего не выполняет). Между ними его переводят запуск, прерывания, уход из приложения и нехватка памяти.
Запуск: not running → inactive → active. Запуск ради silent push или задачи BGTaskScheduler идёт сразу в background и не показывает UI.
Прерывание: Пункт управления, системный алерт или запрос Face ID: active → inactive и обратно, до background дело не доходит.
Уход: active → inactive → background. Через несколько секунд или когда закончится его фоновая задача, система приостанавливает приложение, и никакой колбэк об этом не сообщает.
Возврат: suspended → background → inactive → active, память ровно такая, какой была.
Завершение: при нехватке памяти система молча убивает приостановленные приложения, и следующий запуск будет холодным. applicationWillTerminate вызывается, только если приложение убили, пока оно ещё выполнялось.
iOSMiddle
Жизненный цикл, фон и push
Холодный старт, scene phase, фоновые режимы, BGTaskScheduler, APNs, silent push, universal links
Закрыто вопросов
0 / 23
- 0Повторить
- 3Часто спрашивают
Вопросы
AppDelegate отвечает за процесс: запуск, push-токены, события фонового URLSession, выбор конфигурации сцены. Каждый SceneDelegate владеет одним окном и его жизненным циклом на переднем плане и в фоне. В iOS 13 их разделили, чтобы одно приложение могло показывать несколько окон, каждое в своём состоянии.
**AppDelegate**: didFinishLaunching вызывается один раз за процесс, с UI или без, поэтому сюда идут настройка SDK, регистрация BGTaskScheduler и делегат уведомлений.
**SceneDelegate**: scene(_:willConnectTo:options:) создаёт UIWindow и получает ссылку, которая запустила сцену. За один процесс он может вызваться много раз, потому что система отключает фоновые сцены и подключает их позже.
Зачем сцены: на iPad два окна могут быть в разных состояниях, и общий для приложения applicationDidEnterBackground не может сказать, какое окно ушло. Со сценами UIKit перестаёт вызывать методы жизненного цикла app delegate; уведомления по-прежнему приходят.
Обязательно: по TN3187 от Apple, в релизе после iOS 26 приложение, собранное с последним SDK, обязано перейти на сцены, иначе оно не запустится; достаточно одной сцены в UIApplicationSceneManifest.
Читайте @Environment(\.scenePhase) и реагируйте в .onChange(of:): .active, .inactive и .background соответствуют состояниям сцены в UIKit, а @UIApplicationDelegateAdaptor возвращает AppDelegate для колбэков, для которых в SwiftUI нет модификатора.
Важно, где вы его читаете: внутри вью scenePhase означает фазу конкретного окна. В body типа App это агрегат: .active, если активна хоть одна сцена, и .background, только когда в фоне все.
**.inactive не означает ухода**: эта фаза появляется и во время запроса Face ID или при открытии Control Center, поэтому сохраняйте на .background, а не на .inactive.
Фазы suspended нет: как и в UIKit, о заморозке и завершении ничто не сообщает; .background остаётся последним гарантированным сигналом.
**@UIApplicationDelegateAdaptor**: по-прежнему нужен для колбэков APNs-токена, ранней регистрации задач обработки BGTaskScheduler и других хуков UIApplicationDelegate, которые SwiftUI не открывает. Для фонового обновления и событий фоновой URLSession ручные хуки заменяют модификаторы сцены .backgroundTask(.appRefresh(_:)) и .backgroundTask(.urlSession(_:)).
Нет. Он вызывается, только если приложение завершают, пока оно выполняется, а большинство умирает замороженными, без всякого колбэка. Сохраняйте в sceneDidEnterBackground (или на scenePhase == .background): это последний момент, когда ваш код гарантированно выполняется.
Jetsam: при нехватке памяти ядро первыми молча убивает замороженные приложения. Пользователь возвращается через пару часов, видит launch screen и пустую форму и считает это крашем.
Принудительное закрытие из переключателя приложений: в этот момент приложение обычно уже заморожено, так что колбэка опять нет.
Сохраняйте по ходу работы: записывайте черновики во время ввода или при уходе в фон и восстанавливайте с диска в scene(_:willConnectTo:options:), а не из объектов, которые, как вы надеетесь, ещё в памяти.
Измеряйте: MXAppExitMetric из MetricKit разделяет фоновые завершения на убийства за превышение памяти, убийства watchdog и обычные выходы, так что видно, как часто это происходит на самом деле.
Локальное уведомление планирует само приложение на устройстве с помощью триггера; удалённое отправляет ваш сервер через APNs на device token. Для пользователя они выглядят одинаково и используют одно разрешение, которое запрашивается через UNUserNotificationCenter.requestAuthorization(options:).
Локальные: UNNotificationRequest с триггером по времени, календарю или геопозиции. Работают офлайн и не требуют сервера; система хранит не больше 64 запланированных на приложение.
Удалённые: нужны capability Push Notifications, device token от registerForRemoteNotifications() и сервер, который общается с APNs. Их содержимое можно менять уже после релиза, и они доходят до пользователей, которые не открывали приложение.
Один запрос, один раз: системный диалог показывается только в первый раз. После отказа вернуть разрешение можно только через Настройки (UIApplication.openSettingsURLString), поэтому спрашивайте в момент, когда пользователю понятно, зачем.
Для токена разрешение не нужно: registerForRemoteNotifications() работает и без разрешения на алерты, на этом держатся silent push.
sceneDidBecomeActive срабатывает заново после каждого прерывания, которое лишь делает приложение inactive (запроса Face ID, Control Center, системного алерта), поэтому логирует лишние старты без парного конца. Начинайте сессию в sceneWillEnterForeground, парном к sceneDidEnterBackground.
Прерывания не уводят в фон: они идут sceneWillResignActive → sceneDidBecomeActive, не задевая пару foreground/background, поэтому именно вход в foreground и уход в background дают симметричный сигнал.
И холодный старт, и возврат проходят через него: при возврате вызываются только sceneWillEnterForeground и sceneDidBecomeActive; при холодном старте сначала подключается сцена, а затем идут те же два колбэка, так что одна точка покрывает оба случая.
**Не считайте в didFinishLaunching**: система запускает процесс и в фоне, ради silent push или задачи BGTaskScheduler, без пользователя и без UI.
Используйте паузу бездействия: SDK аналитики завершают сессию по таймауту в несколько минут (Firebase по умолчанию 30 минут, Amplitude 5), поэтому запоминайте время ухода и сравнивайте с временем возврата.
В SwiftUI та же ловушка: scenePhase == .active срабатывает и после каждого прерывания. Запоминайте уход в .background во флаге и начинайте сессию на следующем .active только если флаг выставлен, затем сбрасывайте его.
Система создаёт процесс, dyld загружает динамические библиотеки, отрабатывают рантайм Objective-C и статические инициализаторы, затем main вызывает UIApplicationMain, который создаёт UIApplication и ваш app delegate и только после этого вызывает willFinishLaunching и didFinishLaunching.
До main
SpringBoard просит систему запустить приложение и сразу показывает launch screen из UILaunchScreen или launch storyboard: это статичный снимок, вашего кода там нет.
Ядро отображает исполняемый файл в память, dyld загружает все динамические фреймворки, применяет rebase и bind (chained fixups на текущих тулчейнах) и регистрирует классы и категории Objective-C.
Выполняются инициализаторы: методы +load, статические конструкторы C++, функции с __attribute__((constructor)). Глобальные переменные Swift и static let ленивые и здесь не выполняются.
После main
UIApplicationMain создаёт UIApplication, инстанцирует класс делегата, читает Info.plist и вызывает willFinishLaunchingWithOptions, затем didFinishLaunchingWithOptions. В SwiftUI @main на типе App генерирует тот же main.
Со сценами дальше вызывается application(_:configurationForConnecting:options:), сцена подключается в scene(_:willConnectTo:options:), и запуск заканчивается, когда первый кадр оказывается на экране.
Почему это важно
Время до main растёт с числом динамических фреймворков и с работой в +load и конструкторах, поэтому меньше библиотек или статические библиотеки и пустые инициализаторы ускоряют запуск.
Если запуск затягивается, watchdog убивает приложение с кодом 0x8badf00d; лимит не документирован и обычно называют около 20 секунд, но практическая цель: первый кадр заметно быстрее секунды.
После ухода пользователя приложение получает несколько секунд в фоне, а затем замораживается. Для всего, что дольше, нужен разрешённый механизм: фоновая задача, задача BGTaskScheduler, фоновая URLSession, push или фоновый режим, используемый строго по назначению.
Разрешённые инструменты
**beginBackgroundTask: доделать уже начатое, например сохранение или один запрос: секунды, а не минуты.
BGAppRefreshTask / BGProcessingTask: откладываемая работа, которую система запускает, когда сама решит, с учётом паттернов использования, батареи и сети.
Фоновая URLSession: передачи идут в системном демоне и переживают заморозку; когда они заканчиваются, приложение перезапускается.
Silent push: подсказка от сервера, которая будит приложение примерно на 30 секунд; система её ограничивает.
BGContinuedProcessingTask (iOS 26)**: работа, запущенная пользователем, например экспорт, которая продолжается с системным индикатором прогресса.
Фоновые режимы
UIBackgroundModes в Info.plist держит приложение запущенным только под конкретную задачу: audio, location, voip, bluetooth-central, external-accessory. Режимы fetch, processing и remote-notification лишь позволяют системе вас будить.
Режим, который приложение реально не использует, отклоняют на ревью по guideline 2.5.4; классический пример: audio, включённый ради живой синхронизации.
Что всё равно остановит
Low Power Mode, выключенное Background App Refresh и принудительное закрытие отключают большинство пробуждений, а watchdog и jetsam следят за лимитами времени и памяти. Проектируйте с расчётом на «может вообще не запуститься»: синхронизируйтесь заново при следующем выходе на передний план.
Нет expiration handler, а задача завершается только при успехе, поэтому медленная или упавшая загрузка оставляет идентификатор открытым; когда бюджет заканчивается, watchdog убивает приложение с кодом 0x8badf00d, и пользователь видит это как краш при переключении на другое приложение.
Бюджет короткий: на текущих iOS около 30 секунд, а не минуты. Читайте UIApplication.shared.backgroundTimeRemaining, а не полагайтесь на конкретное число.
Expiration handler обязателен: он вызывается на главном потоке незадолго до конца времени и должен отменить работу и синхронно вызвать endBackgroundTask.
Завершайте ровно один раз и на каждом пути: успех, ошибка и истечение времени. Сбрасывайте сохранённый идентификатор в .invalid, чтобы повторный вызов ничего не делал.
Система может отказать: beginBackgroundTask может вернуть .invalid; не начинайте работу, которая от неё зависит.
Не тот инструмент для больших загрузок: загрузка через фоновую URLSession продолжается после заморозки и перезапускает приложение, когда заканчивается.
Вью считает тики, а не измеряет время. В фоне приложение замораживается через несколько секунд и таймер перестаёт срабатывать, поэтому каждая минута вне приложения теряется, и тренировка оказывается короче ровно на это время.
Почему: замороженный процесс не выполняет никакого кода, так что не срабатывают ни Timer, ни Timer.publish. После разморозки повторяющийся таймер срабатывает один раз за все пропущенные интервалы, а не догоняет их, и elapsed += 1 добавляет одну секунду за десять минут отсутствия. Блокировка экрана приводит к тому же.
Исправление: хранить Date начала и на каждом тике вычислять прошедшее время по часам. Таймер теперь только запускает перерисовку, поэтому пропущенный тик больше не теряет время. Держите startedAt в модели и сохраняйте его: @State пропадёт, если система убьёт замороженное приложение.
Можно вообще без таймера: Text(startedAt, style: .timer) считает сам, а TimelineView(.periodic(from:by:)) перерисовывает по расписанию; оба вычисляют значение от дат.
Та же ловушка в других местах: обратный отсчёт до повторной отправки OTP, тайм-ауты сессии и экраны «попробуйте снова через 30 с» ломаются так же. Храните дедлайн, а не счётчик; если пользователь не должен обходить ограничение, переводя часы на устройстве, сверяйте его со временем сервера.
Как поймать: на устройстве запустите таймер, заблокируйте телефон на минуту и вернитесь. В юнит-тесте внедрите часы и проверьте, что значение берётся из них, а не из количества тиков.
Зарегистрируйте обработчик до возврата из didFinishLaunching, добавьте идентификатор в Info.plist, затем вызовите submit с BGAppRefreshTaskRequest или BGProcessingTaskRequest. Когда запускать, решает система, а тихие ошибки в настройке делают так, что задача не запускается никогда.
Какой запрос выбрать
**BGAppRefreshTask**: около 30 секунд, чтобы подтянуть свежий контент до того, как пользователь откроет приложение. Нужен фоновый режим fetch.
**BGProcessingTask**: минуты, обычно ночью, когда устройство заряжается и простаивает; можно потребовать внешнее питание или сеть. Нужен режим processing.
Почему задача не срабатывает
Идентификатора нет в BGTaskSchedulerPermittedIdentifiers: submit бросает .notPermitted, а try? прячет это на целый релиз.
Регистрация после возврата из didFinishLaunching вызывает NSInternalInconsistencyException: регистрируйте синхронно там, а не во вью и не после await.
Обработчик не отправляет следующий запрос, поэтому задача отработала один раз и больше никогда.
setTaskCompleted(success:) не вызван до срабатывания expiration handler: приложение убивают, а в следующий раз понижают приоритет.
earliestBeginDate задаёт нижнюю границу, а не расписание. Приложение, которое пользователь редко открывает, редко и будят, а выключенное Background App Refresh, Low Power Mode или принудительное закрытие останавливают всё полностью.
Симулятор эти задачи не запускает никогда.
Логируйте pendingTaskRequests() при запуске, чтобы видеть, что на самом деле держит система, и всё равно выполняйте ту же синхронизацию при следующем выходе на передний план.
На устройстве уведите приложение в фон после того, как оно отправило запрос, поставьте паузу в Xcode и вызовите из LLDB приватный _simulateLaunchForTaskWithIdentifier:, и зарегистрированный обработчик выполнится сразу. _simulateExpirationForTaskWithIdentifier: так же вызывает expiration handler.
Только устройство: симулятор вообще не запускает задачи BGTaskScheduler.
Только для отладки: это приватные методы, которые существуют ровно для этого; вызов в релизном коде недопустим.
Чего это не доказывает
Что система вас запланировала бы: earliestBeginDate, требования к питанию и сети, модель использования на устройстве и Low Power Mode обходятся.
Бюджет времени: с подключённым отладчиком watchdog выключен, поэтому слишком долгий обработчик проходит здесь и умирает у пользователей.
Холодный фоновый запуск: процесс уже прогрет, поэтому код запуска, который предполагает наличие UI, остаётся непроверенным.
Поэтому путь кода проверяйте через LLDB, а планирование проверяйте отдельно: логируйте внутри обработчика через Logger и смотрите Console или собственные выгруженные логи после настоящего ночного запуска.
Приложение запрашивает у APNs device token и отправляет его на ваш сервер; сервер шлёт JSON-payload для этого токена в APNs по HTTP/2, аутентифицируясь ключом .p8 или сертификатом, а APNs доставляет его, чтобы iOS показала уведомление или разбудила приложение.
На устройстве
registerForRemoteNotifications() приводит к вызову application(_:didRegisterForRemoteNotificationsWithDeviceToken:) с непрозрачными Data. Ошибки приходят в didFailToRegisterForRemoteNotificationsWithError и обычно означают отсутствующий entitlement.
Кодируйте байты в hex сами: начиная с iOS 13 deviceToken.description возвращает {length = 32, bytes = ...}, а не hex-строку.
Токен меняется при переустановке, при восстановлении на новое устройство и когда так решит система, поэтому регистрируйтесь при каждом запуске и делайте upsert на сервере.
На сервере
Отправляйте на api.push.apple.com для сборок App Store и TestFlight и на api.sandbox.push.apple.com для development-сборок; если перепутать, получите 400 BadDeviceToken.
Заголовки: apns-topic (bundle id), apns-push-type (alert, background и другие), apns-priority и по желанию apns-expiration и apns-collapse-id.
Payload: словарь aps (alert, badge, sound, content-available, mutable-content, category) плюс ваши ключи, до 4 КБ.
410 Unregistered означает, что приложения на этом устройстве больше нет: удаляйте токен, а не повторяйте отправку.
Храните токены в связке пользователя с устройством и удаляйте связку при выходе из аккаунта, иначе следующий вошедший получит уведомления предыдущего пользователя.
В payload нужен "content-available": 1 и ничего видимого, в запросе нужны apns-push-type: background и apns-priority: 5, а приложению нужен фоновый режим remote-notification. Но даже тогда система считает такой push подсказкой и может задержать или отбросить его.
Что требуется
В словаре aps только content-available. Если добавить alert, sound или badge, это станет обычным уведомлением.
Приоритет 10 для чисто фонового push недопустим; используйте 5.
Реализуйте application(_:didReceiveRemoteNotification:fetchCompletionHandler:) или его async-форму и уложитесь примерно в 30 секунд, вернув честный UIBackgroundFetchResult.
Почему он всё равно не доходит
Система выделяет каждому приложению бюджет фоновых пробуждений; Apple советует не больше двух-трёх таких push в час, так что push на каждое изменение данных быстро упирается в ограничения.
Low Power Mode, выключенное Background App Refresh или принудительное закрытие из переключателя приложений прекращают доставку в приложение.
Пока устройство офлайн, APNs хранит только последнее уведомление для приложения, а apns-expiration: 0 означает «доставить сейчас или выбросить».
Считайте его подсказкой, что что-то изменилось, а не каналом доставки: синхронизируйтесь при пробуждении и ещё раз при следующем выходе на передний план.
completionHandler(.newData) вызывается сразу после создания Task, до того как синхронизация что-то сделала. Этот вызов сообщает iOS, что работа закончена, поэтому приложение вскоре замораживается и запрос застывает на полпути; входящие обновятся только при следующем выходе на передний план.
Почему: Task { } только планирует работу и сразу возвращается, поэтому следующая строка выполняется немедленно. Completion handler служит единственным способом сказать системе «готово», и после него она вправе заморозить процесс.
Исправление: вызывать обработчик внутри Task, после возврата из sync(), ровно один раз на каждом пути: .newData или .noData по реальному результату, .failed при ошибке.
Лучше async-вариант: с application(_:didReceiveRemoteNotification:) async -> UIBackgroundFetchResult приложение считается закончившим работу, только когда функция вернулась, так что такую ошибку сделать заметно труднее: достаточно дождаться sync() до возврата.
Обратный баг: не вызвать обработчик вовсе. Бюджет составляет около 30 секунд, после чего приложение убивают, поэтому дайте синхронизации собственный тайм-аут с запасом. Система также учитывает время и энергию, потраченные на каждое пробуждение, и медленные обработчики потом будят реже.
Как поймать: логируйте начало и конец синхронизации через Logger и отправьте фоновый пуш из Push Notifications Console на устройство, где приложение в фоне. Если строка о завершении появляется только после открытия приложения, это тот самый баг.
Реализуйте userNotificationCenter(_:willPresent:), чтобы решать, показывать ли уведомление на переднем плане, и зарегистрируйте при запуске UNNotificationCategory с действиями; ключ category в payload выбирает кнопки, а userNotificationCenter(_:didReceive:) обрабатывает тапы и действия.
Откуда берутся кнопки
UNNotificationAction(identifier:title:options:) описывает одну кнопку. С .foreground она открывает приложение; без него обработчик выполняется в фоне несколько секунд. UNTextInputNotificationAction добавляет поле для ответа прямо в уведомлении.
Сгруппируйте действия в UNNotificationCategory, зарегистрируйте их один раз через setNotificationCategories(_:) и отправляйте "category": "MESSAGE" внутри aps.
Два колбэка делегата
willPresent вызывается только пока приложение на переднем плане. Верните [], чтобы скрыть уведомление для уже открытого чата, иначе [.banner, .list, .sound]; в iOS 14 .alert заменили на .banner и .list.
didReceive вызывается на каждый тап и каждое действие, на переднем плане и нет. actionIdentifier равен UNNotificationDefaultActionIdentifier при тапе по самому уведомлению, а введённый текст приходит в UNTextInputNotificationResponse.userText.
Ловушка
Назначайте UNUserNotificationCenter.current().delegate до возврата из didFinishLaunching. Если приложение запустил тап по уведомлению, ответ доставляется сразу после запуска; если делегат в этот момент ещё nil, ответ теряется и приложение открывается на экране по умолчанию. Маршрутизации место в didReceive, а не в willPresent.
Если добавить .provisional в requestAuthorization, разрешение выдаётся без диалога, но уведомления тихо попадают в Центр уведомлений, без баннера и звука, пока пользователь не нажмёт «Оставить». А .authorized значит лишь, что приложению можно отправлять уведомления, а не что баннеры включены.
Пробный режим: на каждом тихом уведомлении есть кнопки «Оставить» и «Выключить». «Оставить» предлагает выбрать заметную или тихую доставку; «Выключить» переводит в .denied, и оттуда вернуть всё можно только через Настройки.
Push по-прежнему работает: registerForRemoteNotifications() возвращает настоящий токен, так что доставка через APNs реальная, просто без звука и баннера.
Статусы: .notDetermined (ещё можно спросить), .provisional, .authorized, .denied (отправляйте пользователя в UIApplication.openSettingsURLString) и .ephemeral для App Clips.
Разрешено не значит видно: в UNNotificationSettings отдельное значение для каждой поверхности: alertSetting, soundSetting, lockScreenSetting, notificationCenterSetting и scheduledDeliverySetting для Scheduled Summary. Режимы фокусирования тоже могут придерживать уведомления. Проверьте всё это, прежде чем винить APNs.
Рабочий паттерн: при первом запуске запросите provisional-разрешение, заслужите повышение полезными уведомлениями, а настоящий диалог показывайте только тогда, когда пользователь просит то, что без него не работает.
Кастомная схема вроде myapp:// ничем не подтверждена (её может заявить любое приложение, и она не срабатывает, если приложение не установлено), а universal link работает как обычный https-URL, который открывает приложение только после того, как iOS проверила домен, а иначе открывает сайт.
Что нужно universal link
Файл apple-app-site-association по адресу https://<domain>/.well-known/: JSON по HTTPS без редиректа, где в applinks.details указан TEAMID.bundleid и components, совпадающие с путём.
Entitlement Associated Domains applinks:example.com в provisioning profile, которым на самом деле подписана сборка. Отсутствующий entitlement ломает всё молча и выглядит как ошибка в файле.
С iOS 14 устройства получают файл через CDN Apple, который кэширует его на сутки и дольше. applinks:example.com?mode=developer обходит кэш на устройстве, где в настройках разработчика включена разработка associated domains.
Почему открывается Safari
Ссылка на тот же домен, нажатая на странице этого домена или введённая в адресную строку, остаётся в Safari.
Пользователь однажды выбрал открыть сайт в Safari через баннер, и этот выбор запоминается для домена.
Настройки → Разработчик → Universal Links → Diagnostics проверяет URL прямо на устройстве.
Кастомные схемы по-прежнему уместны для колбэков между приложениями, например для OAuth-редиректа в ASWebAuthenticationSession. Любой входящий URL считайте недоверенным вводом.
При холодном старте ссылка приходит не через scene(_:continue:): она лежит в connectionOptions.userActivities внутри scene(_:willConnectTo:options:), а этот код её игнорирует.
Два пути, один обработчик: отправляйте и холодный путь (connectionOptions), и тёплый (scene(_:continue:)) в одну функцию маршрутизации.
Кастомные схемы разделены так же: connectionOptions.urlContexts при холодном старте, scene(_:openURLContexts:) во время работы.
Тапы по уведомлениям: у них то же деление на холодный и тёплый путь (см. вопрос про делегат уведомлений).
SwiftUI: .onOpenURL получает ссылки на обоих путях, поэтому это в основном баг UIKit.
Проверяйте холодный путь специально: убейте приложение и нажмите ссылку в Заметках или Сообщениях. Запуск из Xcode держит приложение тёплым и прячет баг.
Silent push, задача BGTaskScheduler, завершившаяся фоновая URLSession, событие геолокации или Bluetooth могут запустить процесс без UI. Выполняется тот же didFinishLaunching, но сцена не подключается и интерфейс не показывается.
Как это распознать
У фонового запуска нет сцены: scene(_:willConnectTo:options:) не вызывается, а launchOptions содержит причину (.remoteNotification, .location, .bluetoothCentrals) или пуст при пробуждении ради BGTaskScheduler. Всё, что зависит от UI, и тяжёлую инициализацию делайте в scene(_:willConnectTo:options:) или sceneWillEnterForeground, а не в didFinishLaunching.
В SwiftUI-приложении делегат из @UIApplicationDelegateAdaptor получает тот же колбэк.
Чего этот путь должен избегать
Обращений к UI: окна нет, и обращение к коду вью здесь даёт краш-репорты без единого пользователя рядом.
Чтения защищённых данных до первой разблокировки: файлы с NSFileProtectionComplete и элементы Keychain с kSecAttrAccessibleWhenUnlocked недоступны, ошибка errSecInteractionNotAllowed. Проверяйте isProtectedDataAvailable.
Инициализации всего подряд: каждый SDK, прогрев кэша и миграция тратят батарею на работу, которую никто не видит, а бюджет измеряется секундами.
Подсчёта открытия приложения: аналитика в didFinishLaunching выдумывает запуски, которых не было.
Что он должен сделать
При каждом запуске регистрировать обработчики BGTaskScheduler и назначать делегат уведомлений, выполнить свою единственную задачу и вызвать её completion handler; если этого не сделать, будущие пробуждения станут реже. Делайте работу идемпотентной и отменяемой: то же пробуждение может повториться, а процесс могут заморозить на полпути.
Принудительное закрытие говорит iOS, что пользователь хочет остановить приложение: silent push, задачи BGTaskScheduler, передачи фоновой URLSession и перезапуски по геолокации прекращаются, пока пользователь снова не откроет приложение, а alert-push и VoIP push по-прежнему доходят.
Остановлено до следующего ручного запуска
Silent push с content-available в приложение не доставляются.
BGAppRefreshTask и BGProcessingTask не запускаются; отправленные запросы просто лежат.
Передачи фоновой URLSession система отменяет.
События значительной смены местоположения, регионов и визитов не перезапускают приложение, закрытое пользователем: его нужно открыть вручную; перезапуск работает только для приложений, которые выгрузила сама система.
По-прежнему доставляется
Alert-push: их рисует система, notification service extension всё равно запускается в своём процессе, а тап запускает приложение как обычно. Уже запланированные локальные уведомления тоже срабатывают.
VoIP push через PushKit: при условии, что каждый из них сообщается в CallKit как входящий звонок; иначе iOS убивает приложение и со временем перестаёт их доставлять.
Почему это важно
Тестировщики по привычке закрывают приложения принудительно, а потом сообщают, что фоновая синхронизация никогда не работает; воспроизводите на приложении, которое заморозила или выгрузила сама система. И никогда не завязывайте актуальность данных только на silent push: одно смахивание выключает его для этого пользователя, поэтому приложение должно сверяться с сервером при каждом выходе на передний план.
Начиная с iOS 15 система может запустить процесс заранее, перед предсказанным запуском, выполнить dyld и статические инициализаторы и остановиться перед UIApplicationMain. Тогда таймер запуска, привязанный к старту процесса, считает минуты, которые пользователь не ждал.
Что выполняется при prewarm
Динамическая линковка, методы +load и статические конструкторы C++ выполняются по расписанию системы, возможно, пока телефон лежит в кармане.
UIApplicationMain, didFinishLaunching, подключение сцены и первый кадр ждут настоящего тапа. Глобальные переменные Swift и static let ленивые, поэтому выполняются, только если что-то при prewarm к ним обратится.
Как мерить правильно
Распознавайте его: ProcessInfo.processInfo.environment["ActivePrewarm"] == "1", и складывайте такие замеры отдельно, а не усредняйте вместе со всеми.
Запускайте отсчёт в didFinishLaunching, который при prewarm никогда не выполняется, и останавливайте на первом отрисованном кадре, а не от времени старта процесса из sysctl.
В продакшене отслеживайте MXAppLaunchMetric.histogrammedOptimizedTimeToFirstDraw из MetricKit: эта метрика уже учитывает prewarming.
Побочные эффекты
Всё, что в +load или конструкторе открывает базу данных, создаёт id сессии или отправляет запрос, теперь делает это в случайный момент, возможно, задолго до появления пользователя. Держите работу до main пустой и переносите настройку в didFinishLaunching или позже.
Позволяет работе, которую пользователь явно запустил (экспорт, загрузка, большая правка), продолжаться после ухода из приложения, с системным индикатором прогресса и кнопкой отмены. В отличие от BGProcessingTask, стартует сразу, с переднего плана.
Как это работает
Отправьте BGContinuedProcessingTaskRequest(identifier:title:subtitle:) с переднего плана, в прямой ответ на действие пользователя. Разрешённый идентификатор может заканчиваться wildcard (com.example.app.export.*), чтобы у каждой работы был свой id, а обработчик можно зарегистрировать прямо перед отправкой, а не только при запуске.
Обработчик получает BGContinuedProcessingTask. Обновляйте task.progress по ходу работы, а текст обновляйте через updateTitle(_:subtitle:); задачу, которая перестала сообщать прогресс, система может завершить.
request.strategy = .fail делает так, что отправка падает, если система не может начать задачу сразу; .queue по умолчанию позволяет ей подождать.
Пользователь может отменить задачу из системного UI, при этом вызывается expirationHandler. Каждый путь заканчивается вызовом setTaskCompleted(success:).
Когда не стоит
Обслуживанию, синхронизации и предзагрузке, о которых пользователь не просил, место в BGProcessingTask или BGAppRefreshTask.
Одну сетевую передачу лучше делать через фоновую URLSession, которая и так переживает заморозку.
Работа на GPU в фоне требует capability Background GPU и requiredResources = .gpu; сначала проверьте BGTaskScheduler.shared.supportedResources, потому что запрос недоступного ресурса отклоняется при submit.