Атомарность уведомления
Каждое уведомление в шторке — это объект с двумя ключами: тегом (строка, необязательная) и ID (целое число, обязательное). Именно пара «тег — ID» определяет уникальность. Это значит, что два уведомления с одинаковым ID, но разными тегами, спокойно сосуществуют, а два уведомления с одинаковой парой заменяют друг друга.
Это первое место, где начинающие разработчики теряются. Типичный сценарий: приложение шлёт уведомления о новых сообщениях, и разработчик генерирует для каждого сообщения случайный ID, а потом... не может его удалить, потому что ID нигде не сохранён. Классическое решение — заводить карту соответствий «чат → ID уведомления» и хранить её в базе или в SharedPreferences. Решение рабочее, но у него есть фундаментальный недостаток: оно вводит вторую систему учёта. Данные теперь живут в двух местах — в системе уведомлений и в вашем хранилище. И рано или поздно они расходятся: приложение упало между сохранением и показом, пользователь очистил данные приложения, заменилось уведомление без обновления записи в карте. Каждое такое расхождение — это пуш, который нельзя удалить, или баг, который воспроизводится «иногда».
Гораздо надёжнее подход с детерминированным ID: не запоминать, а вычислять.
ВыделитьKotlin
object NotificationIds {
fun forMessage(thingId: Long, postId: Long): Int =
"msg_${thingId}_${postId}".hashCode()
}Функция превращает любые осмысленные данные — идентификатор чата, идентификатор сообщения — в стабильное число. Одна и та же строка всегда даёт один и тот же хеш, на любом устройстве и в любой момент времени. Теперь уведомление не нужно «находить» — его ID восстанавливается из тех же данных, из которых оно было создано. Хранить нечего, значит, и рассинхронизировать нечего. Это тот случай, когда лучший способ управлять состоянием — не иметь его.
Разумеется, у этого подхода есть ограничения, о которых стоит знать честно. Хеш-функция может дать коллизию: две разные строки теоретически дают одно число, и тогда одно уведомление затрёт другое. На практике для разумного количества уведомлений вероятность исчезающе мала, а если добавить в строку префикс по типу события (
msg_, summary_, call_), разные категории пушей оказываются в непересекающихся «областях» хеш-пространства.Теги: Когда одного ID мало
Детерминированный ID решает задачу «одно уведомление на одну сущность». Но в мессенджере хочется другого: каждое сообщение — отдельный пуш, который виден в шторке самостоятельно, а при открытии чата все его пуши удаляются разом. Для этого нужен тег.
ВыделитьKotlin
manager.notify(tag = chatId, id = messageId.hashCode(), notification)Уникальность теперь определяется парой. Все уведомления одного чата получают один и тот же тег и разные ID — они накапливаются, не заменяя друг друга. А удаление всей группы становится тривиальным, потому что список активных уведомлений система возвращает сама:
ВыделитьKotlin
fun cancelChatNotifications(chatId: String) {
val manager = NotificationManagerCompat.from(context)
manager.activeNotifications
.filter { it.tag == chatId }
.forEach { manager.cancel(it.tag, it.id) }
}Здесь происходит то, что мне кажется самым красивым в этой архитектуре: источником истины становится сама система. Не нужно поддерживать реестр «какие пуши я отправил» — вы просто спрашиваете у Android, что сейчас видно в шторке, и этот ответ всегда актуален. Приложение перезапустилось, обновилось, упало — состояние шторки от этого не меняется, и отмена продолжает работать.
Тонкость, о которой легко забыть: summary-уведомление группы должно получать тот же тег. Если вы используете группировку (о ней ниже) и отправляете сводное уведомление с отдельным заголовком, но без тега, то при очистке чата оно останется висеть в шторке одиноким артефактом — ведь фильтр по тегу его не найдёт. Одна строка в вызове notify экономит час отладки странного поведения.
Группировка
Когда уведомлений одного типа накапливается много, Android сам решает свернуть их в компактную группу. Поведение выглядит так: несколько сообщений подряд сначала показываются отдельными карточками, а через несколько минут система объединяет их в одну строку со сводкой. Время «склейки» определяется ОС и прошивкой, разработчик на него не влияет — и не должен.
Группу задаёт метод setGroup(key). Обычно удобно использовать в качестве ключа ту же строку, что и тег: они выполняют смежные роли — тег отвечает за уникальность и отслеживаемость, группа за визуальную свёртку в шторке. Ключ группы также определяет, какие уведомления «клеятся» друг с другом, поэтому вы сами выбираете границу: сворачивать ли все сообщения одного чата или, например, все уведомления приложения целиком.
Важный практический момент: пуш-сообщения с группой должны сопровождаться групповой сводкой (
setGroupSummary(true)). На старых версиях Android без неё группа ведёт себя непредсказуемо, а даже там, где система справляется сама, явная сводка даёт контроль над тем, что видит пользователь в свёрнутом виде — заголовок, количество, краткое содержание.PendingIntent: Маленький, но коварный компонент
Клик по уведомлению обрабатывается через
PendingIntent — «отложенный» интент, который вы передаёте системе, чтобы она выполнила его от вашего имени позже. И здесь есть ловушка, которую проходят почти все.У
PendingIntent.getActivity есть параметр requestCode. Если передать туда константу — например, ноль, — все ваши уведомления будут делить один и тот же PendingIntent. Последствие неочевидно: клик по любому пушу откроет данные последнего созданного. Пока вы кладёте в интент только идентификатор чата, это незаметно — чат один и тот же. Как только вы захотите открывать конкретное сообщение, всё сломается: какой бы пуш пользователь ни тапнул, откроется последний.Лечение простое:
requestCode должен быть уникальным для каждого уведомления. Детерминированный ID, который мы вычислили выше, подходит идеально — он и уникален, и стабилен.
ВыделитьKotlin
val pendingIntent = PendingIntent.getActivity(
context,
messageId.hashCode(), // уникален для каждого сообщения
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)FCM и разделение каналов доставки
Пока приложение работает на устройстве, всё, что мы обсудили, находится под вашим контролем. Но в реальном мессенджере сообщения приходят через Firebase Cloud Messaging — и вот тут начинается самое интересное.
FCM различает два типа полезной нагрузки. Если сообщение содержит блок notification, то Firebase сам показывает уведомление — но только когда приложение в фоне или закрыто. В этом случае onMessageReceived не вызывается вовсе: пуш рисуется системой, без вашего тега, без группы, без вашего channel. Когда приложение на переднем плане, тот же тип сообщения, наоборот, доставляется в сервис, и вы можете сделать с ним что угодно.
Получается раздвоение: один и тот же пуш в зависимости от состояния приложения создаётся либо вашим кодом, либо Firebase. И это не косметика — уведомления, созданные FCM, не имеют ваших тегов, а значит, ваша логика очистки по тегу их не найдёт. Пользователь открывает чат, а старые пуши остаются висеть.
Решение — перевести всю доставку на data-only сообщения: убрать блок
notification и передавать всё, включая заголовок и текст, в блоке data. Тогда onMessageReceived вызывается всегда, при любом состоянии приложения, и все уведомления проходят через один путь — ваш сервис, ваши теги, ваша группировка.Один нюанс, который нельзя пропустить: data-сообщения без флага приоритета могут задерживаться системой в фоне (Doze). Обязательно указывайте
"priority": "high" в запросе к FCM — это то, что заставляет систему разбудить процесс и доставить сообщение немедленно.И стоит помнить об ограничении, которое не обойти: если пользователь остановил приложение принудительно через настройки системы, Android блокирует доставку до следующего ручного запуска. Обычное смахивание из «недавних» этому не мешает, но полного контроля над чужим устройством у разработчика нет — и это правильно.
Лимиты и стили
Размер полезной нагрузки FCM — 4 КБ. Этого более чем достаточно для идентификаторов и текста, и категорически недостаточно для картинок: передача изображения в base64 в пуше — путь в никуда. Картинки передаются URL-ом, а в самом уведомлении показывается текст вида «Новое фото от Ани», а само изображение пользователь увидит, открыв чат. Именно так работают крупные мессенджеры.
К тому же уведомление в шторке — это не произвольная разметка. Есть заголовок и одна-две строки текста, есть «большой» стиль с одной картинкой (BigPictureStyle), причём картинка должна быть уже на устройстве — система за вас её не скачает. Для чатов есть MessagingStyle: свёртка сообщений с именами авторов и аватарками, которая на вытянутой шторке показывает историю переписки. Аватарки для него нужно загружать заранее, в кэш, а в пуш передавать только идентификатор.
Все эти ограничения не случайны: шторка уведомлений — системный интерфейс, а не веб-страница. Задача пуши — привлечь внимание и вернуть пользователя в приложение, а не заменить собой интерфейс целиком.
Каналы
С Android 8 каждое уведомление принадлежит каналу — категории, которую пользователь настраивает отдельно: важность, звук, вибрация, поведение на экране блокировки. Каналы создаются через
NotificationManager.createNotificationChannel, и делать это безопасно при каждом запуске: повторное создание канала с теми же параметрами система игнорирует. Но чище — один раз в Application.onCreate, чтобы и холодные старты, и фоновые пробуждения процесса всегда находили канал готовым.Архитектура целиком
Если собрать всё вместе, надёжная схема работы с уведомлениями выглядит так. Сервер отправляет data-only сообщения с высоким приоритетом. Клиентский сервис обрабатывает каждое сообщение по единому пути: вычисляет детерминированный ID из данных, присваивает тег по чату, группирует и отправляет. Очистка выполняется запросом активных уведомлений у системы и отменой всех с нужным тегом — сводка включена. Приложение не хранит ни одного соответствия «сущность — ID уведомления», поэтому ему не с чем рассинхронизироваться.
Всё состояние, которым вы управляете, живёт в двух местах: в данных сообщения (которые и так есть) и в шторке (которой управляет ОС и которая всегда говорит правду). Между этими двумя источниками истины — чистая функция, вычисляющая одно из другого. И это, пожалуй, главный урок, который даёт эта маленькая подсистема: чем меньше мест, где можно хранить состояние, тем меньше мест, где оно может сломаться.