Push-уведомления в React Native 2026: Expo Notifications, FCM v1 и глубокие ссылки

Настраиваем push-уведомления в React Native с нуля: expo-notifications, FCM v1 API, APNs, каналы Android, deep linking и отправка с сервера. Рабочие примеры для Expo SDK 52+.

Почему 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-уведомления вообще путешествуют от вашего сервера до экрана пользователя. Схема на самом деле простая, но с нюансами:

  1. Ваш бэкенд формирует payload уведомления и отправляет его посреднику.
  2. Посредник — это либо Expo Push Service, либо напрямую FCM/APNs — маршрутизирует сообщение на конкретное устройство.
  3. ОС устройства (Android или iOS) получает сообщение и решает, как его отобразить — в зависимости от того, на переднем плане приложение или нет.
  4. Ваше приложение обрабатывает полученное уведомление и выполняет нужное действие.

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

  1. Перейдите в Firebase Console и создайте новый проект (или используйте существующий).
  2. Добавьте Android-приложение с package name, совпадающим с вашим android.package из app.config.js.
  3. Скачайте файл google-services.json и поместите его в корень вашего Expo-проекта.

Важно: файл google-services.json содержит конфиденциальные данные. Добавьте его в .gitignore и никогда не коммитьте в публичный репозиторий. Для CI/CD используйте секреты окружения или EAS Secrets.

Шаг 2: Загрузите ключ сервисного аккаунта в EAS

Чтобы Expo Push Service мог отправлять уведомления через FCM v1, нужно загрузить ключ сервисного аккаунта:

  1. В Firebase Console перейдите в Project Settings → Service Accounts.
  2. Нажмите Generate new private key и скачайте JSON-файл.
  3. Перейдите на expo.dev → ваш проект → Credentials → Android → FCM V1 service account key.
  4. Загрузите скачанный 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-уведомления — просто ответьте «да».

Если предпочитаете ручную настройку:

  1. Перейдите в Apple Developer Portal → Keys.
  2. Создайте новый ключ с включённой опцией Apple Push Notifications service (APNs).
  3. Скачайте .p8-файл (он скачивается только один раз — сохраните его в надёжном месте!).
  4. Загрузите ключ через 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-уведомления не поддерживаются на эмуляторах и симуляторах. Тестируйте только на реальном устройстве.

Лучшие практики для продакшена

Чтобы система уведомлений работала надёжно в проде, держите в голове эти рекомендации:

  1. Не спамьте. Отправляйте только релевантные уведомления. Агрессивная рассылка — причина номер один, по которой пользователи отключают пуши или вовсе удаляют приложение.
  2. Разделяйте каналы. Дайте пользователю выбирать, какие типы уведомлений он хочет получать.
  3. Персонализируйте. Уведомление «Алексей, ваш заказ доставлен» работает в 4 раза лучше, чем безликое «Заказ доставлен».
  4. Учитывайте часовые пояса. Маркетинговые уведомления отправляйте в дневное время местного часового пояса пользователя. Никто не любит пуши в 3 часа ночи.
  5. Храните несколько токенов. У одного пользователя может быть несколько устройств — сохраняйте и Expo Push Token, и нативный device token для каждого.
  6. Используйте TTL. Устанавливайте ttl (time-to-live) для уведомлений, которые теряют актуальность. Если устройство оффлайн дольше TTL — уведомление не будет доставлено, и это правильное поведение.
  7. Мониторьте доставку. Обрабатывайте 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-запрос. Для массовых рассылок организуйте очередь на бэкенде и разбивайте отправку на чанки.

Об авторе Editorial Team

Our team of expert writers and editors.