Rich push в iOS: у тестера картинка есть, у клиента нет. Где ломается
Rich push в iOS собирается из двух отдельных расширений. Notification Service Extension перехватывает пуш с флагом mutable-content, за 30 секунд скачивает картинку и подменяет текст. Notification Content Extension рисует собственный экран, но только когда пользователь раскрывает уведомление. Если картинки нет, причина почти всегда в payload, адресе файла или настройках target.
🧩 два расширения, а заказчик ждёт одно
Заказчик присылает макет: в баннере крупная фотография товара, под ней три кнопки и полоска прогресса доставки. Разработчик кивает. Через неделю выясняется, что макет собран из двух механизмов, и часть задуманного в баннер попасть не может.
Баннер на заблокированном экране рисует система. Service Extension лишь правит его содержимое: заголовок, текст, звук, вложение. Картинка появится маленькой миниатюрой справа и вырастет, когда пользователь раскроет уведомление. Content Extension — это уже ваш UIViewController со своей вёрсткой, но показывается он только после долгого нажатия или свайпа вниз по уведомлению. В самом баннере его нет.
| Что сравниваем | Service Extension | Content Extension |
|---|---|---|
| Когда запускается | Сразу при получении пуша, до показа на экране | После того как пользователь раскрыл уведомление |
| Что делает | Скачивает файл, меняет текст, расшифровывает содержимое | Рисует свой экран, обрабатывает кнопки |
| Что нужно в payload | mutable-content: 1 и видимый alert | category, совпадающая с ключом в Info.plist |
| Ограничения | Около 30 секунд и ограниченная память | Нажатия доходят до экрана только при включённом UNNotificationExtensionUserInteractionEnabled |
Для статусов заказов и напоминаний обычно хватает одного Service Extension. Content Extension нужен, когда в уведомлении надо показать то, чего нет в системном шаблоне: список позиций заказа или поле для быстрого ответа. Кнопки действий работают и без него: их задаёт категория, которую приложение регистрирует само.
📦 что должно лежать в payload
APNs принимает до 4 КБ на одно уведомление. Картинку в пуш не вкладывают, вкладывают ссылку на неё. Минимальный набор такой: в блоке aps лежат alert с заголовком и текстом и ключ mutable-content со значением 1. Если нужен свой экран, добавляется category. Ссылку на файл кладут в собственный ключ рядом с aps, а не внутрь него, например image_url.
Здесь же прячутся ловушки. Тихий пуш с content-available и без alert расширение не запустит: оно работает только с видимыми уведомлениями. Если mutable-content оказался не внутри aps, а рядом, система его проигнорирует, а APNs при этом вернёт сервер нормальный ответ. Заголовок apns-push-type со значением alert Apple рекомендует передавать для iOS 13 и новее всегда, проще добавить его на сервере сразу.
🛠 Service Extension по шагам
- ✓Добавьте в проект target Notification Service Extension. У него свой bundle id, например com.company.app.NotificationService, и свой App ID в аккаунте разработчика. Без профиля для расширения сборка под устройство не подпишется.
- ✓Выставьте Deployment Target не выше минимальной iOS среди ваших клиентов. Xcode подставляет версию последнего SDK. Если клиент сидит на iOS 16, а расширение собрано под 17, система его не запустит, и ошибки вы не увидите.
- ✓В методе didReceive(_:withContentHandler:) сохраните копию контента в bestAttemptContent и достаньте ссылку из userInfo.
- ✓Скачайте файл по HTTPS через URLSession: App Transport Security заблокирует HTTP. Сохраните его во временную папку с расширением .jpg или .png. Система определяет тип по расширению, и ссылка вида /download?id=15 без суффикса даёт пустое вложение. Выход один из двух: добавлять расширение на сервере или передать подсказку типа через UNNotificationAttachmentOptionsTypeHintKey.
- ✓Создайте UNNotificationAttachment и добавьте его в attachments. Лимиты Apple: изображение до 10 МБ, аудио до 5 МБ, видео до 50 МБ. Для миниатюры отдавайте сжатый JPEG, а не оригинал с камеры.
- ✓Вызовите contentHandler с обновлённым контентом. Реализуйте и serviceExtensionTimeWillExpire(): когда 30 секунд на исходе, система вызывает его, и вы успеваете отдать текст без картинки. Если не вызвать contentHandler до истечения времени, пользователь получит исходный пуш без правок.
Расширение живёт в отдельном процессе и не видит ни код приложения, ни его хранилище. Общие модели и сетевой слой выносят во фреймворк, а токен авторизации передают через App Group. Без неё запрос к закрытому API из расширения вернёт 401, а картинка так и не прикрепится. Зато есть бонус: Service Extension срабатывает, даже если клиент смахнул приложение из переключателя. Тихие пуши в такой ситуации до приложения не доходят.
🎨 Content Extension: Info.plist и категории
Расширение настраивается в Info.plist, в ключе NSExtensionAttributes. Три ключа нужны почти всегда: UNNotificationExtensionCategory (строка или массив строк), UNNotificationExtensionInitialContentSizeRatio (отношение высоты к ширине, скажем 0.6) и UNNotificationExtensionDefaultContentHidden (скрывает стандартный текст под вашим экраном). Значение category в plist и в payload сверяется буквально: «OrderShipped» и «ordershipped» для системы разные категории.
Начальную высоту лучше задать через ratio сразу. Иначе уведомление при раскрытии дёргается: высота меняется уже после показа.
Есть две неочевидные детали. Кнопки действий регистрирует основное приложение через setNotificationCategories, поэтому у пользователя, который установил приложение и ни разу его не открыл, кнопок не будет. Регистрируйте категории при первом же запуске. А вложение, скачанное Service Extension, Content Extension берёт из notification.request.content.attachments: повторно ходить в сеть не нужно, но перед чтением файла надо вызвать startAccessingSecurityScopedResource() у его url, иначе доступ откажет.
🔍 у тестера картинка есть, у клиента нет
Типичная история из проектов по отгрузкам. Менеджер дистрибьютора нажимает «Отгружено» в 1С, и клиенту уходит пуш с фотографией накладной. На телефоне разработчика всё выглядит идеально, но у части клиентов приходит голый текст. Поэтому клиенты звонят менеджеру спросить, выехал ли груз, и очередь на линии растёт ровно там, где приложение должно было её убрать. Пуш с подписью «см. вложение» и без вложения выглядит сломанным и подрывает доверие к приложению целиком.
Причины, по которым картинка есть у разработчика и пропадает у клиента, повторяются из проекта в проект:
- ✓iOS у клиента старше, чем Deployment Target расширения, и расширение молча не запускается;
- ✓mutable-content лежит рядом с aps, а не внутри него;
- ✓ссылка на файл ведёт на HTTP или не заканчивается расширением;
- ✓сервер с файлами отвечает медленно, у клиента на складе плохой сигнал, и 30 секунд кончаются раньше, чем картинка скачана;
- ✓файл больше лимита Apple;
- ✓для расширения не создан профиль распространения, и релизная сборка ведёт себя не так, как отладочная.
Искать причину удобнее так: в Xcode выберите Debug → Attach to Process by PID or Name, впишите имя таргета расширения и отправьте тестовый пуш. Логи видны в Console.app по имени процесса. Проверяйте на реальных телефонах с минимальной поддерживаемой версией iOS и на последней, а не только на своём. Симулятор годится для первого прогона, но сеть и подпись в нём ведут себя иначе.
🔐 чего не должно быть в payload
Всё, что лежит в payload, проходит через серверы Apple. Если там ФИО или телефон клиента, это персональные данные, ушедшие за пределы России, и у такой передачи есть свои требования в 152-ФЗ. Рабочая схема проще: в пуше только идентификатор события и нейтральный текст вроде «Статус заказа обновился», а Service Extension идёт за подробностями на ваш сервер по токену из App Group и подставляет их уже на устройстве. Есть и побочный плюс: если расширение не отработало, клиент видит аккуратный запасной текст, а не обрубок.
💼 как это собирается в приложении с 1С и сколько стоит
В B2B-приложении события рождаются в 1С: отгрузка, поступление оплаты, смена статуса заявки, согласование договора. Цепочка получается такая: событие в 1С, сервер уведомлений, APNs, Service Extension, который подтягивает детали по идентификатору. Звеньев много, и каждое ломается по-своему, поэтому пуши лучше проектировать вместе с обменом данными, а не добавлять в конце. Подробнее о связке на страницах про интеграцию приложения с 1С и B2B-приложение с 1С.
Мы закладываем расширения в архитектуру с первого релиза: отдельные App ID и профили, общий фреймворк, App Group для токена, запасной текст на случай таймаута. Перед выпуском прогоняем список причин из предыдущего раздела на устройствах с минимальной и последней iOS. Так клиент получает пуш с фото накладной, а менеджер не получает звонок «а где мой груз».
Стоимость зависит от объёма: разработка мобильных приложений у нас стартует от 500 000 ₽. Точную смету с учётом пушей и интеграции с 1С считаем после разбора ваших сценариев: какие события нужны, какие данные можно показывать на заблокированном экране, под какие версии iOS собираем.
❓ Частые вопросы
Почему в пуше на iPhone нет картинки?
Чаще всего расширение просто не запускается. Проверьте четыре вещи: лежит ли mutable-content внутри блока aps, не выше ли Deployment Target расширения, чем iOS у клиента, идёт ли ссылка по HTTPS и заканчивается ли она расширением файла. Затем подключите отладчик к процессу расширения и посмотрите логи.
Сколько времени есть у Notification Service Extension?
Около 30 секунд на всё: скачать файл, обработать и вызвать contentHandler. Когда время на исходе, система вызывает serviceExtensionTimeWillExpire(), и там нужно отдать лучший доступный вариант, например текст без картинки. Память тоже ограничена, поэтому крупные оригиналы фото лучше не тянуть, а отдавать сжатые копии.
Работает ли Service Extension, если приложение закрыто?
Да. Расширение запускается отдельным процессом при получении видимого пуша с mutable-content, даже если пользователь смахнул приложение из переключателя. Тихие пуши в такой ситуации приложению не доставляются, а расширение срабатывает. Условие: в пуше должен быть alert, иначе расширение не вызывается. Токен для запросов к вашему серверу расширение берёт из App Group.
Нужен ли Content Extension, чтобы в пуше были кнопки?
Нет. Кнопки действий задаются категорией, которую приложение регистрирует через setNotificationCategories, а в payload указывается её идентификатор. Content Extension нужен только для собственного экрана в раскрытом уведомлении. Учтите, что категории регистрируются при запуске приложения, поэтому после установки нужен хотя бы один запуск.
Можно ли передавать персональные данные клиента в тексте пуша?
Лучше не передавать. Payload проходит через серверы Apple за пределами России, а у трансграничной передачи персональных данных есть требования в 152-ФЗ. Надёжнее отправлять идентификатор события и нейтральный текст, а подробности подтягивать Service Extension с вашего сервера. Так на заблокированном экране не окажется ни ФИО, ни телефона клиента.
Или позвоните: +7 906 045-28-27 — в рабочее время с 9:00 до 19:00
Остались вопросы? Нужна помощь?
Менеджеры компании с радостью ответят на ваши вопросы, произведут расчет стоимости услуг и подготовят индивидуальное коммерческое предложение.
Бесплатная консультация

