Почему push-уведомления — это must-have для мобильного приложения
Давайте начистоту: мобильное приложение без push-уведомлений — как магазин без вывески. Пользователь установил ваше приложение, открыл пару раз и… забыл. По данным за 2026 год, приложения с грамотно настроенными пушами удерживают на 88% больше пользователей через 30 дней после установки. Впечатляет, правда?
Но вот в чём дело — реализация push-уведомлений в React Native это не просто «подключи библиотеку и отправь сообщение». Тут нужно разобраться с FCM v1 API для Android, APNs для iOS, каналами уведомлений, фоновой обработкой, навигацией по нажатию — и всё это должно работать одинаково надёжно на обеих платформах.
Честно говоря, когда я впервые настраивал пуши в Expo-проекте, потратил добрых два дня на то, чтобы всё завелось. Чтобы вы этого избежали, в этом руководстве мы пройдём весь путь от нуля до production-готовой системы push-уведомлений с помощью expo-notifications, Firebase Cloud Messaging v1 и Expo Router. Все примеры кода актуальны для Expo SDK 52+ и проверены на реальных устройствах.
Архитектура push-уведомлений в React Native
Прежде чем лезть в код, давайте разберёмся, как push-уведомления вообще путешествуют от вашего сервера до экрана пользователя. Схема на самом деле простая, но с нюансами:
- Ваш бэкенд формирует payload уведомления и отправляет его посреднику.
- Посредник — это либо Expo Push Service, либо напрямую FCM/APNs — маршрутизирует сообщение на конкретное устройство.
- ОС устройства (Android или iOS) получает сообщение и решает, как его отобразить — в зависимости от того, на переднем плане приложение или нет.
- Ваше приложение обрабатывает полученное уведомление и выполняет нужное действие.
Expo Push Service vs. прямая отправка через FCM/APNs
У вас есть два пути:
- Expo Push Service — выступает прослойкой между вашим сервером и FCM/APNs. Вы отправляете единый запрос на
https://exp.host/--/api/v2/push/send, а Expo сам маршрутизирует сообщение на нужную платформу. Идеально для быстрого старта и проектов среднего масштаба. - Прямая отправка — ваш бэкенд общается напрямую с FCM v1 API и APNs. Контроля больше, гибкость в payload выше, но и кода на сервере больше. Подходит для enterprise-приложений и сценариев с кастомной аналитикой.
Хорошая новость: expo-notifications прекрасно работает с обоими подходами. Библиотека не привязывает вас к Expo Push Service — вы спокойно можете получать уведомления, отправленные напрямую через FCM или APNs.
Установка и базовая настройка expo-notifications
Ну что, начнём с установки. Нам понадобится сама библиотека уведомлений плюс пара вспомогательных модулей:
npx expo install expo-notifications expo-device expo-constants
Каждый пакет выполняет свою роль:
expo-notifications— основная библиотека для работы с уведомлениями (отправка, получение, обработка).expo-device— определяет тип устройства. Push-уведомления работают только на физических устройствах, и этот модуль помогает проверить это перед запросом токена.expo-constants— даёт доступ к конфигурации проекта, включаяprojectId, который необходим для генерации Expo Push Token.
Настройка плагина в app.config.js
Добавьте конфигурацию плагина в ваш app.config.js (или app.json, кому как удобнее):
// app.config.js
export default {
expo: {
// ... другие настройки
plugins: [
[
"expo-notifications",
{
icon: "./assets/notification-icon.png",
color: "#ffffff",
defaultChannel: "default",
enableBackgroundRemoteNotifications: true,
},
],
],
android: {
googleServicesFile: "./google-services.json",
},
ios: {
entitlements: {
"aps-environment": "production",
},
infoPlist: {
UIBackgroundModes: ["remote-notification", "fetch"],
},
},
},
};
Обратите внимание на параметр icon для Android: иконка уведомления должна быть полностью белой на прозрачном фоне — это требование Google. Если используете цветную иконку, на некоторых устройствах вместо неё покажется белый квадрат (проходили, знаем).
Настройка Firebase Cloud Messaging (FCM v1) для Android
Android-уведомления работают через Firebase Cloud Messaging. С июля 2024 года Google полностью отключил устаревший FCM Legacy API, и единственный поддерживаемый протокол — FCM v1. Если вы видели старые туториалы с серверным ключом (Server Key) — можете смело закрывать их. FCM v1 использует OAuth 2.0 авторизацию через сервисный аккаунт.
Шаг 1: Создайте проект в Firebase Console
- Перейдите в Firebase Console и создайте новый проект (или используйте существующий).
- Добавьте Android-приложение с package name, совпадающим с вашим
android.packageизapp.config.js. - Скачайте файл
google-services.jsonи поместите его в корень вашего Expo-проекта.
Важно: файл google-services.json содержит конфиденциальные данные. Добавьте его в .gitignore и никогда не коммитьте в публичный репозиторий. Для CI/CD используйте секреты окружения или EAS Secrets.
Шаг 2: Загрузите ключ сервисного аккаунта в EAS
Чтобы Expo Push Service мог отправлять уведомления через FCM v1, нужно загрузить ключ сервисного аккаунта:
- В Firebase Console перейдите в Project Settings → Service Accounts.
- Нажмите Generate new private key и скачайте JSON-файл.
- Перейдите на expo.dev → ваш проект → Credentials → Android → FCM V1 service account key.
- Загрузите скачанный JSON-файл.
Или через CLI, если предпочитаете терминал:
eas credentials
# Выберите: Android → production → Google Service Account
# → Manage your Google Service Account Key for Push Notifications (FCM V1)
Шаг 3: Проверьте конфигурацию
После настройки пересоберите проект через EAS Build:
eas build --platform android --profile development
Это важный момент — google-services.json встраивается в нативный бинарник на этапе сборки. Обновления через expo start без пересборки не подхватят изменения Firebase-конфигурации. Я сам на этом однажды застрял на полдня, пока не догадался пересобрать.
Настройка Apple Push Notification Service (APNs) для iOS
На iOS push-уведомления проходят через Apple Push Notification Service. Для работы с ним понадобится платный аккаунт Apple Developer Program (99$ в год — увы, без этого никак).
Генерация APNs-ключа
EAS CLI может автоматически сгенерировать и управлять APNs-ключами за вас. При первом запуске eas build для iOS вас спросят, хотите ли вы включить push-уведомления — просто ответьте «да».
Если предпочитаете ручную настройку:
- Перейдите в Apple Developer Portal → Keys.
- Создайте новый ключ с включённой опцией Apple Push Notifications service (APNs).
- Скачайте
.p8-файл (он скачивается только один раз — сохраните его в надёжном месте!). - Загрузите ключ через
eas credentialsили на expo.dev.
Включение Push Notification capability
При использовании EAS Build и expo-notifications capability добавляется автоматически через конфигурацию в app.config.js. Если вы работаете с bare-проектом, откройте Xcode, перейдите в Signing & Capabilities и добавьте Push Notifications вручную.
Запрос разрешений и получение Push-токена
Теперь — к самому интересному. Переходим к коду. Создадим утилиту для регистрации устройства и получения push-токена:
// src/utils/notifications.ts
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { Platform } from "react-native";
// Настраиваем поведение уведомлений на переднем плане
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export async function registerForPushNotifications(): Promise<string | null> {
// Push-уведомления работают только на физических устройствах
if (!Device.isDevice) {
console.warn("Push-уведомления не работают на эмуляторах/симуляторах");
return null;
}
// Проверяем текущие разрешения
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Запрашиваем разрешения, если ещё не выданы
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
console.warn("Разрешение на push-уведомления не получено");
return null;
}
// Настраиваем канал уведомлений для Android
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "Основной канал",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
sound: "default",
});
}
// Получаем Expo Push Token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) {
throw new Error("Не найден projectId. Проверьте конфигурацию EAS.");
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
console.log("Expo Push Token:", tokenData.data);
return tokenData.data;
}
Тут есть один важный момент — setNotificationHandler. Без этого вызова уведомления, пришедшие когда приложение на переднем плане, просто не будут отображаться. Это, пожалуй, самая частая ошибка при первой настройке. Если видите, что уведомления приходят только в фоне — первым делом проверьте, вызвали ли вы setNotificationHandler в корне приложения.
Каналы уведомлений на Android
Начиная с Android 8.0 (API 26), каждое уведомление должно быть привязано к каналу (notification channel). Каналы дают пользователю контроль над типами уведомлений — например, отключить маркетинговые, но оставить транзакционные. Удобная штука, хоть и добавляет работы разработчику.
// Создание нескольких каналов уведомлений
async function setupNotificationChannels() {
if (Platform.OS !== "android") return;
// Канал для заказов
await Notifications.setNotificationChannelAsync("orders", {
name: "Заказы",
description: "Обновления статуса заказов",
importance: Notifications.AndroidImportance.HIGH,
sound: "default",
enableVibrate: true,
});
// Канал для чата
await Notifications.setNotificationChannelAsync("chat", {
name: "Сообщения",
description: "Новые сообщения в чате",
importance: Notifications.AndroidImportance.MAX,
sound: "message.wav",
enableVibrate: true,
});
// Канал для маркетинга (менее приоритетный)
await Notifications.setNotificationChannelAsync("promo", {
name: "Акции и предложения",
description: "Специальные предложения и скидки",
importance: Notifications.AndroidImportance.LOW,
sound: null,
enableVibrate: false,
});
}
Пользователь может зайти в настройки приложения на устройстве и отключить любой канал. Поэтому продумайте структуру каналов заранее — после создания изменить importance канала программно нельзя. Единственный выход — удалить канал и создать новый с другим ID (что, мягко говоря, не идеально).
Указание канала при отправке
При отправке уведомления через Expo Push API укажите channelId:
{
"to": "ExponentPushToken[xxxxxxx]",
"title": "Ваш заказ отправлен",
"body": "Заказ #12345 уже в пути!",
"channelId": "orders",
"data": { "orderId": "12345", "screen": "OrderDetails" }
}
Обработка уведомлений: передний план, фон и закрытое приложение
Приложение может находиться в трёх состояниях при получении уведомления, и поведение в каждом случае отличается. Разберём все три.
Передний план (foreground)
Приложение активно и на экране. Уведомление будет отображаться только если вы настроили setNotificationHandler (как мы сделали выше). Плюс можно добавить слушатель для кастомной обработки:
// app/_layout.tsx
import { useEffect, useRef } from "react";
import * as Notifications from "expo-notifications";
import { registerForPushNotifications } from "@/utils/notifications";
export default function RootLayout() {
const notificationListener = useRef<Notifications.EventSubscription>();
const responseListener = useRef<Notifications.EventSubscription>();
useEffect(() => {
// Регистрируем устройство
registerForPushNotifications().then((token) => {
if (token) {
// Отправляем токен на свой сервер
sendTokenToBackend(token);
}
});
// Слушатель: уведомление получено (приложение на переднем плане)
notificationListener.current =
Notifications.addNotificationReceivedListener((notification) => {
const data = notification.request.content.data;
console.log("Уведомление получено:", data);
// Можно обновить UI, показать in-app toast и т.д.
});
// Слушатель: пользователь нажал на уведомление
responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data;
handleNotificationNavigation(data);
});
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(
notificationListener.current
);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, []);
return (/* ваш layout */);
}
Фон (background)
Приложение свёрнуто, но не убито. ОС автоматически покажет уведомление в системном трее. При нажатии сработает addNotificationResponseReceivedListener. Тут всё просто.
Закрытое приложение (killed/quit)
А вот тут интереснее. Приложение не запущено вообще. ОС отображает уведомление, и при нажатии приложение запустится с нуля. Получить данные начального уведомления можно так:
// Получение уведомления, которое запустило приложение
const lastNotification = await Notifications.getLastNotificationResponseAsync();
if (lastNotification) {
const data = lastNotification.notification.request.content.data;
handleNotificationNavigation(data);
}
Вызывайте getLastNotificationResponseAsync в корневом layout при инициализации. Это критически важно — если пользователь нажал на уведомление, которое запустило приложение, слушатель addNotificationResponseReceivedListener может не успеть подписаться до того, как событие произошло.
Глубокие ссылки (deep linking): навигация по нажатию на уведомление
Получать уведомления — это полдела. Настоящая ценность — привести пользователя на нужный экран при нажатии. Вот как реализовать навигацию из уведомлений с Expo Router:
// src/utils/notificationNavigation.ts
import { router } from "expo-router";
interface NotificationData {
screen?: string;
orderId?: string;
chatId?: string;
articleId?: string;
[key: string]: unknown;
}
export function handleNotificationNavigation(data: NotificationData) {
if (!data?.screen) return;
switch (data.screen) {
case "OrderDetails":
if (data.orderId) {
router.push(`/orders/${data.orderId}`);
}
break;
case "Chat":
if (data.chatId) {
router.push(`/chat/${data.chatId}`);
}
break;
case "Article":
if (data.articleId) {
router.push(`/articles/${data.articleId}`);
}
break;
default:
// Открываем главный экран, если маршрут не распознан
router.push("/");
}
}
Мой совет — структурируйте поле data в payload уведомления заранее. Всегда включайте поле screen (или url) — это позволит единообразно обрабатывать навигацию без необходимости переписывать клиентский код при добавлении новых типов уведомлений.
Навигация при запуске приложения из уведомления
Объединим получение начального уведомления и подписку на последующие в один хук:
// hooks/useNotificationObserver.ts
import { useEffect } from "react";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { handleNotificationNavigation } from "@/utils/notificationNavigation";
export function useNotificationObserver() {
useEffect(() => {
// Обрабатываем уведомление, которое запустило приложение
Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
const data = response.notification.request.content.data;
// Небольшая задержка, чтобы навигация успела инициализироваться
setTimeout(() => handleNotificationNavigation(data), 500);
}
});
// Слушаем нажатия на уведомления в реальном времени
const subscription =
Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data;
handleNotificationNavigation(data);
});
return () => subscription.remove();
}, []);
}
Да, setTimeout тут выглядит как костыль, но на самом деле это необходимость. Expo Router нуждается в небольшом времени для инициализации дерева навигации после холодного старта. Без задержки router.push может выполниться до того, как навигатор будет готов, и ничего не произойдёт.
Отправка уведомлений с сервера
Переходим к серверной части. Ваш бэкенд должен хранить push-токены пользователей и уметь отправлять уведомления.
Через Expo Push Service (рекомендуется для старта)
Expo предоставляет простой REST API для отправки:
// server/sendNotification.ts (Node.js)
interface ExpoPushMessage {
to: string | string[];
title: string;
body: string;
data?: Record<string, unknown>;
sound?: "default" | null;
badge?: number;
channelId?: string;
priority?: "default" | "normal" | "high";
ttl?: number;
}
async function sendPushNotification(messages: ExpoPushMessage[]) {
// Expo рекомендует отправлять не более 100 уведомлений за запрос
const chunks = chunkArray(messages, 100);
for (const chunk of chunks) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
// Для продакшена добавьте Expo Access Token
Authorization: "Bearer YOUR_EXPO_ACCESS_TOKEN",
},
body: JSON.stringify(chunk),
});
const result = await response.json();
// Обработайте ошибки для отдельных токенов
if (result.data) {
result.data.forEach((ticket: any, index: number) => {
if (ticket.status === "error") {
if (ticket.details?.error === "DeviceNotRegistered") {
// Удалите этот токен из базы данных
removeInvalidToken(chunk[index].to as string);
}
}
});
}
}
}
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
Через FCM v1 API напрямую
Если нужен полный контроль — отправляйте напрямую через FCM v1. Для этого потребуется OAuth 2.0 авторизация:
// server/sendFCMv1.ts (Node.js)
import { GoogleAuth } from "google-auth-library";
const FCM_PROJECT = "your-firebase-project-id";
const FCM_ENDPOINT = `https://fcm.googleapis.com/v1/projects/${FCM_PROJECT}/messages:send`;
async function getAccessToken(): Promise<string> {
const auth = new GoogleAuth({
keyFilename: "./service-account-key.json",
scopes: ["https://www.googleapis.com/auth/firebase.messaging"],
});
const client = await auth.getClient();
const token = await client.getAccessToken();
return token.token!;
}
async function sendFCMv1Notification(
deviceToken: string,
title: string,
body: string,
data?: Record<string, string>
) {
const accessToken = await getAccessToken();
const message = {
message: {
token: deviceToken,
notification: { title, body },
data: data || {},
android: {
priority: "high",
notification: {
channelId: "default",
sound: "default",
clickAction: "OPEN_ACTIVITY",
},
},
apns: {
payload: {
aps: {
sound: "default",
badge: 1,
"content-available": 1,
},
},
},
},
};
const response = await fetch(FCM_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(message),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Ошибка FCM: ${JSON.stringify(error)}`);
}
return response.json();
}
При прямой отправке через FCM v1 вам нужен нативный токен устройства, а не Expo Push Token. Получить его можно вот так:
// Получаем нативный device push token (для прямой отправки через FCM/APNs)
const nativeToken = await Notifications.getDevicePushTokenAsync();
console.log("Native device token:", nativeToken.data);
Управление жизненным циклом токенов
Push-токены — не вечные. Они могут стать невалидными, если пользователь переустановит приложение, сбросит устройство или просто не открывал его долгое время. Грамотное управление токенами — залог надёжной доставки уведомлений.
Обновление токена при изменении
// В корневом layout или инициализации приложения
useEffect(() => {
// Подписываемся на обновления токена
const tokenSubscription = Notifications.addPushTokenListener((newToken) => {
console.log("Токен обновлён:", newToken.data);
// Обновляем токен на бэкенде
updateTokenOnBackend(newToken.data);
});
return () => tokenSubscription.remove();
}, []);
Обработка невалидных токенов на сервере
При получении ответа от Expo Push Service или FCM обязательно проверяйте ошибки:
DeviceNotRegistered— токен больше не валиден. Удалите его из базы данных.MessageTooBig— payload превышает лимит (4 КБ для FCM, 4 КБ для APNs). Уменьшите объём данных.InvalidCredentials— проблема с ключами FCM/APNs. Проверьте конфигурацию в EAS.
Рекомендую настроить еженедельную задачу на бэкенде, которая отправляет «тихие» (silent) уведомления всем токенам и удаляет невалидные. Так вы предотвратите накопление мёртвых токенов и ускорите массовую рассылку.
Интерактивные уведомления: категории и действия
Вот это реально крутая штука — пользователь может взаимодействовать с уведомлением прямо из шторки, не открывая приложение. Для этого используются категории уведомлений с кнопками действий:
// Настройка категорий при инициализации приложения
await Notifications.setNotificationCategoryAsync("message", [
{
identifier: "reply",
buttonTitle: "Ответить",
options: {
opensAppToForeground: false,
},
textInput: {
submitButtonTitle: "Отправить",
placeholder: "Введите ответ...",
},
},
{
identifier: "markRead",
buttonTitle: "Прочитано",
options: {
opensAppToForeground: false,
},
},
]);
await Notifications.setNotificationCategoryAsync("order", [
{
identifier: "track",
buttonTitle: "Отследить",
options: {
opensAppToForeground: true,
},
},
{
identifier: "details",
buttonTitle: "Подробнее",
options: {
opensAppToForeground: true,
},
},
]);
При отправке уведомления укажите categoryIdentifier в payload:
{
"to": "ExponentPushToken[xxxxxxx]",
"title": "Новое сообщение от Алексея",
"body": "Привет! Как дела с проектом?",
"categoryId": "message",
"data": { "chatId": "456", "screen": "Chat" }
}
А обработка действий на клиенте выглядит так:
Notifications.addNotificationResponseReceivedListener((response) => {
const actionId = response.actionIdentifier;
const data = response.notification.request.content.data;
switch (actionId) {
case "reply":
// Пользователь ввёл текст ответа
const userInput = response.userText;
if (userInput && data.chatId) {
sendReplyToChat(data.chatId, userInput);
}
break;
case "markRead":
if (data.chatId) {
markChatAsRead(data.chatId);
}
break;
case "track":
if (data.orderId) {
router.push(`/orders/${data.orderId}/tracking`);
}
break;
case Notifications.DEFAULT_ACTION_IDENTIFIER:
// Пользователь просто нажал на уведомление
handleNotificationNavigation(data);
break;
}
});
Локальные уведомления и планирование
Не все уведомления нужно отправлять с сервера. Локальные уведомления планируются прямо на устройстве — и это идеально для напоминаний, таймеров и событий, которые не зависят от бэкенда:
// Немедленное локальное уведомление
await Notifications.scheduleNotificationAsync({
content: {
title: "Перерыв!",
body: "Вы работаете уже 2 часа. Время размяться.",
sound: "default",
data: { screen: "Timer" },
},
trigger: null, // null = показать немедленно
});
// Запланированное уведомление (через 30 минут)
await Notifications.scheduleNotificationAsync({
content: {
title: "Напоминание",
body: "Не забудьте завершить задачу",
sound: "default",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 1800,
repeats: false,
},
});
// Ежедневное уведомление в определённое время
await Notifications.scheduleNotificationAsync({
content: {
title: "Доброе утро!",
body: "Посмотрите, что нового в вашей ленте",
sound: "default",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour: 9,
minute: 0,
},
});
// Получить список запланированных уведомлений
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
console.log("Запланировано уведомлений:", scheduled.length);
// Отменить все запланированные уведомления
await Notifications.cancelAllScheduledNotificationsAsync();
На Android 12+ для планирования уведомлений в точное время требуется разрешение SCHEDULE_EXACT_ALARM. Добавьте его в конфигурацию плагина:
// app.config.js
plugins: [
[
"expo-notifications",
{
icon: "./assets/notification-icon.png",
color: "#ffffff",
enableBackgroundRemoteNotifications: true,
androidPermissions: ["SCHEDULE_EXACT_ALARM"],
},
],
],
Управление бейджем приложения
Бейдж — это тот самый красный кружок с числом на иконке приложения. На iOS управление бейджем простое и предсказуемое, а вот на Android поддержка зависит от лаунчера:
// Установить количество непрочитанных
await Notifications.setBadgeCountAsync(5);
// Получить текущее значение
const count = await Notifications.getBadgeCountAsync();
// Сбросить бейдж (например, при открытии приложения)
await Notifications.setBadgeCountAsync(0);
Хорошая практика — сбрасывать бейдж при каждом открытии приложения в корневом useEffect. Иначе пользователь будет видеть устаревшее число, даже если давно прочитал все уведомления. Мелочь, а раздражает.
Тестирование и отладка push-уведомлений
Тестирование push-уведомлений — процесс, который не получится полностью автоматизировать. Вот проверенные подходы:
Инструмент тестирования Expo
Самый быстрый способ проверить работу — отправить тестовое уведомление через Expo Push Notifications Tool на сайте expo.dev. Просто вводите ваш Expo Push Token, заполняете заголовок и тело — и жмёте «Send». Буквально 30 секунд.
curl-запрос для тестирования
curl -X POST https://exp.host/--/api/v2/push/send \
-H "Content-Type: application/json" \
-d '{
"to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
"title": "Тестовое уведомление",
"body": "Если вы это видите — всё работает!",
"sound": "default",
"data": { "screen": "Home" }
}'
Частые проблемы и решения
- Уведомления не приходят на Android: убедитесь, что
google-services.jsonкорректен и приложение пересобрано черезeas build. Проверьте, что FCM v1 ключ загружен на expo.dev. - Уведомления не приходят на iOS: проверьте, что APNs-ключ сконфигурирован и
aps-environmentсоответствует типу сборки (development/production). - Уведомления не отображаются на переднем плане: убедитесь, что вызвали
setNotificationHandlerсshouldShowAlert: true. - Навигация не работает при нажатии: проверьте, что обрабатываете
getLastNotificationResponseAsyncдля холодного старта. - Ошибка «DeviceNotRegistered»: токен устарел. Удалите его из базы и запросите новый.
- На эмуляторе ничего не работает: это нормально — push-уведомления не поддерживаются на эмуляторах и симуляторах. Тестируйте только на реальном устройстве.
Лучшие практики для продакшена
Чтобы система уведомлений работала надёжно в проде, держите в голове эти рекомендации:
- Не спамьте. Отправляйте только релевантные уведомления. Агрессивная рассылка — причина номер один, по которой пользователи отключают пуши или вовсе удаляют приложение.
- Разделяйте каналы. Дайте пользователю выбирать, какие типы уведомлений он хочет получать.
- Персонализируйте. Уведомление «Алексей, ваш заказ доставлен» работает в 4 раза лучше, чем безликое «Заказ доставлен».
- Учитывайте часовые пояса. Маркетинговые уведомления отправляйте в дневное время местного часового пояса пользователя. Никто не любит пуши в 3 часа ночи.
- Храните несколько токенов. У одного пользователя может быть несколько устройств — сохраняйте и Expo Push Token, и нативный device token для каждого.
- Используйте TTL. Устанавливайте
ttl(time-to-live) для уведомлений, которые теряют актуальность. Если устройство оффлайн дольше TTL — уведомление не будет доставлено, и это правильное поведение. - Мониторьте доставку. Обрабатывайте receipt-ы от Expo Push API, чтобы отслеживать процент доставки и быстро реагировать на проблемы.
Часто задаваемые вопросы
Можно ли тестировать push-уведомления на эмуляторе или симуляторе?
Нет. Push-уведомления не работают на Android Emulator и iOS Simulator. Тестировать нужно на физическом устройстве. Используйте development build через eas build --profile development, установите его на реальный телефон и отправляйте тестовые пуши через Expo Push Notifications Tool или curl.
Чем Expo Push Token отличается от нативного device token?
Expo Push Token (ExponentPushToken[xxx]) — это обёртка Expo для отправки через Expo Push Service. Нативный device token — это FCM Registration Token (Android) или APNs Device Token (iOS), который нужен при прямой отправке через FCM v1 или APNs. Библиотека expo-notifications позволяет получить оба: getExpoPushTokenAsync() для Expo-токена и getDevicePushTokenAsync() для нативного.
FCM Legacy API отключён — как перейти на FCM v1?
Google отключил FCM Legacy API в июле 2024 года. Для миграции: создайте сервисный аккаунт в Firebase Console, скачайте JSON-ключ и загрузите его в EAS через eas credentials или на expo.dev. На серверной стороне замените отправку по серверному ключу на OAuth 2.0 авторизацию через google-auth-library. Эндпоинт изменился на https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send.
Как обрабатывать нажатие на уведомление, если приложение было закрыто?
Используйте Notifications.getLastNotificationResponseAsync() при инициализации приложения в корневом layout. Этот метод вернёт данные последнего уведомления, на которое нажал пользователь. Вызывайте его в useEffect с небольшой задержкой (около 500 мс), чтобы навигатор успел инициализироваться.
Сколько уведомлений можно отправлять через Expo Push Service бесплатно?
Expo Push Service бесплатен и не имеет жёсткого лимита на количество уведомлений. Но Expo рекомендует отправлять не более 600 уведомлений в минуту и пакетами по 100 штук за один HTTP-запрос. Для массовых рассылок организуйте очередь на бэкенде и разбивайте отправку на чанки.