4gophers

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

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

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

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

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

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

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

import UserNotifications

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

let center = UNUserNotificationCenter.current()

func requestAuthorisation() {
    center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        print("Permission granted: \(granted)")
    }
}

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

let notifications = Notifications()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    notifications.requestAuthorisation()

    return true
}

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

func requestAuthorisation() {
    center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        print("Permission granted: \(granted)")

        guard granted else {
            return
        }

        self.getNotificationSettings()
    }
}

func getNotificationSettings() {
    center.getNotificationSettings { settings in
        print("Notification settings : \(settings)")
    }
}

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

func scheduleNotification(type: String) {
    let content = UNMutableNotificationContent()

    content.title = type
    content.body = "Example notification " + type
    content.sound = .default
    content.badge = 1 // красный бейджик на иконке с кол-вом непрочитанных сообщений
}

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

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

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

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

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let id = "Local Notification #1"

let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)

notifications.add(request) { error in
    if let error = error {
        print("Error \(error.localizedDescription)")
    }
}

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

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

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

let delegate = UIApplication.shared.delegate as? AppDelegate

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

delegate?.scheduleNotification(type: "local")

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

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

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

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

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

Есть возможность получать уведомления, даже когда приложение на переднем плане. Реализуем протокол 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.

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

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

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

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

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

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

    completionHandler()
}

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

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

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

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

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

let userAction = "User Action"

let category = UNNotificationCategory(
                identifier: userAction,
                actions: [snoozeAction, deleteAction],
                intentIdentifiers: [])

notifications.setNotificationCategories([category])

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

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

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

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

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

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

    print(response.actionIdentifier)

    switch response.actionIdentifier {
    case UNNotificationDismissActionIdentifier:
        print("Dismiss action")
    case UNNotificationDefaultActionIdentifier:
        print("Default action")
    case "snooze":
        print("snooze")
        scheduleNotification(type: "Reminder")
    case "delete":
        print("delete")
    default:
        print("undefined")
    }

    completionHandler()
}

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

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

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

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

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

guard let icon = Bundle.main.url(forResource: "icon", withExtension: "png") else {
    print("Path error")
    return
}

do {
    let attach = try UNNotificationAttachment(identifier: "icon", url: icon)
    content.attachments = [attach]
} catch {
    print("Attachment error")
}

Картинка должна быть в файлах проекта, не в папке 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 для получения пуш-уведомлений.

func getNotificationSettings() {
    center.getNotificationSettings { settings in
        print("Notification settings : \(settings)")

        guard settings.authorizationStatus == .authorized else {
            return
        }

        // регистрироваться необходимо в основном потоке
        DispatchQueue.main.async {
            UIApplication.shared.registerForRemoteNotifications()
        }
    }
}

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

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let parts = deviceToken.map { data in
        return String(format: "%02.2hhx", data)
    }

    let token = parts.joined()
    print("Device token: \(token)")
}

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

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

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

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

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

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

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

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

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

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

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

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

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

{
    "aps": {
        "alert": {
            "title": "Hello", 
            "body": "Тут можно много всего написать"
        },
        "sound": "default",
        "badge": 10,
        "thread-id": 1,
        "category": "User Action",
        "content-available": 1
    },
    "yourCustomKey": "1"
}

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

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

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

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

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

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

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

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

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

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

package main

import (
	"fmt"
	"log"

	"github.com/sideshow/apns2"
	"github.com/sideshow/apns2/token"
)

func main() {

	authKey, err := token.AuthKeyFromFile("./AuthKey_YYS33CP3HU.p8")
	if err != nil {
		log.Fatal("token error:", err)
	}

	token := &token.Token{
		AuthKey: authKey,
		KeyID:   "YYS33CP3HU",
		TeamID:  "25K6PDW2HY",
	}

	notification := &apns2.Notification{}
	notification.DeviceToken = "f6c10036b6203ebf40a246ce5a741c3b17778063c78aa1016c6474d3dfef46e2"
	notification.Topic = "ru.4gophers.Notifications"
	notification.Payload = []byte(`{"aps":{"alert":"Hello!"}}`)

	client := apns2.NewTokenClient(token)
	res, err := client.Push(notification)

	if err != nil {
		log.Fatal("Error:", err)
	}

	fmt.Printf("%v %v %v\n", res.StatusCode, res.ApnsID, res.Reason)
}

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

Ссылки

comments powered by Disqus