iOS Нотификации. Подписка и рассылка

43 minute read

Все просто, но не очень. В интернете куча статей про нотификации в иос. И в этом проблема - слишком много статей, часть из них уже не актуальны и большинство очень поверхностны. Поэтому, я решил добавить еще одну статью и хорошенько во всем разобраться.

Нотификации в приложении генерируются из-за событий в самом приложении (например, по таймеру) или по сообщению с сервера. Первые называются локальными, а вторые – пуш-нотификациями.

Пуш-нотификации работают через APNs (Apple Push Notification service). Для отправки сообщения пользователю нужно сформировать запрос к серверу APNs. Это делается разными способами.

Отправка соединений с помощью токена выглядит попроще - ей и займемся.

Локальные нотификации

Вся логика будет реализована в классе Notifications. Перед началом работы с нотификациями импортируем UserNotifications

1import UserNotifications

Запрашиваем разрешение у пользователя на отправку нотификаций. Для этого в классе Notifications добавляем метод

1let center = UNUserNotificationCenter.current()
2
3func requestAuthorisation() {
4    center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
5        print("Permission granted: \(granted)")
6    }
7}

В классе AppDelegate добавим новое свойство notifications и вызовем метод requestAuthorisation при старте приложения

1let notifications = Notifications()
2
3func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
4    notifications.requestAuthorisation()
5
6    return true
7}

Пользователь может поменять настройки уведомлений. Нужно не только запрашивать авторизацию, но и проверять настройки сообщений при старте приложения. Реализуем метод getNotificationSettings() и изменим requestAuthorisation() - и добавим получение настроек нотификаций, если requestAuthorization возвращает granted == true

 1func requestAuthorisation() {
 2    center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
 3        print("Permission granted: \(granted)")
 4
 5        guard granted else {
 6            return
 7        }
 8
 9        self.getNotificationSettings()
10    }
11}
12
13func getNotificationSettings() {
14    center.getNotificationSettings { settings in
15        print("Notification settings : \(settings)")
16    }
17}

Создадим локальное уведомление. Для этого добавим метод scheduleNotification() в классе AppDelegate`. В нем будем задавать нотификации по расписанию.

1func scheduleNotification(type: String) {
2    let content = UNMutableNotificationContent()
3
4    content.title = type
5    content.body = "Example notification " + type
6    content.sound = .default
7    content.badge = 1 // красный бейджик на иконке с кол-вом непрочитанных сообщений
8}

Для создания уведомления используем класс UNMutableNotificationContent. Подробней о возможностях этого класса в документации.

Триггер для показа уведомления может срабатывать по времени, календарю или местоположению. Можно отправлять уведомления каждый день в определенное время или раз в неделю.

Мы будем слать уведомления по времени. Создадим соответствующий триггер.

 1content.sound = .default
 2content.badge = 1 // красный бейджик на иконке с кол-вом непрочитанных сообщений
 3
 4let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
 5let id = "Local Notification #1"
 6
 7let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
 8
 9notifications.add(request) { error in
10    if let error = error {
11        print("Error \(error.localizedDescription)")
12    }
13}

Сначала создаем trigger - триггер, который будет срабатывать через 5 секунд. Задаем идентификатор для нашего уведомления id. Он должен быть уникальным для каждого уведомления.

Теперь у нас есть все, чтобы создать запрос на показ уведомления и добавить его в центр уведомлений UNUserNotificationCenter. Для этого делаем вызов notifications.add(request)

Осталось вызвать метод scheduleNotification(type: String). В любой контроллер добавим делегат:

1let delegate = UIApplication.shared.delegate as? AppDelegate

Добавим кнопку и по нажатию вызовем нужный метод

1delegate?.scheduleNotification(type: "local")

Если нажать кнопку, то через 5 секунд появится уведомление как на картинке. Не забывайте, что нужно свернуть приложение, чтобы увидеть уведомление.

На иконке появился бейджик. Сейчас он остается на всегда и не пропадает. Давайте это поправим - добавим несколько строчек кода в AppDelegate

1func applicationDidBecomeActive(_ application: UIApplication) {
2    UIApplication.shared.applicationIconBadgeNumber = 0
3}

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

Уведомления когда приложение не в бекграунде

Есть возможность получать уведомления, даже когда приложение на переднем плане. Реализуем протокол UNUserNotificationCenterDelegate. Для этого добавим новый экстеншен.

В документации по протоколу UNUserNotificationCenterDelegate сказано

Use the methods of the UNUserNotificationCenterDelegate protocol to handle user-selected actions from notifications, and to process notifications that arrive when your app is running in the foreground.

Нам нужно использовать метод func userNotificationCenter(UNUserNotificationCenter, willPresent: UNNotification, withCompletionHandler: (UNNotificationPresentationOptions) -> Void) про который написано

Asks the delegate how to handle a notification that arrived while the app was running in the foreground.

Это как раз то, чего мы хотим добиться. Подпишем класс Notifications под протокол UNUserNotificationCenterDelegate.

1extension Notifications: UNUserNotificationCenterDelegate {
2    public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> ()) {
3        completionHandler([.alert, .sound])
4    }
5}

И укажем делегат перед вызовом метода requestAuthorisation() в классе AppDelegate.

1func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
2    notifications.requestAuthorisation()
3    notifications.center.delegate = notifications // не самый лучший код, но для примера сгодится
4    return true
5}

Обработка уведомлений

При тапе на уведомление открывается приложение. Это поведение по умолчанию. Чтобы мы могли как-то реагировать на нажатия по уведомлениям - нужно реализовать еще один метод протокола UNUserNotificationCenterDelegate.

1public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> ()) {
2    if response.notification.request.identifier == "Local Notification #1" {
3        print("Received notification Local Notification #1")
4    }
5
6    completionHandler()
7}

Действия для уведомлений

Чтобы добавить кастомные действий в уведомлениях, сначала нужно нужно добавить категории уведомлений.

Добавляем кастомные экшены в методе scheduleNotification().

1let snoozeAction = UNNotificationAction(identifier: "snooze", title: "Snooze")
2let deleteAction = UNNotificationAction(identifier: "delete", title: "Delete", options: [.destructive])

Теперь создаем категорию с уникальным идентификатором.

1let userAction = "User Action"
2
3let category = UNNotificationCategory(
4                identifier: userAction,
5                actions: [snoozeAction, deleteAction],
6                intentIdentifiers: [])
7
8notifications.setNotificationCategories([category])

Метод setNotificationCategories() регистрирует нашу новую категорию в центре уведомлений.

Осталось указать категорию при создании нашего уведомления. В месте, где мы создаем экземпляр класса UNMutableNotificationContent, нужно установить параметр categoryIdentifier.

1content.sound = .default
2content.badge = 1 // красный бейджик на иконке с кол-вом непрочитанных сообщений
3content.categoryIdentifier = userAction

У нас появились кастомные действия. Их будет видно, если потянуть уведомление вниз. Но они пока ничего не делают.

Добавим обработку стандартных и кастомных действий в экстеншене.

 1public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> ()) {
 2    if response.notification.request.identifier == "Local Notification #1" {
 3        print("Received notification Local Notification #1")
 4    }
 5
 6    print(response.actionIdentifier)
 7
 8    switch response.actionIdentifier {
 9    case UNNotificationDismissActionIdentifier:
10        print("Dismiss action")
11    case UNNotificationDefaultActionIdentifier:
12        print("Default action")
13    case "snooze":
14        print("snooze")
15        scheduleNotification(type: "Reminder")
16    case "delete":
17        print("delete")
18    default:
19        print("undefined")
20    }
21
22    completionHandler()
23}

UNNotificationDefaultActionIdentifier - срабатывает при нажатии по уведомлению. UNNotificationDismissActionIdentifier - срабатывает, когда мы смахиваем уведомление вниз. С Dismiss есть один неочевидный момент - он не будет работать, если при создании категории не указать опцию .customDismissAction:

1let category = UNNotificationCategory(identifier: userAction,
2                                        actions: [snoozeAction, deleteAction],
3                                        intentIdentifiers: [],
4                                        options: .customDismissAction)

На сайте документации есть две статьи по теме кастомных действий:

Пользовательский контент

Для уведомлений можно устанавливать кастомные изображения. Добавим его в методе scheduleNotification(type: String)

 1guard let icon = Bundle.main.url(forResource: "icon", withExtension: "png") else {
 2    print("Path error")
 3    return
 4}
 5
 6do {
 7    let attach = try UNNotificationAttachment(identifier: "icon", url: icon)
 8    content.attachments = [attach]
 9} catch {
10    print("Attachment error")
11}

Картинка должна быть в файлах проекта, не в папке Assets.xcassets. Иначе, метод Bundle.main.url вернет nil. Если все сделано правильно – уведомление будет выглядеть как-то так:

На этом с локальными уведомлениями все.

Пуш-уведомления

Для работы с такими уведомлениями вам нужен платный аккаунт разработчика.

Пуш-уведомления отправляются с сервера через APNs. Уведомления приходят на разные девайсы, APNs сам маршрутизирует сообщения. Разработчик сам решает, когда отправить уведомление.

Для отправки пуш-уведомлений необходимо выполнить дополнительные манипуляции. Схема ниже показывает нужные шаги.

  1. Приложение регистрируется для отправки сообщений.
  2. Девайс получает специальный токен с APNs сервера.
  3. Токен передается в приложение.
  4. Приложение отправляет токен провайдеру(например, нашему бэкенду)
  5. Теперь провайдер может слать уведомления через APNs с использованием токена, который сохранили на 4 шаге.

Существует 2 вида пуш-уведомлений: тестовые(sandbox) и реальные(production). Для разных видов уведомлений используются разные APNs сервера.

Чтобы приложение могло зарегистрироваться для оправки соединения - нужно включить поддержку поддержку пуш-уведомлений. Проще всего это сделать с помощью Xcode. Раньше это был довольно замороченный процесс, но сейчас достаточно выбрать Push Notifications.

И сразу добавьте поддержку бэкграунд обработку задач. Должно быть как на картинке.

За кадром сгенерируется новый идентификатор приложения, обновится Provisioning Profile. Идентификатор моего приложения ru.4gophers.Notifications. Его можно найти на страничке https://developer.apple.com/account/resources/identifiers/list

В настройках этого идентификатора уже должна быть указана поддержка пуш-уведомлений.

И в проекте появляется новый файл Notifications.entitlements. Этот файл имеет расширение .entitlements и называется как и проект.

Сертификаты

Теперь нам нужно создать CertificateSigningRequest для генерации SSL сертификата пуш-уведомлений. Это делается с помощью программы Keychain Access

Сгенерированный файл CertificateSigningRequest.certSigningRequest сохраните на диск. Теперь с его помощью генерируем SSL сертификаты для отправки пуш-уведомлений. Для этого на страничке https://developer.apple.com/account/resources/identifiers/list выберите ваш идентификатор, в разделе Push Notifications нажмите кнопку Сonfigure и сгенерируйте новый Development SSL сертификат с помощью файла CertificateSigningRequest.

Скачайте сгенерированный сертификат и установите его в системе(просто кликните по нему). В программе Keychain Access должен показаться этот серт:

Отлично! Теперь экспортируем сертификат с помощью все той же программы Keychain Access. Нажимаем правой кнопкой по сертификату и выбираем экспорт:

При экспорте нужно выбрать расширение файла .p12. Этот экспортированный сертификат понадобится нам в будущем.

Пуш-уведомления можно тестировать только на реальных устройствах. Девайс должен быть зарегистрирован в https://developer.apple.com/account/resources/devices/list и у вас должен быть рабочий сертификат разработчика.

Осталось добавить ключ для пуш-уведомлений. Для этого на страничке https://developer.apple.com/account/resources/authkeys/list нажимаем + добавляем новый ключ:

Я назову ключ Push Notification Key. После создания ключа, обязательно скачайте его, нажав на кнопку Done

Получение пуш-уведомлений

С подготовкой закончили, вернемся к коду. В методе getNotificationSettings() регистрируем наше приложение в APNs для получения пуш-уведомлений.

 1func getNotificationSettings() {
 2    center.getNotificationSettings { settings in
 3        print("Notification settings : \(settings)")
 4
 5        guard settings.authorizationStatus == .authorized else {
 6            return
 7        }
 8
 9        // регистрироваться необходимо в основном потоке
10        DispatchQueue.main.async {
11            UIApplication.shared.registerForRemoteNotifications()
12        }
13    }
14}

Теперь в классе AppDelegate нужно добавить пару методов. Получаем девайс токен:

1func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
2    let parts = deviceToken.map { data in
3        return String(format: "%02.2hhx", data)
4    }
5
6    let token = parts.joined()
7    print("Device token: \(token)")
8}

Этот токен нам нужен для отправки уведомлений. Он работает как адрес приложения. В реальном приложении мы отправим его наш бекенд и сохраним в базе.

Обработаем ситуацию когда что-то пошло не так и нам не получилось зарегистрироваться в APNs.

1func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
2    print("Failed registration: \(error.localizedDescription)")
3}

Забавно, но у меня ничего не заработало сразу. Ни метод didRegisterForRemoteNotificationsWithDeviceToken, ни didFailToRegisterForRemoteNotificationsWithError не срабатывали. Я потратил на поиск проблемы несколько часов, пока случайно не наткнулся на это обсуждение. Выключите и включите вай-фай. Да. Не спрашивайте.

Отправка нотификаций

Все готово для отправки и получения уведомлений. Давайте протестируем.

Десктопное приложение

Приложений для тестирования уведомлений целая куча, но мне больше всего нравится PushNotifications. Переключитесь на вкладку TOKEN и укажите нужные данные.

Сначала попробуем отправить сообщение с помощью ключа Push Notification Key.

  • f6c10036b6203ebf40a246ce5a741c3b17778063c78aa1016c6474d3dfef46e2 – Токен, который мы получаем при запуске приложения. Он выводится в консоль.
  • YYS33CP3HU – Идентификатор ключа, который мы сгенерировали выше и назвали Push Notification Key
  • 25K6PDW2HY – Team ID, идентификатор аккаунта разработчика

Тело самого уведомления - обычный JSON

1{
2    "aps": {
3        "alert": "Hello2" // это тело уведомления
4    },
5    "yourCustomKey": "1" // любые кастомные данные
6}

alert может быть объектом с заголовком и телом. В уведомление можно указывать звук, бейдж. thread-id позволяет группировать уведомления. Ключ category позволяет использовать кастомные экшены. content-available обозначает досупность обновления для уведомления в бэкграунд режиме.

 1{
 2    "aps": {
 3        "alert": {
 4            "title": "Hello", 
 5            "body": "Тут можно много всего написать"
 6        },
 7        "sound": "default",
 8        "badge": 10,
 9        "thread-id": 1,
10        "category": "User Action",
11        "content-available": 1
12    },
13    "yourCustomKey": "1"
14}

Для отправки нотификаций можно использовать не только .p8 ключ, но и наш SSL сертификат, который мы сгенерировали ранее. Для этого в приложении PushNotifications есть вкладка CERTIFICATE. Она работает точно так же, только нужно использовать сертификат .p12, указать пароль и не нужно указывать Team ID.

Обработка кастомных параметров

Для получения данных из пуш-уведомления нужно реализовать метод в AppDelegate.

1func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
2    print(userInfo)
3}

Но этот метод позволяет получить данные уже после показа уведомления. А в iOS есть возможность кастомизировать контент уведомления с помощью экстеншенов. Например, можно задавать кастомную картинку для каждого уведомления. Для этого нужно создать расширение Notification Content Extension как показано на скриншотах.

Также, можно менять данные в нотификациях перед их показом с помощью Notification Service Extension. Но тема создания таких расширений слишком обширна и тянет на отдельную статью.

Используем Go библиотеку

И теперь самое простое - отправка уведомлений с использованием Go. Уже есть множество готовых библиотек, нам нужно выбрать самую удобную и научится с ней работать.

Мне больше всего понравился пакет APNS/2. В этом пакете уже есть готовая консольная утилита для отправки уведомлений. И у него очень простое АПИ.

Создаем клиент, который будет отправлять сообщения с помощью .p8 ключа.

 1package main
 2
 3import (
 4	"fmt"
 5	"log"
 6
 7	"github.com/sideshow/apns2"
 8	"github.com/sideshow/apns2/token"
 9)
10
11func main() {
12
13	authKey, err := token.AuthKeyFromFile("./AuthKey_YYS33CP3HU.p8")
14	if err != nil {
15		log.Fatal("token error:", err)
16	}
17
18	token := &token.Token{
19		AuthKey: authKey,
20		KeyID:   "YYS33CP3HU",
21		TeamID:  "25K6PDW2HY",
22	}
23
24	notification := &apns2.Notification{}
25	notification.DeviceToken = "f6c10036b6203ebf40a246ce5a741c3b17778063c78aa1016c6474d3dfef46e2"
26	notification.Topic = "ru.4gophers.Notifications"
27	notification.Payload = []byte(`{"aps":{"alert":"Hello!"}}`)
28
29	client := apns2.NewTokenClient(token)
30	res, err := client.Push(notification)
31
32	if err != nil {
33		log.Fatal("Error:", err)
34	}
35
36	fmt.Printf("%v %v %v\n", res.StatusCode, res.ApnsID, res.Reason)
37}

Такой простой код позволяет отправлять сообщения из Go-приложения на iOS телефон. В приложении может быть хендлер, который будет сохранять DeviceToken в базу. И вы сможете рассылать любые уведомления в любое время.

Ссылки

comments powered by Disqus