Rich Push Notifications в iOS: Notification Service Extension, Notification Content Extension и неочевидные детали

Статья рассчитана на iOS‑разработчиков, которые уже знакомы с базовой настройкой пуш‑уведомлений и хотят разобраться с Notification Service Extension и Notification Content Extension. Здесь не рассматривается подключение пушей с нуля — основной фокус сделан на работе расширений, структуре payload, категориях уведомлений, кастомном UI и типичных проблемах при настройке. Все примеры приведены на отдельном демо‑проекте и основаны на публичной документации и открытых источниках. Цель статьи — собрать в одном месте детали, на которых легко потерять время при настройке rich notifications.
Практический разбор на демо‑проекте
Стандартного пуш‑уведомления хватает не всегда. Иногда нужно скачать изображение до показа, использовать отдельную большую картинку для раскрытого уведомления или полностью заменить стандартный интерфейс собственным UIViewController. Такие уведомления ещё могут называть Rich Push Notifications. В статье разберём, как для этого совместно использовать Notification Service Extension и Notification Content Extension, как связать их через категории пуш‑уведомлений и Info.plist, и как одним Notification Content Extension обрабатывать несколько категорий уведомлений.
Демо‑проект
В демо‑проекте есть основной таргет приложения и два таргета расширений:
NotificationsServiceExtension — перехватывает подходящее пуш‑уведомление перед показом, скачивает изображения и превращает их в
UNNotificationAttachment.NotificationsContentExtension — отображает собственный UI для двух разных категорий уведомлений.
Основное приложение — запрашивает разрешение на уведомления, регистрируется для пуш‑уведомлений и регистрирует
UNNotificationCategory.
Чтобы пример не зависел от конкретного бэкенда или пуш‑провайдера, payload и все идентификаторы в проекте демонстрационные.
Service Extension и Content Extension — не одно и то же
Для начала немного вводной теории о расширениях. Их названия похожи, но сами расширения находятся на разных этапах обработки уведомления. Notification Service Extension позволяет изменить содержимое уведомления перед его показом пользователю: например, скачать изображения и добавить их в виде attachments. Notification Content Extension отвечает за представление раскрытого уведомления и позволяет заменить стандартный интерфейс собственным.
Расширения запускаются системой при разных условиях: Service Extension — при обработке подходящего уведомления с mutable-content, а Content Extension — для зарегистрированной в Info.plist категории уведомления, указанной через category. Выполняются они в отдельных процессах, поэтому и отлаживать их нужно отдельно.

Есть и ещё одно важное различие: Notification Service Extension предназначен для обработки только remote notifications, тогда как Notification Content Extension может отображать кастомный интерфейс как для remote, так и для local notifications.
1. Основное приложение: регистрируем категории
Начнём не с расширений, а с основного приложения. В демо‑проекте используются две категории:
pushWithImageCategoryeventCategory
Первая нужна для пуш‑уведомления с изображением. Вторая — для уведомления о событии с отдельным кастомным лейаутом.
private enum Category {
static let image = "pushWithImageCategory"
static let event = "eventCategory"
}
private func registerNotificationCategories() {
let imageCategory = UNNotificationCategory(
identifier: Category.image,
actions: [],
intentIdentifiers: [],
options: []
)
let eventCategory = UNNotificationCategory(
identifier: Category.event,
actions: [],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([
imageCategory,
eventCategory
])
}Эти идентификаторы — часть контракта между приложением и payload. Для пуш‑уведомления значение ключа category внутри aps должно совпасть с identifier зарегистрированной категории.
2. Payload: где именно должна лежать категория
Одна из самых неприятных ошибок — положить category «почти туда». Для APNs положение системного ключа принципиально: параметр category должен находиться внутри словаря aps.
Правильно:
{
"aps": {
"alert": {
"title": "New photo",
"body": "Expand the notification to see the image"
},
"sound": "default",
"mutable-content": 1,
"category": "pushWithImageCategory"
},
"smallImageURL": "https://example.com/preview.jpg",
"bigImageURL": "https://example.com/content.jpg"
}Неправильно:
{
"aps": {
"alert": {
"title": "New photo",
"body": "Expand the notification to see the image"
}
},
"category": "pushWithImageCategory"
}Во втором варианте category уже не является системным ключом aps. Это просто кастомные данные верхнего уровня. Система не использует его как category для уведомления. Обратите внимание и на URL изображений — smallImageURL и bigImageURL, наоборот, являются данными приложения, поэтому находятся рядом с aps, а не внутри него.
3. Mutable‑content: как запустить Notification Service Extension
Чтобы iOS перед показом уведомления передала его в Notification Service Extension, payload должен содержать alert и флаг mutable-content со значением 1 внутри aps:
"mutable-content": 1После этого система может вызвать didReceive(_:withContentHandler:), где мы получаем копию контента, модифицируем её и обязательно завершаем обработку через contentHandler.
4. Notification Service Extension: скачиваем две картинки
В демо‑проекте сервер передаёт два URL. Из smallImageURL создаётся attachment small-image, а из bigImageURL— big-image. Последний затем явно выбирается в Notification Content Extension для отображения в кастомном интерфейсе.
private enum PayloadKey {
static let smallImageURL = "smallImageURL"
static let bigImageURL = "bigImageURL"
}
private enum AttachmentIdentifier {
static let smallImage = "small-image"
static let bigImage = "big-image"
}Сначала делаем mutable‑копию content и запускаем загрузку:
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
guard let content = request.content.mutableCopy()
as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
bestAttemptContent = content
downloadImages(for: content) { [weak self] modifiedContent in
self?.finish(with: modifiedContent)
}
}Attachments должны быть локальными файлами. Поэтому URL из payload недостаточно просто передать в UNNotificationAttachment: файл сначала скачивается во временную директорию.
private func downloadAttachment(
from url: URL,
identifier: String,
completion: @escaping (UNNotificationAttachment?) -> Void
) {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 15
let session = URLSession(configuration: configuration)
let task = session.downloadTask(with: url) {
temporaryURL, response, error in
defer { session.finishTasksAndInvalidate() }
guard
error == nil,
let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode),
let temporaryURL
else {
completion(nil)
return
}
do {
let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
let localURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(ext)
try FileManager.default.copyItem(
at: temporaryURL,
to: localURL
)
let attachment = try UNNotificationAttachment(
identifier: identifier,
url: localURL,
options: nil
)
completion(attachment)
} catch {
completion(nil)
}
}
task.resume()
}Две загрузки можно выполнять параллельно. Однако не стоит полагаться на порядок их завершения. В демо‑проекте результаты сначала складываются по identifier, а затем массив attachments формируется в детерминированном порядке: small-image, big-image.
content.attachments = [
downloadedAttachments["small-image"],
downloadedAttachments["big-image"]
].compactMap { $0 }Нужен ли App Group для передачи attachment между extensions?
В описанном сценарии — нет. Notification Service Extension добавляет UNNotificationAttachment в изменённый UNNotificationContent, а Notification Content Extension затем получает эти attachments через notification.request.content.attachments. App Group понадобится, если расширениям или основному приложению нужно независимо обмениваться собственными файлами, UserDefaults или другим состоянием через общий контейнер.
5. У Notification Service Extension есть дедлайн
Notification Service Extension не может бесконечно ждать завершения сетевых запросов. На обработку уведомления система даёт ограниченное время (не более 30 секунд на изменение content и вызов contentHandler). Если расширение не успевает завершить работу, вызывается метод serviceExtensionTimeWillExpire(). Поэтому желательно вернуть системе наиболее подготовленный вариант UNMutableNotificationContent перед завершением работы расширения.
override func serviceExtensionTimeWillExpire() {
guard let bestAttemptContent else { return }
finish(with: bestAttemptContent)
}6. Notification Content Extension и несколько категорий
Теперь переходим к отображению. Один Notification Content Extension может обслуживать несколько категорий. Это настраивается не в Swift‑коде, а в Info.plist самого расширения. В демо‑проекте Info.plist содержит две категории: pushWithImageCategory и eventCategory. Ключ UNNotificationExtensionCategory изначально может быть строкой. Если расширение должно поддерживать несколько категорий, значение нужно сделать Array и добавить каждый identifier отдельным Item.

В коде это выглядит так:
<key>UNNotificationExtensionCategory</key>
<array>
<string>pushWithImageCategory</string>
<string>eventCategory</string>
</array>Идентификаторы чувствительны к регистру, поэтому значения в Info.plist, UNNotificationCategory и payload должны совпадать буквально.
В демо‑проекте UNNotificationExtensionDefaultContentHidden установлен в NO, поэтому системная часть уведомления остаётся видимой вместе с кастомным интерфейсом. Если установить значение YES, система скроет стандартный контент уведомления и оставит интерфейс Content Extension.
7. UI кодом: NSExtensionPrincipalClass вместо storyboard
При создании Notification Content Extension сам Xcode обычно создаёт стандартный storyboard, что может быть неудобно. В демо‑проекте интерфейс собирается кодом, поэтому в качестве входной точки задаётся NSExtensionPrincipalClass:
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>Если переходите со сториборда на программный UIViewController, не оставляйте одновременно старый NSExtensionMainStoryboard. У расширения должна быть однозначная точка входа.
final class NotificationViewController:
UIViewController,
UNNotificationContentExtension {
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
return imageView
}()
private let calendarImageView =
makeSymbolImageView(systemName: "calendar")
private let locationImageView =
makeSymbolImageView(systemName: "mappin.and.ellipse")
private lazy var dateRowStackView =
makeEventRow(icon: calendarImageView, label: eventDateLabel)
private lazy var locationRowStackView =
makeEventRow(icon: locationImageView, label: eventLocationLabel)
private lazy var eventStackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [
dateRowStackView,
locationRowStackView
])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 12
stackView.isHidden = true
return stackView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(imageView)
view.addSubview(eventStackView)
setupConstraints()
}
}8. Не разбираем aps.category вручную
После доставки пуш‑уведомления нет необходимости снова лезть в userInfo, искать aps и вручную доставать category. UserNotifications уже сделал это за нас: значение доступно через content.categoryIdentifier.
Вместо:
let aps = content.userInfo["aps"] as? [AnyHashable: Any]
let category = aps?["category"] as? Stringлучше:
let content = notification.request.content
guard content.categoryIdentifier == "pushWithImageCategory" else {
return
}9. Большая картинка в кастомном интерфейсе
Notification Content Extension получает уже подготовленные attachments. Для кастомного UI мы не берём «первую картинку», а явно ищем attachment с идентификатором big-image:
guard let imageAttachment = content.attachments.first(where: {
$0.identifier == "big-image"
}) else {
return
}Такой подход не связывает UI с порядком массива и хорошо масштабируется, если позже появятся другие медиа.
private func loadImage(from attachment: UNNotificationAttachment) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let didStartAccessing =
attachment.url.startAccessingSecurityScopedResource()
defer {
if didStartAccessing {
attachment.url.stopAccessingSecurityScopedResource()
}
}
guard
let data = try? Data(contentsOf: attachment.url),
let image = UIImage(data: data)
else {
return
}
DispatchQueue.main.async {
guard let self else { return }
self.preferredContentSize =
self.preferredImageSize(for: image.size)
self.imageView.image = image
}
}
}preferredContentSize позволяет подстроить высоту раскрытого уведомления с изображением под пропорции картинки. В демо‑проекте изображение уменьшается до доступной ширины с сохранением aspect ratio.
10. Вторая категория: eventCategory и другой лейаут
Чтобы показать, что одно расширение может обслуживать разные типы уведомлений, в демо‑проекте есть вторая категория — eventCategory. Для неё тот же NotificationViewController переключается на отдельный лейаут: две горизонтальные строки с SF Symbols calendar и mappin.and.ellipse — датой и местом события.
private enum Category {
static let image = "pushWithImageCategory"
static let event = "eventCategory"
}
func didReceive(_ notification: UNNotification) {
let content = notification.request.content
switch content.categoryIdentifier {
case Category.image:
configureImageNotification(with: content)
case Category.event:
configureEventNotification(with: content)
default:
break
}
}Payload для события может выглядеть так:
{
"aps": {
"alert": {
"title": "Meetup reminder",
"body": "iOS Community Meetup"
},
"sound": "default",
"category": "eventCategory"
},
"eventDate": "October 15, 19:00",
"eventLocation": "Community Hall"
}Здесь есть полезное отличие от уведомлений с изображениями: eventCategory не требует предварительной загрузки медиа, поэтому mutable-content не нужен. Notification Content Extension отрабатывает по aps.category, а eventDate и eventLocation читаются из ключей верхнего уровня. В самом Notification Content Extension второй сценарий переключает видимость views и заполняет отдельный лейаут. Дата и место оформлены как две горизонтальные строки с системными SF Symbols:
private func configureEventNotification(
with content: UNNotificationContent
) {
imageView.isHidden = true
eventStackView.isHidden = false
let eventDate = content.userInfo["eventDate"] as? String
let eventLocation = content.userInfo["eventLocation"] as? String
eventDateLabel.text = eventDate ?? "Date not specified"
eventLocationLabel.text =
eventLocation ?? "Location not specified"
preferredContentSize = CGSize(
width: view.bounds.width,
height: 112
)
}Фактически для Notification Content Extension имеет значение изменение высоты preferredContentSize: ширину интерфейса контролирует система, и переданное значение width игнорируется.
Строки собираются через UIStackView, а иконки берутся из SF Symbols — никаких дополнительных assets для этого примера не требуется:
private func makeEventRow(
icon: UIImageView,
label: UILabel
) -> UIStackView {
let stackView = UIStackView(arrangedSubviews: [icon, label])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 10
NSLayoutConstraint.activate([
icon.widthAnchor.constraint(equalToConstant: 22),
icon.heightAnchor.constraint(equalToConstant: 22)
])
return stackView
}
private static func makeSymbolImageView(
systemName: String
) -> UIImageView {
let imageView = UIImageView(
image: UIImage(systemName: systemName)
)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.tintColor = .label
return imageView
}11. Параметр Minimum Deployment в таргетах с расширениями
Каждое расширение — отдельный таргет со своими параметрами Minimum Deployment. Поэтому после создания расширения стоит проверить это у всех трёх таргетов.
PushNotificationsSample → iOS 16.0
NotificationsServiceExtension → iOS 16.0
NotificationsContentExtension → iOS 16.0
В Xcode это выглядит вот так (может отличаться, в зависимости от версии IDE):

Важная ремарка: Apple не требует, чтобы значения всегда были одинаковыми. Но если расширение будет иметь более высокую версию minimum OS, чем основное приложение, на более старой поддерживаемой приложением версии iOS это расширение будет недоступно, из‑за чего может возникнуть иллюзия, что само расширение не работает. Если расширения должны работать на всём диапазоне версий, поддерживаемых приложением, удобнее и безопаснее держать версии таргетов согласованными. В демо‑проекте все три таргета используют iOS 16.0.
12. Как это всё работает
Приложение регистрирует категории:
pushWithImageCategoryeventCategoryСервер отправляет payload:
aps.mutable-content = 1aps.category = pushWithImageCategorysmallImageURL = ...bigImageURL = ...iOS запускает Notification Service Extension
Service Extension:
• скачивает
small image;• скачивает
big image;• создаёт
UNNotificationAttachment;• возвращает
modified content.iOS сопоставляет
categoryсUNNotificationExtensionCategoryвInfo.plistПри раскрытии шторки уведомления лонгтапом запускается Notification Content Extension
NotificationViewController:• читает
content.categoryIdentifier;• находит в
attachment“big-image”;• отображает его в кастомном UI.
Для eventCategory Notification Service Extension вообще не нужен: после доставки уведомления система сама сопоставляет category со списком категорий в Notification Content Extension, а NotificationViewController показывает лейаут.
13. Чеклист: если пуш приходит, а расширение не работает
Проверить, есть ли
alertв payload, если вы ожидаете запуск Notification Service Extension?Находится ли
mutable-content: 1внутриaps?Находится ли
categoryвнутриaps, а не рядом с ним?Совпадает ли
categoryсidentifierзарегистрированногоUNNotificationCategory?Есть ли эта же категория в
UNNotificationExtensionCategoryвInfo.plistвашего Notification Content Extension?Если категорий несколько, имеет ли
UNNotificationExtensionCategoryвInfo.plistтипArray?Совпадает ли регистр символов в идентификаторах?
Если UI создаётся кодом, корректно ли указан
NSExtensionPrincipalClassи удалён ли старыйNSExtensionMainStoryboardвInfo.plist?Вызывается ли
contentHandlerво всех путях выполнения Notification Service Extension?Есть ли
fallbackвserviceExtensionTimeWillExpire()?Скачан ли
attachmentв локальный файл до созданияUNNotificationAttachment?Не требует ли расширение более новую iOS, чем устройство, на котором тестируется приложение?
14. Тестирование в симуляторе: что действительно можно проверить
Для быстрого тестирования пушей в симуляторе удобно использовать payload‑файлы с расширением.apns. Такой файл можно просто перетащить в запущенный симулятор и получить уведомление. Если приложение находится в состоянии background (в демо‑проекте я не настраивал отображение уведомлений в режиме foreground), на экране симулятора появится шторка уведомления. При наличии в payload нужной категории, если сделать лонгтап на шторке, уведомление раскроется, по нужной category отработает Notification Content Extension и отобразится соответствующий лейаут.
В данном примере используется подготовленный файл Event.apns:
{
"Simulator Target Bundle": "com.PushNotificationsSample",
"aps": {
"alert": {
"title": "iOS Community Meetup",
"body": "Don't forget about today's meetup"
},
"sound": "default",
"category": "eventCategory"
},
"eventDate": "October 15, 19:00",
"eventLocation": "Community Hall"
}Тот же payload можно отправить из терминала:
xcrun simctl push booted com.PushNotificationsSample Event.apnsBundle Identifier в команде и значение Simulator Target Bundle должны совпадать с Bundle Identifier установленного приложения.
15. Дебаг расширений
При отладке Notification Service Extension и Notification Content Extension есть неочевидный момент: если запустить только основную схему приложения, брейкпоинты внутри расширения могут не сработать, поскольку расширения выполняются в отдельных процессах.
Для отладки выберите в Xcode схему нужного расширения — NotificationsServiceExtension или NotificationsContentExtension. После запуска Xcode предложит выбрать приложение, через которое будет активировано расширение. В нашем случае это PushNotificationsSample. После этого можно ставить брейкпоинты непосредственно в коде расширения и дебажить.

16. Ограничения локального тестирования Notification Service Extension
Локальная симуляция через drag‑and‑drop apns‑файла или через xcrun simctl push не эквивалентна настоящей доставке через APNs. Например, вы можете столкнуться с тем, что при перетаскивании на симулятор apns‑файла с ссылками на картинки никаких изображений не появится. Apple прямо отмечает, что при отправке remote notifications поддерживается больше возможностей, и приводит Notification Service Extensions как пример. Поэтому отсутствие вызова NotificationService.didReceive(_:withContentHandler:) при локально симулированном пуш‑уведомлении само по себе не означает ошибку в расширении или проблему с брейкпоинтами.
На практике Event.apns подходит для локальной проверки category и Notification Content Extension, а сценарий с загрузкой smallImageURL / bigImageURL через Notification Service Extension нужно проверять настоящим remote push через APNs — именно тогда загрузится нужное расширение, если payload пуша содержит отображаемый alert, а в словаре aps присутствует mutable-content со значением 1. У extension есть ограниченное время на изменение контента, поэтому serviceExtensionTimeWillExpire() остаётся страховкой на случай тайм‑аута.
Итог
Rich notifications становятся гораздо понятнее, если перестать воспринимать расширения как единый механизм. Notification Service Extension — это этап подготовки данных. Notification Content Extension — этап представления.
NotificationServiceExtension — подготовить content
NotificationContentExtension — отобразить content
mutable‑content — запустить Service Extension
aps.category — определить тип уведомления
UNNotificationExtensionCategory — связать тип уведомления с Content Extension
NSExtensionPrincipalClass — указать программный UIViewController для Content Extension
Большинство трудноуловимых ошибок в этой схеме находится не в UIKit‑коде, а на стыках: в структуре JSON, идентификаторах категорий, Info.plist и настройках таргетов. Поэтому при отладке полезнее идти по пайплайну сверху вниз — от payload до конкретного UIViewController.
Ссылки
Документация Apple
Дополнительные материалы
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.