React Native iOS Push Notifications: почему push не сохраняются в background/killed state
- вторник, 2 июня 2026 г. в 00:00:20
Материал написан на основе реального кейса интеграции Mindbox SDK в React Native приложение, но все описанные проблемы и решения универсальны - они касаются любого push-провайдера и любого проекта, где уведомления должны работать в killed state на iOS.
Полагаю, вы законно спросите: А какая задача вообще стояла? Отвечаю, необходимо было не просто показать пользователю push-сообщение, а нужно было эти пуши сохранить в памяти телефона, да ещё и в состоянии, когда приложение закрыто.
Я не буду писать о том, как это сделать, так как это выходит за рамки статьи, а расскажу о проблемах, когда казалось бы, что все обработчики написаны, React-компоненты созданы, push-сообщения отправлены и даже отображаются на экране мобильного телефона, но вот незадача - они никак не обрабатываются и где искать проблему совершенно непонятно, так как логи пусты. Итак, приступим.
Сценарий, через который рано или поздно проходит почти каждый React Native разработчик, выглядит примерно так: push-уведомления отлично работают на Android; на iOS всё нормально, пока приложение открыто.
А теперь сценарий, когда приложение работает в background: push приходит, но данные не сохраняются. Аналитика не отправляется. React Native не получает событие. Кастомный payload теряется. Local storage пустой. Бизнес-логика не выполняется.
Первая реакция обычно такая: «Наверное, опять React Native что-то сломал».
Но проблема почти никогда не находится на уровне JavaScript. Настоящая причина лежит гораздо глубже: iOS process lifecycle, Notification Service Extensions, App Groups, shared storage, sandbox architecture, Xcode targets, APNS delivery pipeline. Именно в этот момент многие React Native разработчики впервые сталкиваются с тем, что mobile engineering — это не только JS.
В этой статье разберём: почему Android и iOS ведут себя по-разному; как на самом деле работает push pipeline на iOS; почему AppDelegate не всегда вызывается; зачем нужен Notification Service Extension; почему App Groups критически важны; как правильно сохранять push в killed state; какие ошибки ломают всё чаще всего.
Скажу сразу, что я немного лукавлю - в Android всё не работает само, но я осознанно пошёл на это допущение, так как сегодня мы говорим про iOS. Возможно, я напишу ещё одну статью о проблемах интеграции push-сервисов в Android-приложение, ну а пока так.
Android гораздо более либерален в background execution. Даже если приложение убито, Android всё ещё может запускать background services, будить process, выполнять Headless JS, обрабатывать FCM payload, писать данные в storage. React Native разработчик привыкает к тому, что push пришёл - JS получил данные — всё сохранилось. На iOS архитектура совершенно другая.
На iOS способ обработки push зависит от состояния приложения. И это фундаментально важно.
Когда приложение открыто, цепочка выглядит предсказуемо: APNS → AppDelegate → React Native bridge → JavaScript. В этом режиме всё просто. Ваш AppDelegate получает push:
func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { let userInfo = notification.request.content.userInfo savePush(userInfo) notifyReactNative() }
Приложение живо. React Native bridge жив. JS runtime жив. Можно спокойно сохранять данные, обновлять state, отправлять analytics, вызывать NativeModules, работать с local storage.
Когда приложение свёрнуто, ситуация уже сложнее. Иногда iOS всё ещё позволяет AppDelegate получить push. Иногда нет. Это зависит от типа push, system state, memory pressure, background execution policy, payload. Именно поэтому background-поведение на iOS часто кажется нестабильным - оно действительно нестабильно по дизайну. Система сама решает, дать приложению процессорное время или нет, и её решение непредсказуемо для разработчика.
А вот здесь начинается самое интересное. Когда пользователь свайпает приложение из списка recent apps - app process уничтожается. AppDelegate больше не существует. React Native bridge не существует. Hermes/JSC runtime не существует. JavaScript не выполняется. И в этот момент многие разработчики продолжают ожидать: «push придёт — JS обработает данные». Но обрабатывать данные уже некому.
Потому что push показывает сама iOS. Операционная система читает:
{ "aps": { "alert": { "title": "Новое сообщение", "body": "Привет" } } }
И отображает notification UI. Но ваш код при этом может вообще не запускаться. Это ключевой момент, который трудно принять после Android-опыта: уведомление видно, значит кто-то же его показал, значит приложение работало? Нет. Показала система. Ваш процесс в этом не участвовал.
Apple предусмотрела специальный механизм: Notification Service Extension. Это отдельный mini-process. Не часть приложения. Не часть React Native. Не часть AppDelegate. Это отдельный process внутри iOS, который система запускает по своим правилам.
Extension умеет модифицировать push, подгружать изображения, изменять текст notification, расшифровывать payload, сохранять данные, подготавливать rich notifications. И самое главное: extension может запускаться даже когда основное приложение убито. Это именно то, что нам нужно.
Очень многие думают: extension - это часть app. Но это совсем не так. У extension свой lifecycle, свой process, свой sandbox, свой bundle id, свои entitlements. По сути это отдельное mini-приложение со своим жизненным циклом. Система запускает его независимо, даёт около 30 секунд на работу и так же независимо убивает. Основное приложение может никогда не узнать, что extension вообще запускался.
Вот здесь находится главный баг, который ломает голову новичкам. Допустим, extension получил push и сохранил данные:
UserDefaults.standard.set(...)
А потом основное приложение пытается прочитать через UserDefaults.standard - и ничего не находит. Причина проста и от этого ещё более обидная: UserDefaults.standard у extension и UserDefaults.standard у app - это разные sandbox containers. Физически разные файлы на диске, в разных директориях, с разными правами доступа. Extension честно записал данные, приложение честно пошло их читать, но каждый смотрит в свой собственный изолированный контейнер.
Для решения этой проблемы Apple придумала App Groups — shared container между приложением, extensions, widgets и другими targets. Это общая область файловой системы, доступная всем участникам группы. Например: group.cloud.Mindbox.com.example.exampleapp. Тогда UserDefaults(suiteName: "group.cloud.Mindbox.com.example.exampleapp") будет общим storage и для main app, и для Notification Service Extension.
В нашем случае с Mindbox это особенно важно: SDK должен иметь возможность записать данные о полученном пуше в момент, когда основное приложение ещё не запущено, а затем передать их приложению, когда пользователь откроет уведомление.
Разные App Groups. App использует UserDefaults(suiteName: "group.cloud.Mindbox.com.example.exampleapp"), а extension использует UserDefaults(suiteName: "group.cloud.Mindbox.com.mindbox.exampleRN"). В результате extension пишет в один container, app читает из другого. Визуально это выглядит как «push не сохраняется в background». Хотя push сохраняется. Просто не туда.
Эта ошибка особенно коварна тем, что на первый взгляд всё настроено правильно. Идентификаторы групп похожи, оба зарегистрированы в developer portal, entitlements на месте. Но одна опечатка в строке - и данные уходят в никуда. А дебажить это сложно, потому что напрямую заглянуть в shared container extension во время его работы почти невозможно.
Потому что React Native скрывает native layer. Пока всё работает - это удобно. Но как только начинается background execution, extensions, APNS lifecycle, shared storage, native debugging - JS abstraction перестаёт помогать. Приходится понимать, как реально устроена mobile OS. И это момент, когда многие RN-разработчики впервые открывают Xcode не чтобы поменять иконку, а чтобы пройтись дебаггером по Swift-коду extension в режиме реального времени.
Notification Service Extension НЕ запускается автоматически. Для этого push должен содержать:
{ "aps": { "mutable-content": 1 } }
Без этого ключа extension никогда не будет вызван. Система просто не знает, что вы хотите модифицировать уведомление перед показом. Это одна из самых частых причин, по которой разработчик создал extension, написал код, настроил App Groups, а ничего не работает. Проверьте payload. Скорее всего, mutable-content отсутствует или равен нулю. При интеграции с Mindbox убедитесь, что кампании на стороне провайдера отправляют этот флаг — без него extension не запустится ни при каких обстоятельствах.
Очень распространённая ошибка. Разработчик создаёт ios/MindboxNotificationServiceExtension, кладёт туда Swift-файлы - и думает что extension готов. Но iOS использует не папки. iOS использует Xcode targets. Это совершенно другой уровень абстракции.
В Xcode должен существовать полноценный NotificationServiceExtension target с bundle id, entitlements, signing, build phases, embed configuration, pods. Без этого ваши Swift-файлы - просто текст на диске. Они не компилируются, не линкуются, не попадают в финальную сборку. Система просто не знает об их существовании.
Extension - это отдельный target. И ему нужны собственные pod dependencies. Например:
target 'MindboxNotificationServiceExtension' do pod 'MindboxNotifications' end
Без этого extension может не собраться, не линковаться, silently fail. Самое неприятное - silently fail. Ошибки линковки extension не всегда видны при сборке основного таргета. Вы запускаете приложение, оно работает, а extension молча падает при первом же пуше, и вы об этом узнаете только от пользователей.
Так уж получилось, что работая с пушами, я параллельно производил миграцию на RN с версии 0.74 на 0.78 и столкнулся с интересной проблемой, которая оказалась, что встречается довольно часто.
Например, у вас есть ios/NotificationModule.swift и одновременно ios/ExampleApp/NotificationModule.swift. Если внутри обоих файлов объявлен класс с одинаковой Objective-C аннотацией @objc(NotificationModule), то iOS может компилировать обе версии, использовать случайную, получать duplicate symbols, работать нестабильно.
Эта нестабильность особенно коварна. Локально на дебаг-сборке может подхватываться один файл и всё работать. На CI собирается release-сборка с другими флагами оптимизации — и линковщик выбирает другой файл. Поведение меняется от сборки к сборке. Clean build, pod install, release archive - каждый раз лотерея. Единственный способ избежать этого - аудит исходников после каждой миграции и безжалостное удаление дубликатов.
Я долго думал, включать ли этот раздел в статью - лично я с таким на практике не сталкивался, а узнал об этой проблеме из доклада от Mad Brains Техно «Как кастомизировать пуши в iOS». Вопрос показался мне интересным, и я решил в нём разобраться.
Кстати, рекомендую данный доклад для общего развития, ссылку оставлять не буду, но вы его легко можете найти сами.
На первый взгляд кажется, что Automatic Signing решает все проблемы. Xcode сам управляет сертификатами, provisioning profiles, entitlements. Но как только появляются extensions, автоматика может давать сбой.
Первый сценарий: Xcode «забывает» entitlements. Вы создали extension, добавили App Groups capability, проверили - всё на месте. Но в project.pbxproj для extension target может отсутствовать параметр CODE_SIGN_ENTITLEMENTS. Файл .entitlements физически существует на диске, но не используется при подписи. Результат: aps-environment не попадает в финальную сборку, и push не приходит, даже когда всё остальное настроено правильно.
Второй сценарий: сложная структура сертификатов. В некоторых проектах проще один раз зайти в Apple Developer Portal, вручную создать Provisioning Profile для extension, явно указав в нём App Groups и Push Notifications capability, скачать его и прописать в Xcode вручную на вкладке Signing (Manual). Это даёт полный контроль и устраняет элемент неожиданности.
Правило: если настроили всё по инструкции, а extension молчит или сыплются ошибки подписи - первым делом отключите Automatic Signing для extension target. Проверьте CODE_SIGN_ENTITLEMENTS в project.pbxproj. Убедитесь, что provisioning profile содержит нужные App Groups. Иногда пять минут в developer.apple.com экономят часы дебага в Xcode.
Ещё одна ошибка, которую невозможно заметить визуально. Недостаточно, чтобы extension target существовал в проекте и успешно компилировался. Расширение должно быть внедрено в основное приложение.
Технически это означает, что бинарный файл расширения (.appex) должен быть скопирован внутрь .app бандла основного приложения. Без этого iOS просто не найдёт расширение при получении пуша. Push с mutable-content: 1 придёт, но запускать будет нечего.
В Xcode это настраивается через Build Phases основного таргета. Нужно открыть таргет приложения, перейти на вкладку Build Phases, найти секцию «Embed Foundation Extensions» и убедиться, что ваш MindboxNotificationServiceExtension присутствует в списке. Если его там нет - добавить через плюсик.
Это особенно коварно тем, что никаких ошибок сборки не возникает. Расширение компилируется, подписывается, всё зелёное. Но оно лежит отдельно от приложения, и система о нём не знает. Вы отправляете тестовый push, ждёте срабатывания, а в консоли - тишина. Потому что запускать нечего.
Проверить, что расширение попало в бандл, можно простым способом: собрать .ipa или .app, открыть его содержимое и найти папку PlugIns. Внутри должен лежать MindboxNotificationServiceExtension.appex. Если папка пустая или отсутствует - вы не заembedили расширение.
Правильный pipeline выглядит так: APNS → Notification Service Extension → Shared App Group UserDefaults → Main App → React Native bridge → JavaScript. Каждый этап должен быть настроен явно, никакой магии не происходит. Если хотя бы одно звено выпадает - данные теряются.
Чтобы push-уведомления корректно сохранялись на iOS в background/killed state, нужно последовательно пройти десять шагов.
Шаг первый: добавить Notification Service Extension target в Xcode. Не папку, не Swift-файлы рядом с проектом — именно target.
Шаг второй: добавить "mutable-content": 1 в payload каждого пуша, который должен обрабатываться extension.
Шаг третий: создать одну общую App Group, например group.cloud.Mindbox.com.example.exampleapp, и зарегистрировать её в developer portal.
Шаг четвёртый: использовать её везде — в AppDelegate, в NotificationModule, в Extension, в entitlements обоих таргетов. Никаких вариаций, никаких похожих строк. Ровно один и тот же идентификатор.
Шаг пятый: использовать shared UserDefaults исключительно через suiteName:
UserDefaults(suiteName: "group.cloud.Mindbox.com.example.exampleapp")
Никаких UserDefaults.standard в контексте передачи данных между процессами.
Шаг шестой: правильно подключить extension target в настройках основного таргета - Embed App Extensions должен содержать ваш extension.
Шаг седьмой: добавить extension target в Podfile с корректными зависимостями и директивой inherit! :search_paths.
Шаг восьмой: проверить, что extension присутствует в списке Embedded App Extensions после сборки. Это можно посмотреть в составе .app-бандла.
Шаг девятый: удалить duplicate native files. После каждой миграции проходиться по дереву исходников и безжалостно вычищать дубликаты модулей.
Шаг десятый: проверить, что extension реально запускается. Добавьте print("🔥 EXTENSION STARTED") в метод didReceive и подключитесь через Console.app к процессу extension при отправке тестового пуша. Пока не увидели лог - не считайте, что extension работает.
И последний совет: иногда лучше тестировать на живом устройстве. Если вы уверены, что все сделали правильно, не поленитесь - соберите проект и протестируйте приложение через TestFlight.
Главная мысль, которую важно понять: проблема background push на iOS - это не React Native проблема. Это iOS architecture, process lifecycle, sandbox isolation, native mobile engineering. И именно в этот момент React Native developer начинает превращаться из JS-разработчика в mobile engineer. Потому что настоящие production-проблемы почти всегда живут глубже JavaScript слоя.
По традиции, я бы попросил вас подписаться на мой канал, чтобы поддержать автора, но теперь даже и не знаю. В общем, как говорит один из моих любимый ютуб-блогеров:
stay in touch!