The Daily Newsstand · Free, Always
Monday, October 5, 2026

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

Translate

Статья рассчитана на 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 и Notification Content Extension

Области действия Notification Service Extension и Notification Content Extension

Есть и ещё одно важное различие: Notification Service Extension предназначен для обработки только remote notifications, тогда как Notification Content Extension может отображать кастомный интерфейс как для remote, так и для local notifications.

1. Основное приложение: регистрируем категории

Начнём не с расширений, а с основного приложения. В демо‑проекте используются две категории:

  • pushWithImageCategory

  • eventCategory

Первая нужна для пуш‑уведомления с изображением. Вторая — для уведомления о событии с отдельным кастомным лейаутом.

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.

Info.plist в Notification Content Extension в демо-проекте

Info.plist в Notification Content Extension в демо‑проекте

В коде это выглядит так:

<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):

Minimum Deployment target в Xcode

Minimum Deployment target в Xcode

Важная ремарка: Apple не требует, чтобы значения всегда были одинаковыми. Но если расширение будет иметь более высокую версию minimum OS, чем основное приложение, на более старой поддерживаемой приложением версии iOS это расширение будет недоступно, из‑за чего может возникнуть иллюзия, что само расширение не работает. Если расширения должны работать на всём диапазоне версий, поддерживаемых приложением, удобнее и безопаснее держать версии таргетов согласованными. В демо‑проекте все три таргета используют iOS 16.0.

12. Как это всё работает

  1. Приложение регистрирует категории:

    pushWithImageCategory

    eventCategory

  2. Сервер отправляет payload:

    aps.mutable-content = 1

    aps.category = pushWithImageCategory

    smallImageURL = ...

    bigImageURL = ...

  3. iOS запускает Notification Service Extension

  4. Service Extension:

    • скачивает small image;

    • скачивает big image;

    • создаёт UNNotificationAttachment;

    • возвращает modified content.

  5. iOS сопоставляет category с UNNotificationExtensionCategory в Info.plist

  6. При раскрытии шторки уведомления лонгтапом запускается Notification Content Extension

  7. 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.apns

Bundle 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

Дополнительные материалы

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.