Со стороны пользователя почтовый клиент — приложение нехитрое. Разработчики Яндекс.Почты даже шутят, что в приложении всего три экрана: список писем; отправка письма; экран about.
Но очень много интересного происходит под капотом. Как многие мобильные приложения, Почта использует push-уведомления, чтобы взаимодействовать с пользователями. Как многие iOS-приложения, Почта теряет часть уведомлений в силу особенностей работы Apple Push Notification Service.
Руководитель iOS-группы Яндекс.Почты Ася Свириденко докажет, что даже с учетом ограничений системы, с потерями push-уведомлений можно и нужно бороться, если они критичны для вашего приложения. Для Почты это так, потому что push-уведомления о новых письмах — это то, ради чего пользователь устанавливает приложение. Если же для вашего приложения доставка push-уведомлений не так критична, узнать, какие велосипеды нагородила мобильная Яндекс.Почта, все равно интересно.
Речь пойдет о remote notification, то есть уведомлениях, которые приходят с сервера через APNs (Apple Push Notification Service). Локальные уведомления затрагивать не будем и поговорим о том:
Сейчас API для работы с push-уведомлениями — это достаточно мощная штука, которая позволяет делать много интересных вещей. Но так было не всегда.
Раньше push-уведомления выглядели именно так — это была несчастная голубая плашка, которая всплывала на экране, блокировала работу с текущим приложением, не давала ничего сделать, а потом пропадала навсегда, и больше никаких напоминаний о ней не было.
С тех пор прошло достаточно времени.
Для нас, как для разработчиков, все началось в iOS 3, когда push-уведомления стали доступны для сторонних библиотек.
В iOS 5 появился Notification Center, и push-уведомления перестали уходить в никуда, теперь они остаются в Notification Center, где их можно посмотреть повторно.
В iOS 6 появился режим «Не беспокоить». У пользователя появилась возможность задать промежуток времени, в течение которого он не хочет получать уведомления.
Эти изменения касались в основном того, как пользователь может работать с push-уведомлениями, как они могут сделать его жизнь комфортнее, а не того, как разработчики могут влиять на уведомления.
Для разработчиков важной вехой стал iOS 8 и появление Notification Action, которые позволили выполнять по push-уведомлениям действия, характерные для конкретного приложения.
В iOS 10 появились Notification Service Extension и Notification Content Extension. Первый позволяет модифицировать push-уведомление до того, как оно будет показано пользователю. Второй — по Force Touch на push-уведомление показывать некоторый UI, в котором, например, можно отображать более детальную информацию. В iOS 10 этот UI был некликабельный — смотреть можно, трогать нельзя.
В iOS 11 появился Notification Privacy Settings. Теперь пользователь может зайти в настройки и указать, хочет ли он, чтобы в пришедших уведомлений отображалось содержимое. Это огромный шаг в сторону безопасности. Понадобилось всего 8 версий iOS, чтобы понять, что не все пользователи хотят, чтобы на лежащем на столе iPhone внезапно всплывала персональная информация.
В iOS 12 появилась возможность группировать push-уведомления по thread-id, и тот UI, который мы получили в iOS 10 с помощью Notification Content Extension, стал кликабельным. Теперь туда можно добавлять кнопки и управление жестами — все то, что помогает пользователю взаимодействовать с UI.
Как вы видите, push-уведомления прошли огромный путь, и сегодня с их помощью можно делать действительно много всего.
Как и раньше, мы можем отправлять текстовые сообщения в push–уведомлении, но теперь дополнительно можно указать ключи для локализации.
Если указать
Как и раньше, можно добавлять звуки в payload уведомления.
В iOS 12 появился critical alert. Это звуки, которые будут проиграны даже в том случае, если пользователь находится в режиме «Не беспокоить».
Обычно пользователю не нужно, чтобы, к примеру, приложение с подпиской на журнал ночью сообщило, что вышел новый номер. Поэтому Apple ограничивает приложения, которые могут использовать critical alert. Если ваше приложение работает со здоровьем, безопасностью, или вы считаете, что critical alert — это то, что действительно может помочь пользователям взаимодействовать с вашим приложением, напишите Apple. Возможно, они разрешат вам использовать эту функциональность.
Silent-уведомления пользователь не видит. Они приходят напрямую в приложение, будят его и позволяют выполнить какие-то действия, чтобы привести приложение в актуальное состояние: отправить запрос на сервер, запросить данные в фоне, обновить данные из базы, обновить UI, чтобы когда пользователь войдет в приложение, он увидел обновленные данные.
Для того, чтобы push-уведомление стало silent, необходимо в payload указать:
Чтобы сгруппировать сообщения, в payload необходимо указать «thread-id». Он может иметь несколько значений в рамках одного приложения, если вы хотите группировать по-разному: по аккаунтам, по получателям, по темам.
Это очень удобно, потому что теперь push-уведомления не занимают все место на заблокированном экране, а сгруппированы вместе. Если вы еще не используете эту функциональность, самое время начать.
Push-уведомления можно менять до того, как они будут показаны. Для этого необходимо добавить в приложение Notification Content Extension и переопределить метод
К примеру, можно в уведомлении отправить ссылку на медиа-контент, скачать контент в Extension, и прикрепить скачанное к уведомлению. После этого вызываете completion с новым контекстом, и показываете пользователю расширенное push-уведомление. Можно менять title, subtitle и т.д.
Еще интересный кейс — можно отправлять push-уведомление с зашифрованным контекстом, если хотите, чтобы данные были дополнительно защищены, и Apple их не увидел. В Notification Content Extension вы сможете их дешифровать и показать пользователю уже дешифрованные данные.
В iOS 11 появилась возможность скрывать содержимое push-уведомления, и мы с вами, как разработчики, на это повлиять никак не можем. Если пользователь выставил галочку «Скрывать контент уведомления», так или иначе он будет скрыт. Все, что мы можем сделать — это через UNNotificationCategory указать placeholder, который отобразится вместо содержимого (по умолчанию это notification), и задать, показывать ли title или subtitle.
Чтобы осуществлять действия по push-уведомлению без запуска самого приложения, необходимо создать категорию и добавить в нее action. Identifier категории передается в поле category у payload уведомления. Можно подключать разные actions к разным типам уведомлений.
В этом расширении можно обрабатывать дополнительные действия, которые вы добавили к push-уведомлению, и показывать custom UI.
Для этого необходимо в приложение добавить Notification Content Extension, определить в нем класс, который наследуется от UNNotificationContentExtension, и дальше работать с ним, как с обычным UIViewController.
Если вы обрабатываете кастомные действия, важно помнить, что по этим действиям стоит обновить UI, который вы показываете пользователю. Не надо пытаться реализовать в этом расширении бизнес-логику. Отправлять запрос на сервер по действию с push-уведомлением надо в основном приложении, а не здесь. Это место только для UI.
Видите, сколько всего можно сделать с push-уведомлениями в iOS. От версии к версии у нас появляется все новая и новая функциональность, но схема доставки push уведомлений сейчас точно такая же, какая она была в iOS 3.
Можно было бы подумать, что схема доставки push-уведомлений была прекрасна с самого начала, но это не так.
В схеме доставки push-уведомлений есть три основных узла:
Я пропущу часть о том, как зарегистрироваться, получать токен, куда его отправлять. Предположим, все это у нас есть. Что происходит дальше?
В Почте и во многих других приложениях используется расширенная схема доставки push-уведомлений. Добавляется Notification Service Extension, в который приходят push-уведомления с
В Яндексе провайдер, формирующий payload, называется XIVA. XIVA — это база данных подписок. Почта использует XIVA для работы с push-уведомлениями как стороннюю библиотеку.
В Почте работа с подписками организована достаточно нетривиально. Мы не просто подписываем приложение на уведомления, у нас есть мультиаккаунтность. Мы можем подписывать разные аккаунты, или в рамках одного аккаунта выбрать, на какие папки пользователь хочет получать уведомления, а на какие не хочет. Всем этим занимается XIVA. Некоторые другие сервисы Яндекса также работают через XIVA: вся информация о приложениях, уведомлениях, подписках, токенах хранится в XIVA.
В схеме доставки push-уведомлений четыре стрелочки, потери могут возникать на трех из этих переходов.
Между сервером и XIVA потери могут возникнуть в следующем случае. Пользователю пришло письмо, сервер об этом знает, формирует уведомление и отправляет в XIVA. Но XIVA может потерять эту информацию, например, если пользователь в приложении выбрал «Подписаться» на определенную папку, пока был офлайн. Тогда XIVA не получит информацию о подписке на папку, и когда придет payload, просто его удалит, и пользователь не увидит нотификации.
Между XIVA и APNs могут возникать потери, связанные с сетью. Мы почти не можем повлиять на сеть, поэтому останавливаться на этом пункте не будем.
Между APNs и Extension либо APNS и iOS, если вы не используете Extension. Это самый частый вид потерь. Такие потери происходят потому, что APNs не хранит более одного push по приложению на устройстве. Если, пока пользователь офлайн, ему приходит несколько уведомлений, когда он выйдет онлайн, он увидит только последнее сообщение.
Это те самые потери, которые не позволяют нам гарантировать доставку и полагаться на push-уведомления. Apple явно пишет, что доставка не гарантирована.
Между Extension приложения и iOS потерь возникать не может, и Apple это гарантирует. Если вы используете Extension и переопределили метод didReceiveContent with completion, даже если вы не вызовете этот completion, уведомление будет показано все равно. Об этом важно помнить. Вы можете его не вызвать или не успеть его вызвать, но тогда уведомление будет показано без каких-либо изменений, в том виде, в котором оно приходит из APNs.
Мы рассмотрим, как мы бороться с потерями между APNs и Extension. Но если вам понадобится увеличить доставляемость push-уведомлений, посмотрите на всю схему. Проверьте, не возникают ли потери на стороне сервиса, нормально ли ваш провайдер взаимодействует с APNs и так далее. Проверьте и измерьте всю цепочку, а потом уже делайте выводы, где больше всего возникает потерь и какую часть этой схемы стоит модифицировать.
Наш способ борьбы с потерями в связке APNs и Extension мы назвали очередью push-уведомлений.
Если сжать весь рассказ до одной фразы, то это будет:
В нашей схеме доставки уведомлений все те же самые участники: XIVA, APNs, Extension. Упрощенно схема работает так:
Первая ожидаемая проблема — дублирование уведомлений. Когда мы повторно запрашиваем у XIVA сообщение, мы не знаем, что сейчас в очереди на отправку, потому что общаемся с ней не напрямую, а через APNs. Предположим, мы увидели, что каких-то уведомлений не хватает, и отправили запрос в XIVA. XIVA отправила через APNs payload с пропущенным уведомлением. Но до того, как мы его получили, мы получили другой payload и тоже с пропуском. Опять перезапросили — XIVA еще раз отправила.
Чтобы уведомления не дублировались, мы используем apns-collapse-id. Эта настройка позволяет на стороне iOS схлопывать push-уведомления с одинаковыми ID. Если на устройство пришло несколько push-уведомлений с одинаковым apns-collapse-id, iOS их схлопнет, и пользователь увидит только одно уведомление.
Расскажу, как это все работает на XIVA, потому что всегда любопытно, что происходит на бэкенде.
XIVA существовала до появления очереди push-уведомлений и представляла из себя базу данных подписок. Важно, что в базе все хранилось по юзерам:
XIVA брала данные из базы и отправляла в APNs или другой сервис, так как работает не только с iOS. Мы решили это переиспользовать.
Мы пришли к команде, разрабатывающей XIVA, и очень попросили сделать очередь push-уведомлений. В принципе, у XIVA для этого уже все было: база данных, TTL у payloads, то есть они не удаляются сразу, их можно переотправить. Единственное, чего не хватало для того, чтобы можно было в рамках текущей реализации XIVA настроить очередь push-уведомлений — это сквозная нумерация.
Для сквозной нумерации push-уведомления должны были пронумерованы по устройству и app_name. То есть сквозная нумерация нужна для конкретного устройства и для конкретного приложения, чтобы опираться на неё на стороне клиента. Сделали это следующим образом: переиспользовали базу данных XIVA, но стали записывать в неё payloads по другому ключу. Теперь в качестве сервиса выступает apns_queue, в качестве юзера
Теперь XIVA берет данные из основной базы данных и, когда их необходимо отправить, складывает в очередь. В этот момент payloads получают новую нумерацию, потому что теперь лежат в этой же базе, но по другому ключу. Уже оттуда XIVA их вынимает и отправляет через APNs. Итого на клиенте получается необходимая нумерация payload.
На клиенте используется Notification Service Extension.
В нем переопределяем метод
Дальше в коде внутри метода идут сплошные проверки: пришел ли необходимый payload, смогли ли его распарсить. Если не распарсили, то это сообщение не из XIVA. Если сообщение не из XIVA, мы не можем с ним дальше работать и просто вызываем completion с тем уведомлением, которое пришло из APNs, никакие расчеты не осуществляем.
Логируем, проверяем, не поменялся ли deviceId, так как знаем, что в iOS это возможно. Честно говоря, мы с изменением deviceId так и не столкнулись, но на всякий случай обрабатываем, потому что если он изменится, мы не сможем доверять нумерации от XIVA.
Дальше смотрим, можем ли получить данные XIVA в этом payload, есть они или нет. Если нет, снова вызываем contentHandler.
Если данные есть, проверяем, для того ли deviceId пришли данные. XIVA присылает в payload хэш устройства, если сверили и совпало, продолжаем, нет — вызываем contentHandler.
Следующим блоком смотрим, есть ли сохраненная позиция:
Считаем количество пропущенных уведомлений. Если пропущенных ноль — отлично, мы ничего не пропустили.
Иначе берем из XIVA данные по позиции — из той самой сквозной нумерации. Дальше смотрим, не превышает ли количество пропущенных некое заданное значение.
Зачем это нужно? Предположим, пользователь достаточно долго был офлайн, и за это время накопилась сотня пропущенных сообщений. Мы запросим всю сотню (нам несложно), XIVA всю сотню вышлет, и пользователь получит все уведомления. Даже если мы сгруппируем их по thread-id (а мы группируем), то все равно для каждого уведомления вызовется этот Extension, пройдут все проверки. Кажется маловероятным, что пользователю нужны все сто уведомлений. Поэтому мы формируем уведомление, в котором так и пишем, что у вас 100 пропущенных сообщений, зайдите в приложение и посмотрите. И показываем пользователю именно это сообщение, потому что можем подменять push-уведомления.
Когда все проверки пройдены, мы отправляем запрос в XIVA: последнюю позицию, которая нам пришла, и количество пропущенных сообщений. И смотрим:
Таким образом реализация на клиенте сводится к большому количеству проверок, в которых мы выясняем, можем ли мы работать с полученными данными.
Как известно, чтобы убедиться, хорошо ли работает подход, надо логировать. Мы стали собирать статистику по новому способу доставки уведомлений и сравнивать, как изменилась доставляемость.
Первое, с чем мы столкнулись, — это ограничения push-extension.
Не всегда вызывается. Если в настройках приложения выключить отрисовку уведомлений (возможность получать уведомление остается включенной, но выключаются все возможные отрисовки), Extension вызван не будет — не будет вызвана вся логика с пересчетами и, самое главное, логирование. Мы не сможем узнать то, что нам важнее всего, — получил ли пользователь уведомление.
У push-extension есть ограничение по времени. В документации Apple написано, что в течение примерно 30 секунд необходимо вызвать completion с видоизмененным уведомлением, иначе будет показано изначальное уведомление.
Интересно то, как мы это выяснили. Мы реализовали фичу, которую назвали «красивые» push-уведомления, прикрепляли к уведомлениям медиаэлементы, изменяли title, subtitle. В ходе тестирования оказалось, что некоторые push-уведомления стали красивыми, а остальные как были гадкими утятами, так и остались.
Мы стали смотреть, в чем разница между этими push-уведомлениями, и выяснили, что разницы нет, просто для одних мы успеваем вызвать completion, а для других нет. Соответственно, когда не успеваем, push-уведомления показывается именно в том виде, в котором пришли с APNs.
Третье ограничение — по памяти. Apple предупреждает, что память, выделяемая на push-extension, ограничена, и не рекомендует загружать в него тяжелые данные, но не уточняет точный размер. У нас получилось, что это примерно 12 МБ.
На Apple Developer Forum разработчики активно обсуждают, какие есть ограничения, высказывают свои предположения и пытаются их точно вычислить. Ограничения на память немного отличаются, но порядок примерно такой — 10 МБ.
Мы столкнулись с этим ограничением, когда добавляли логирование. Для логирования мы используем Яндекс AppMetrica. Когда мы начинали, AppMetrica для загрузки требовалось много памяти, и наш Extension все время отваливался. Поэтому нам пришлось нагородить маленький велосипед, чтобы все-таки залогировать получение уведомлений.
Измерение результатов превратилось в игру: попытку не уронить Extension и залогировать данные.
В итоге логирования push-extension пишет данные в UserDefaults. Потом, когда основное приложение просыпается, оно отправляет данные в AppMetrica.
У этого подхода есть минусы. Основной из них сказывается на измерении. Нам пришлось учитывать, что пользователи не обязательно запускают приложение в тот же день, это вообще может произойти через месяц. Поэтому мы строим выводы только на основе измерений тех пользователей, которые запустили приложение в тот же или на следующий день. Иначе у нас будет большое несоответствие между теми данными, которые отправила XIVA (мы их логируем), и тем, что получил пользователь.
Важно помнить, что Notification Extension работает с iOS 10 и выше, поэтому если вы логируете данные через Extension, не забывайте удалять данные о тех пользователях, которые используют более ранние версии.
В защиту AppMetrica: очень многое сделано с тех пор, push-extension уже давно не падает по памяти. В AppMetrica есть логирование push-уведомлений, и я думаю, что в ближайшее время мы выкинем наш велосипед и вернёмся к нормальному логированию. Какие есть возможность, можно прочитать в AppMetrica Push SDK.
Вот, что показали измерения. график за январь — было до того, как внедрили очередь. По вертикали доставляемость, по горизонтали время.
Явные падения — это выходные дни, когда пользователи и меньше отправляют уведомлений, и гораздо реже открывают почту.
После того, как мы внедрили очередь push-уведомлений, характер графика сохранился, но при этом доставляемость стала гораздо выше — график за февраль.
Доставляемость увеличивается, а значит, мы движемся в верном направлении. Тут можно было бы и остановиться, но…
Мы сделали многое: написали код, посчитали, графики нарисовали. Но как определить, сработало ли? Изменилось ли что-то от того, что мы внедрили очередь push-уведомлений? Доставляемость увеличилась, а как это повлияло на работу с приложением? Как это поменяло user experience и сценарий работы?
Мы, разработка, взяли задачу, придумали решение, написали код, получили результаты и, вроде бы, стали счастливее. Но с продуктовой точки зрения мы еще не до конца поняли, что именно дало увеличение доставляемости push-уведомлений. Возможно, это будет темой следующего рассказа.
Push-уведомления в iOS прошли большой путь. Если вы еще не используете их в своем приложении или используете по минимуму, посмотрите на пример Яндекс.Почты. Возможно, некоторые решения вам пригодятся.
Пропущенные push-уведомления можно (и нужно) перезапрашивать. Совершенно необязательно делать это как в Яндекс.Почте через XIVA. Может, у вас есть похожий сервис, который поможет вам в этом. Может, найдете сторонний, который тоже умеет делать нечто подобное. Ищите!
Помните про ограничения push-extension. Не перегружайте его по памяти, учитывайте ограничение по времени. Следите, чтобы он вызывался.
Подумайте, что вам даст улучшение доставки. Прежде, чем ввязаться в эту авантюру, подумайте, нужно ли вам это, даст ли это что-то вашему приложению. Возможно, увеличение доставляемости push-уведомлений для вашего приложения абсолютно не критично и ничего вам не даст. Но, возможно, именно вам оно даст прирост пользователей, и вы окажетесь на вершине App Store, где мы все хотим оказаться, и обязательно окажемся!
Но очень много интересного происходит под капотом. Как многие мобильные приложения, Почта использует push-уведомления, чтобы взаимодействовать с пользователями. Как многие iOS-приложения, Почта теряет часть уведомлений в силу особенностей работы Apple Push Notification Service.
Руководитель iOS-группы Яндекс.Почты Ася Свириденко докажет, что даже с учетом ограничений системы, с потерями push-уведомлений можно и нужно бороться, если они критичны для вашего приложения. Для Почты это так, потому что push-уведомления о новых письмах — это то, ради чего пользователь устанавливает приложение. Если же для вашего приложения доставка push-уведомлений не так критична, узнать, какие велосипеды нагородила мобильная Яндекс.Почта, все равно интересно.
Речь пойдет о remote notification, то есть уведомлениях, которые приходят с сервера через APNs (Apple Push Notification Service). Локальные уведомления затрагивать не будем и поговорим о том:
- Как выглядит API для работы с push-уведомлениями. Рассмотрим схему доставки push-уведомлений и то, где в этой схеме могут возникать потери.
- Как решили бороться с потерями в Яндекс.Почте — об очереди push-уведомлений.
- Как логировать и какие еще сложности могут встретиться.
Что имеем и где теряем
Сейчас API для работы с push-уведомлениями — это достаточно мощная штука, которая позволяет делать много интересных вещей. Но так было не всегда.
Раньше push-уведомления выглядели именно так — это была несчастная голубая плашка, которая всплывала на экране, блокировала работу с текущим приложением, не давала ничего сделать, а потом пропадала навсегда, и больше никаких напоминаний о ней не было.
С тех пор прошло достаточно времени.
Для нас, как для разработчиков, все началось в iOS 3, когда push-уведомления стали доступны для сторонних библиотек.
В iOS 5 появился Notification Center, и push-уведомления перестали уходить в никуда, теперь они остаются в Notification Center, где их можно посмотреть повторно.
В iOS 6 появился режим «Не беспокоить». У пользователя появилась возможность задать промежуток времени, в течение которого он не хочет получать уведомления.
Эти изменения касались в основном того, как пользователь может работать с push-уведомлениями, как они могут сделать его жизнь комфортнее, а не того, как разработчики могут влиять на уведомления.
Для разработчиков важной вехой стал iOS 8 и появление Notification Action, которые позволили выполнять по push-уведомлениям действия, характерные для конкретного приложения.
В iOS 10 появились Notification Service Extension и Notification Content Extension. Первый позволяет модифицировать push-уведомление до того, как оно будет показано пользователю. Второй — по Force Touch на push-уведомление показывать некоторый UI, в котором, например, можно отображать более детальную информацию. В iOS 10 этот UI был некликабельный — смотреть можно, трогать нельзя.
В iOS 11 появился Notification Privacy Settings. Теперь пользователь может зайти в настройки и указать, хочет ли он, чтобы в пришедших уведомлений отображалось содержимое. Это огромный шаг в сторону безопасности. Понадобилось всего 8 версий iOS, чтобы понять, что не все пользователи хотят, чтобы на лежащем на столе iPhone внезапно всплывала персональная информация.
В iOS 12 появилась возможность группировать push-уведомления по thread-id, и тот UI, который мы получили в iOS 10 с помощью Notification Content Extension, стал кликабельным. Теперь туда можно добавлять кнопки и управление жестами — все то, что помогает пользователю взаимодействовать с UI.
Push-уведомления сегодня
Как вы видите, push-уведомления прошли огромный путь, и сегодня с их помощью можно делать действительно много всего.
Текстовые сообщения и локализация
Как и раньше, мы можем отправлять текстовые сообщения в push–уведомлении, но теперь дополнительно можно указать ключи для локализации.
"aps" : {
"alert" : {
"title" : "New Mail",
"subtitle-loc-key" : "alert_subtitle_localization_key",
"loc-key" : "alert_body_localization_key",
}
}
Если указать
subtitle-loc-key
и loc-key
в payload уведомления, то когда push-уведомление придет на устройство, в файле Localizable.string приложения будут найдены нужные значения, и пользователь увидит локализованное сообщение.Звук и critical alert
Как и раньше, можно добавлять звуки в payload уведомления.
"aps" : {
"sound" : {
"critical" : 1,
"name" : "bingbong.aiff",
"volume" : 1.0,
}
}
В iOS 12 появился critical alert. Это звуки, которые будут проиграны даже в том случае, если пользователь находится в режиме «Не беспокоить».
Обычно пользователю не нужно, чтобы, к примеру, приложение с подпиской на журнал ночью сообщило, что вышел новый номер. Поэтому Apple ограничивает приложения, которые могут использовать critical alert. Если ваше приложение работает со здоровьем, безопасностью, или вы считаете, что critical alert — это то, что действительно может помочь пользователям взаимодействовать с вашим приложением, напишите Apple. Возможно, они разрешат вам использовать эту функциональность.
Silent-уведомления
Silent-уведомления пользователь не видит. Они приходят напрямую в приложение, будят его и позволяют выполнить какие-то действия, чтобы привести приложение в актуальное состояние: отправить запрос на сервер, запросить данные в фоне, обновить данные из базы, обновить UI, чтобы когда пользователь войдет в приложение, он увидел обновленные данные.
"aps" : {
"content-available" : 1
// не включать alert, sound и badge ключи в payload
}
Для того, чтобы push-уведомление стало silent, необходимо в payload указать:
"content-available" : 1
. И не указывать alert, sound и badge ключи в payload — они совершенно бесполезны для push-уведомления, которое не будет показано пользователю.Группировка уведомлений
Чтобы сгруппировать сообщения, в payload необходимо указать «thread-id». Он может иметь несколько значений в рамках одного приложения, если вы хотите группировать по-разному: по аккаунтам, по получателям, по темам.
"aps" : {
"thread-id" : "any_thread_identifier"
}
Это очень удобно, потому что теперь push-уведомления не занимают все место на заблокированном экране, а сгруппированы вместе. Если вы еще не используете эту функциональность, самое время начать.
Изменение уведомления до его показа
Push-уведомления можно менять до того, как они будут показаны. Для этого необходимо добавить в приложение Notification Content Extension и переопределить метод
didReceive
. В этом методе можно получить контент уведомления и модифицировать его."aps" : { "mutable-content" : 1 }
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard let mutableContent = request.content.mutableCopy()
as? UNMutableNotificationContent else {
contentHandler(request.content); return
}
mutableContent.subtitle = "Got it!"
contentHandler(mutableContent)
}
К примеру, можно в уведомлении отправить ссылку на медиа-контент, скачать контент в Extension, и прикрепить скачанное к уведомлению. После этого вызываете completion с новым контекстом, и показываете пользователю расширенное push-уведомление. Можно менять title, subtitle и т.д.
Еще интересный кейс — можно отправлять push-уведомление с зашифрованным контекстом, если хотите, чтобы данные были дополнительно защищены, и Apple их не увидел. В Notification Content Extension вы сможете их дешифровать и показать пользователю уже дешифрованные данные.
Скрытый контент уведомления
В iOS 11 появилась возможность скрывать содержимое push-уведомления, и мы с вами, как разработчики, на это повлиять никак не можем. Если пользователь выставил галочку «Скрывать контент уведомления», так или иначе он будет скрыт. Все, что мы можем сделать — это через UNNotificationCategory указать placeholder, который отобразится вместо содержимого (по умолчанию это notification), и задать, показывать ли title или subtitle.
let commentCategory = UNNotificationCategory(identifier: "comment-category",
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder:
NSString.localizedUserNotificationString(forKey:"COMMENT_KEY",arguments: nil),
options: [.hiddenPreviewsShowTitle])
Действия по уведомлению без запуска приложения
Чтобы осуществлять действия по push-уведомлению без запуска самого приложения, необходимо создать категорию и добавить в нее action. Identifier категории передается в поле category у payload уведомления. Можно подключать разные actions к разным типам уведомлений.
"aps" : { "category" : "message" }
let action = UNNotificationAction(identifier:"reply", title:"Reply", options:[])
let category = UNNotificationCategory(identifier: "message",
actions: [action],
minimalActions: [action],
intentIdentifiers: [],
options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
Rich Notifications
В этом расширении можно обрабатывать дополнительные действия, которые вы добавили к push-уведомлению, и показывать custom UI.
Для этого необходимо в приложение добавить Notification Content Extension, определить в нем класс, который наследуется от UNNotificationContentExtension, и дальше работать с ним, как с обычным UIViewController.
class NotificationViewController: UIViewController, UNNotificationContentExtension {
@IBOutlet var userLabel: UILabel?
func didReceive(_ notification: UNNotification) {
let content = notification.request.content
self.title = content.title
let userInfo = content.userInfo
self.userLabel?.text = userInfo["video-user"] as? String
}
}
Если вы обрабатываете кастомные действия, важно помнить, что по этим действиям стоит обновить UI, который вы показываете пользователю. Не надо пытаться реализовать в этом расширении бизнес-логику. Отправлять запрос на сервер по действию с push-уведомлением надо в основном приложении, а не здесь. Это место только для UI.
Схема доставки push-уведомлений
Видите, сколько всего можно сделать с push-уведомлениями в iOS. От версии к версии у нас появляется все новая и новая функциональность, но схема доставки push уведомлений сейчас точно такая же, какая она была в iOS 3.
Можно было бы подумать, что схема доставки push-уведомлений была прекрасна с самого начала, но это не так.
В схеме доставки push-уведомлений есть три основных узла:
- провайдер, который формирует payload push-уведомления;
- APNs — Apple Push Notification Service, который доставляет уведомление;
- устройство с iOS и вашим приложением.
Я пропущу часть о том, как зарегистрироваться, получать токен, куда его отправлять. Предположим, все это у нас есть. Что происходит дальше?
- Провайдер формирует payload и отправляет его в APNs.
- APNs отправляет его на устройство.
- Пользователь видит push-сообщение на своем устройстве.
В Почте и во многих других приложениях используется расширенная схема доставки push-уведомлений. Добавляется Notification Service Extension, в который приходят push-уведомления с
"mutable-content" : 1
. Провайдер разделяется на сервер, который занимается бэкенд-логикой приложения, и собственно провайдер, который формирует payload и занимается подписками.В Яндексе провайдер, формирующий payload, называется XIVA. XIVA — это база данных подписок. Почта использует XIVA для работы с push-уведомлениями как стороннюю библиотеку.
В Почте работа с подписками организована достаточно нетривиально. Мы не просто подписываем приложение на уведомления, у нас есть мультиаккаунтность. Мы можем подписывать разные аккаунты, или в рамках одного аккаунта выбрать, на какие папки пользователь хочет получать уведомления, а на какие не хочет. Всем этим занимается XIVA. Некоторые другие сервисы Яндекса также работают через XIVA: вся информация о приложениях, уведомлениях, подписках, токенах хранится в XIVA.
Где потери?
В схеме доставки push-уведомлений четыре стрелочки, потери могут возникать на трех из этих переходов.
Между сервером и XIVA потери могут возникнуть в следующем случае. Пользователю пришло письмо, сервер об этом знает, формирует уведомление и отправляет в XIVA. Но XIVA может потерять эту информацию, например, если пользователь в приложении выбрал «Подписаться» на определенную папку, пока был офлайн. Тогда XIVA не получит информацию о подписке на папку, и когда придет payload, просто его удалит, и пользователь не увидит нотификации.
Между XIVA и APNs могут возникать потери, связанные с сетью. Мы почти не можем повлиять на сеть, поэтому останавливаться на этом пункте не будем.
Между APNs и Extension либо APNS и iOS, если вы не используете Extension. Это самый частый вид потерь. Такие потери происходят потому, что APNs не хранит более одного push по приложению на устройстве. Если, пока пользователь офлайн, ему приходит несколько уведомлений, когда он выйдет онлайн, он увидит только последнее сообщение.
Это те самые потери, которые не позволяют нам гарантировать доставку и полагаться на push-уведомления. Apple явно пишет, что доставка не гарантирована.
Между Extension приложения и iOS потерь возникать не может, и Apple это гарантирует. Если вы используете Extension и переопределили метод didReceiveContent with completion, даже если вы не вызовете этот completion, уведомление будет показано все равно. Об этом важно помнить. Вы можете его не вызвать или не успеть его вызвать, но тогда уведомление будет показано без каких-либо изменений, в том виде, в котором оно приходит из APNs.
Мы рассмотрим, как мы бороться с потерями между APNs и Extension. Но если вам понадобится увеличить доставляемость push-уведомлений, посмотрите на всю схему. Проверьте, не возникают ли потери на стороне сервиса, нормально ли ваш провайдер взаимодействует с APNs и так далее. Проверьте и измерьте всю цепочку, а потом уже делайте выводы, где больше всего возникает потерь и какую часть этой схемы стоит модифицировать.
Очередь push-уведомлений
Наш способ борьбы с потерями в связке APNs и Extension мы назвали очередью push-уведомлений.
Если сжать весь рассказ до одной фразы, то это будет:
Если вы пропустили push-уведомление, его можно запросить заново.
В нашей схеме доставки уведомлений все те же самые участники: XIVA, APNs, Extension. Упрощенно схема работает так:
- XIVA нумерует push-уведомления, которые собирается отправлять в APNs, и только потом отправляет информацию.
- Extension получает push-уведомление номер 1 и, через какое-то время, номер 3. Понимает, что какие-то данные пропущены.
- Отправляет в XIVA запрос с последней полученной позицией, diff и просит прислать пропущенные данные заново.
- XIVA повторно отправляет push-уведомление, потому что хранит у себя базу payloads и базу подписок. Все подписки хранятся в течение некоторого времени, и их можно перезапросить.
- Перезапрашиваем, получаем push-уведомление, и имеем на клиенте все сообщения, которые клиент должен был получить.
Первая ожидаемая проблема — дублирование уведомлений. Когда мы повторно запрашиваем у XIVA сообщение, мы не знаем, что сейчас в очереди на отправку, потому что общаемся с ней не напрямую, а через APNs. Предположим, мы увидели, что каких-то уведомлений не хватает, и отправили запрос в XIVA. XIVA отправила через APNs payload с пропущенным уведомлением. Но до того, как мы его получили, мы получили другой payload и тоже с пропуском. Опять перезапросили — XIVA еще раз отправила.
Чтобы уведомления не дублировались, мы используем apns-collapse-id. Эта настройка позволяет на стороне iOS схлопывать push-уведомления с одинаковыми ID. Если на устройство пришло несколько push-уведомлений с одинаковым apns-collapse-id, iOS их схлопнет, и пользователь увидит только одно уведомление.
XIVA
Расскажу, как это все работает на XIVA, потому что всегда любопытно, что происходит на бэкенде.
XIVA существовала до появления очереди push-уведомлений и представляла из себя базу данных подписок. Важно, что в базе все хранилось по юзерам:
- В качестве ключа выступал
<service, user>
. - В качестве value хранился payload (данные о письмах в случае Почты).
XIVA брала данные из базы и отправляла в APNs или другой сервис, так как работает не только с iOS. Мы решили это переиспользовать.
Мы пришли к команде, разрабатывающей XIVA, и очень попросили сделать очередь push-уведомлений. В принципе, у XIVA для этого уже все было: база данных, TTL у payloads, то есть они не удаляются сразу, их можно переотправить. Единственное, чего не хватало для того, чтобы можно было в рамках текущей реализации XIVA настроить очередь push-уведомлений — это сквозная нумерация.
Для сквозной нумерации push-уведомления должны были пронумерованы по устройству и app_name. То есть сквозная нумерация нужна для конкретного устройства и для конкретного приложения, чтобы опираться на неё на стороне клиента. Сделали это следующим образом: переиспользовали базу данных XIVA, но стали записывать в неё payloads по другому ключу. Теперь в качестве сервиса выступает apns_queue, в качестве юзера
device_id + app_name
— те самые данные, по которым нужна нумерация на клиенте, то есть key: <apns_queue, device_id + app_name>
.Теперь XIVA берет данные из основной базы данных и, когда их необходимо отправить, складывает в очередь. В этот момент payloads получают новую нумерацию, потому что теперь лежат в этой же базе, но по другому ключу. Уже оттуда XIVA их вынимает и отправляет через APNs. Итого на клиенте получается необходимая нумерация payload.
На клиенте используется Notification Service Extension.
public override func didReceive(_ request: UNNotificationRequest, withContentHandler
contentHandler: @escaping (UNNotificationContent) -> Void) {
// . . .
}
В нем переопределяем метод
didReceive
и смотрим, что же пришло с сервера. Всем push-уведомлениям добавляем "mutable-content" : 1
, чтобы они попали в Extension, потому что иначе не можем учесть их в расчетах.Дальше в коде внутри метода идут сплошные проверки: пришел ли необходимый payload, смогли ли его распарсить. Если не распарсили, то это сообщение не из XIVA. Если сообщение не из XIVA, мы не можем с ним дальше работать и просто вызываем completion с тем уведомлением, которое пришло из APNs, никакие расчеты не осуществляем.
guard let payload = try? self.payloadParser.parsePayload(from: request.content.userInfo) else {
// невалидный формат, или нотификация не из xiva
contentHandler(request.content); return
}
Логируем, проверяем, не поменялся ли deviceId, так как знаем, что в iOS это возможно. Честно говоря, мы с изменением deviceId так и не столкнулись, но на всякий случай обрабатываем, потому что если он изменится, мы не сможем доверять нумерации от XIVA.
self.logger.logNotificationReceived(with: payload)
if lastPositionDeviceId != deviceId {
// deviceId изменился, сбрасываем сохранённые настройки
lastNotificationPosition = nil
lastPositionDeviceId = deviceId
}
Дальше смотрим, можем ли получить данные XIVA в этом payload, есть они или нет. Если нет, снова вызываем contentHandler.
guard let xivaInfo = payload.xivaInfo else {
contentHandler(request.content); return
}
Если данные есть, проверяем, для того ли deviceId пришли данные. XIVA присылает в payload хэш устройства, если сверили и совпало, продолжаем, нет — вызываем contentHandler.
guard isHashCompatible(deviceId: deviceId, deviceIdHash: xivaInfo.deviceIdHash) else {
// payload device_id не равен device_id приложения
contentHandler(request.content); return
}
Следующим блоком смотрим, есть ли сохраненная позиция:
- Если у нас нет последней сохраненной позиции, то мы либо еще не получали уведомления и не заходили в Extension, либо по какой-то причине дропнули. Тогда не от чего отталкиваться, чтобы посчитать diff пропущенных, и мы снова вызываем completion.
- Если есть, идем дальше.
guard let lastPos = lastNotificationPosition else {
// сохраняем позицию на следующий раз
lastNotificationPosition = xivaInfo.notificationPosition
contentHandler(request.content); return
}
Считаем количество пропущенных уведомлений. Если пропущенных ноль — отлично, мы ничего не пропустили.
let missedMessages = xivaInfo.notificationPosition - lastPos - 1
guard missedMessages > 0 else {
// получили дубликат push–уведомления или ничего не пропустили
contentHandler(request.content); return
}
Иначе берем из XIVA данные по позиции — из той самой сквозной нумерации. Дальше смотрим, не превышает ли количество пропущенных некое заданное значение.
lastNotificationPosition = xivaInfo.notificationPosition
guard missedMessages <= repeatMaxCount else {
// слишком много сообщений пропущено, показываем одно
contentHandler(buildNewNotification()); return
}
Зачем это нужно? Предположим, пользователь достаточно долго был офлайн, и за это время накопилась сотня пропущенных сообщений. Мы запросим всю сотню (нам несложно), XIVA всю сотню вышлет, и пользователь получит все уведомления. Даже если мы сгруппируем их по thread-id (а мы группируем), то все равно для каждого уведомления вызовется этот Extension, пройдут все проверки. Кажется маловероятным, что пользователю нужны все сто уведомлений. Поэтому мы формируем уведомление, в котором так и пишем, что у вас 100 пропущенных сообщений, зайдите в приложение и посмотрите. И показываем пользователю именно это сообщение, потому что можем подменять push-уведомления.
Когда все проверки пройдены, мы отправляем запрос в XIVA: последнюю позицию, которая нам пришла, и количество пропущенных сообщений. И смотрим:
- Если XIVA ответила успешно: «Все хорошо, перепосылаю данные», мы показываем пользователю текущее уведомление и ждем, пока XIVA дошлет все остальное, и пользователь увидит все пропущенные сообщения.
- Если же XIVA отвечает ошибкой, то показываем пользователю кастомное уведомление о том, что у него есть пропущенные сообщения, которые можно посмотреть в приложении.
self.requestMissedNotifications(lastPosition: xivaInfo.notificationPosition,
gap: missedMessages) { result in
result.onValue { _ in
self.logger.logNotificationProcessed(with: .success)
contentHandler(request.content)
}.onError { error in
self.logger.logNotificationProcessed(with: .failure(error))
contentHandler(buildNewNotification())
}
}
Таким образом реализация на клиенте сводится к большому количеству проверок, в которых мы выясняем, можем ли мы работать с полученными данными.
Логирование и прочие сложности
Как известно, чтобы убедиться, хорошо ли работает подход, надо логировать. Мы стали собирать статистику по новому способу доставки уведомлений и сравнивать, как изменилась доставляемость.
Ограничения push-extension
Первое, с чем мы столкнулись, — это ограничения push-extension.
Не всегда вызывается. Если в настройках приложения выключить отрисовку уведомлений (возможность получать уведомление остается включенной, но выключаются все возможные отрисовки), Extension вызван не будет — не будет вызвана вся логика с пересчетами и, самое главное, логирование. Мы не сможем узнать то, что нам важнее всего, — получил ли пользователь уведомление.
У push-extension есть ограничение по времени. В документации Apple написано, что в течение примерно 30 секунд необходимо вызвать completion с видоизмененным уведомлением, иначе будет показано изначальное уведомление.
Интересно то, как мы это выяснили. Мы реализовали фичу, которую назвали «красивые» push-уведомления, прикрепляли к уведомлениям медиаэлементы, изменяли title, subtitle. В ходе тестирования оказалось, что некоторые push-уведомления стали красивыми, а остальные как были гадкими утятами, так и остались.
Мы стали смотреть, в чем разница между этими push-уведомлениями, и выяснили, что разницы нет, просто для одних мы успеваем вызвать completion, а для других нет. Соответственно, когда не успеваем, push-уведомления показывается именно в том виде, в котором пришли с APNs.
Третье ограничение — по памяти. Apple предупреждает, что память, выделяемая на push-extension, ограничена, и не рекомендует загружать в него тяжелые данные, но не уточняет точный размер. У нас получилось, что это примерно 12 МБ.
На Apple Developer Forum разработчики активно обсуждают, какие есть ограничения, высказывают свои предположения и пытаются их точно вычислить. Ограничения на память немного отличаются, но порядок примерно такой — 10 МБ.
Мы столкнулись с этим ограничением, когда добавляли логирование. Для логирования мы используем Яндекс AppMetrica. Когда мы начинали, AppMetrica для загрузки требовалось много памяти, и наш Extension все время отваливался. Поэтому нам пришлось нагородить маленький велосипед, чтобы все-таки залогировать получение уведомлений.
Измерение результатов превратилось в игру: попытку не уронить Extension и залогировать данные.
Измеряем результаты
В итоге логирования push-extension пишет данные в UserDefaults. Потом, когда основное приложение просыпается, оно отправляет данные в AppMetrica.
У этого подхода есть минусы. Основной из них сказывается на измерении. Нам пришлось учитывать, что пользователи не обязательно запускают приложение в тот же день, это вообще может произойти через месяц. Поэтому мы строим выводы только на основе измерений тех пользователей, которые запустили приложение в тот же или на следующий день. Иначе у нас будет большое несоответствие между теми данными, которые отправила XIVA (мы их логируем), и тем, что получил пользователь.
Важно помнить, что Notification Extension работает с iOS 10 и выше, поэтому если вы логируете данные через Extension, не забывайте удалять данные о тех пользователях, которые используют более ранние версии.
В защиту AppMetrica: очень многое сделано с тех пор, push-extension уже давно не падает по памяти. В AppMetrica есть логирование push-уведомлений, и я думаю, что в ближайшее время мы выкинем наш велосипед и вернёмся к нормальному логированию. Какие есть возможность, можно прочитать в AppMetrica Push SDK.
Вот, что показали измерения. график за январь — было до того, как внедрили очередь. По вертикали доставляемость, по горизонтали время.
Явные падения — это выходные дни, когда пользователи и меньше отправляют уведомлений, и гораздо реже открывают почту.
После того, как мы внедрили очередь push-уведомлений, характер графика сохранился, но при этом доставляемость стала гораздо выше — график за февраль.
Доставляемость увеличивается, а значит, мы движемся в верном направлении. Тут можно было бы и остановиться, но…
Фрустрация
Мы сделали многое: написали код, посчитали, графики нарисовали. Но как определить, сработало ли? Изменилось ли что-то от того, что мы внедрили очередь push-уведомлений? Доставляемость увеличилась, а как это повлияло на работу с приложением? Как это поменяло user experience и сценарий работы?
Стали ли наши пользователи счастливее от того, что они за день увидели на 2–3–20 уведомлений больше?
Мы, разработка, взяли задачу, придумали решение, написали код, получили результаты и, вроде бы, стали счастливее. Но с продуктовой точки зрения мы еще не до конца поняли, что именно дало увеличение доставляемости push-уведомлений. Возможно, это будет темой следующего рассказа.
Итоги
Push-уведомления в iOS прошли большой путь. Если вы еще не используете их в своем приложении или используете по минимуму, посмотрите на пример Яндекс.Почты. Возможно, некоторые решения вам пригодятся.
Пропущенные push-уведомления можно (и нужно) перезапрашивать. Совершенно необязательно делать это как в Яндекс.Почте через XIVA. Может, у вас есть похожий сервис, который поможет вам в этом. Может, найдете сторонний, который тоже умеет делать нечто подобное. Ищите!
Помните про ограничения push-extension. Не перегружайте его по памяти, учитывайте ограничение по времени. Следите, чтобы он вызывался.
Подумайте, что вам даст улучшение доставки. Прежде, чем ввязаться в эту авантюру, подумайте, нужно ли вам это, даст ли это что-то вашему приложению. Возможно, увеличение доставляемости push-уведомлений для вашего приложения абсолютно не критично и ничего вам не даст. Но, возможно, именно вам оно даст прирост пользователей, и вы окажетесь на вершине App Store, где мы все хотим оказаться, и обязательно окажемся!
Мы в AppsConf с нетерпением ждем следующего доклада Аси, в котором она уже 21 или 22 октября расскажет об идеях и открытиях, родившихся за время рефакторинга большей части приложения Яндекс.Почты. В равной степени мы предвкушаем еще почти 50 выступлений, призванных помочь мобильным разработчикам расти. До 1 сентября можно подать заявку на доклад и оказаться в компании крутых людей, которые прямо влияют на будущее индустрии — успевайте.
Комментарии (6)
prs123
28.08.2019 14:01Вы, как я понимаю, наверное, отправляете только push'ы с нотификацией. То есть сервисных у вас нет. Дело то вот какое,
Для того, чтобы push-уведомление стало silent, необходимо в payload указать: «content-available»: 1. И не указывать alert, sound и badge ключи в payload — они совершенно бесполезны для push-уведомления, которое не будет показано пользователю.
Это действительно так, но в случае если приложение активно или свёрнуто, но не закрыто (!), то есть (foreground || background) && !killed. По крайней мере, мне не удалось обойти это, хотя появился такой эффект только в 10 iOS'е. Вы с таким не сталкивались?
mxms
Apple старается изо всех сил чтобы не дать своим пользователям возможность нормально работать с любым сервером посредством IMAP IDLE.
edo1h
мне казалось, что это сделано ради экономии батарейки — через один канал связи с APNs можно получать нотификации для всех приложений (соответственно меньше открытых tcp-соединений, keep-alive пакетов и т.п.)
mxms
Вы серьёзно думаете что плюс один открытый сокет сколь-нибудь заметно влияет на энергоэффективность?
Я здесь ничего, кроме попытки ограничения пользователей для защиты своей поляны (прибылей) не вижу. Из той же оперы и невозможность задать опрос входящей почты чаще чем раз в 15 минут.
edo1h
вот именно так думает автор каждой программы
Zordhauer
от создателей "ещё 5 минуточек"