Expo 推送通知完全指南:expo-notifications、FCM v1 与后端发送实战(2026)

从 Expo push token 到 FCM v1 与 APNs 后端发送的一站式实战:包含 iOS/Android 权限、通道、深度链接跳转与常见收不到推送的排查清单,代码可直接复制运行。

Expo 推送通知完全指南 2026

更新时间:2026 年 9 月 17 日

在 Expo 中集成推送通知的最短路径是:安装 expo-notifications、在 EAS 项目中配置 FCM v1 服务账号和 APNs 密钥、在应用启动时请求权限并获取 Expo push token、把 token 存入后端,然后通过 Expo Push API 或者直接调用 FCM v1 / APNs 发送消息。本文以 Expo SDK 54 + React Native 0.81 为基准,从零覆盖 iOS 和 Android 两端的推送通知集成、FCM v1 迁移、后端发送、深度链接跳转与常见故障排查,代码全部可直接复制运行。

  • FCM legacy HTTP 和 XMPP API 已于 2024 年 6 月正式停用,2026 年新项目必须使用 FCM v1 REST API 并通过 OAuth 2.0 服务账号鉴权。
  • Expo push token(以 ExponentPushToken[...] 开头)是对 FCM/APNs token 的封装,走 Expo 中转服务;如果不想依赖第三方,也可以直接读取原生 devicePushToken。
  • iOS 上推送必须在真机测试,配置需要 APNs Key(p8 文件)和 Bundle ID;Android 上从 SDK 53 起 EAS Build 会自动读取 google-services.json。
  • 后台/关闭态推送在 Android 必须通过 NotificationChannel 声明通道,否则 Android 8.0+ 完全不显示。
  • 点击通知跳转依赖 data 字段搭配 Expo Router 的 router.push(),冷启动时用 getLastNotificationResponseAsync 读初始消息。
  • 生产环境务必区分 sandbox 与 production 的 APNs 环境,构建配置错误是 iOS 收不到推送最常见的原因。

2026 年 Expo 推送通知的现状与选型

2024 年 6 月 Google 关停了 FCM 的 legacy HTTP / XMPP 接口,所有仍在使用 fcm.googleapis.com/fcm/send 端点的服务端代码在那之后都会返回 401。到 2026 年,唯一可用的 Firebase Cloud Messaging 通道是 FCM v1 REST API,鉴权改为通过服务账号 JSON 文件签发的 OAuth 2.0 access token。Expo 团队相应地在 EAS 项目管理页新增了「FCM V1 service account」上传入口,Expo Push Service 会替开发者完成签名、缓存与刷新。

选型上有两条路径。第一条是继续使用 Expo Push Service:应用侧只保留一个统一的 ExponentPushToken,后端调用 https://exp.host/--/api/v2/push/send,Expo 内部再分发到 APNs 和 FCM。它的优势是跨平台一致、消息发送速率有内置限流、还能拿到 receipts 用于统计送达率。第二条是 bare workflow 或对合规有要求的团队直接调用 FCM v1 与 APNs,跳过 Expo 中转。对绝大多数 Managed workflow 项目,推荐第一条;本文默认走 Expo Push Service,同时给出直接调 FCM v1 的对照示例。

说句实在话,我第一次做迁移那会儿被 401 折腾了整整一晚,最后才反应过来是端点变了。如果你还在用 React Native DevTools 与 Hermes 做本地调试,通知功能在 Hermes 下没有兼容性差异,仍然按标准 JS 事件订阅即可;但要注意,Hermes 的 crash 日志不会写入通知回调,需要靠 Sentry 之类的工具补齐链路。

安装与项目配置:expo-notifications 与 EAS

新项目建议直接从 Expo SDK 54 起步(发布于 2026 年 9 月)。expo-notifications 是官方维护的通知库,包含前台/后台/关闭态三种运行状态下的完整回调,也是本文所有代码示例的基础。

# 安装 SDK 与运行时依赖
npx expo install expo-notifications expo-device expo-constants

# 生成或更新 EAS 项目
npx eas init
npx eas build:configure

在 app.json(或 app.config.ts)里声明插件,让 prebuild 阶段能修改原生工程:

{
  "expo": {
    "name": "relay-demo",
    "slug": "relay-demo",
    "ios": {
      "bundleIdentifier": "com.example.relay",
      "supportsTablet": true,
      "usesAppleSignIn": false,
      "infoPlist": {
        "UIBackgroundModes": ["remote-notification"]
      }
    },
    "android": {
      "package": "com.example.relay",
      "googleServicesFile": "./google-services.json",
      "useNextNotificationsApi": true
    },
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#0F766E",
          "defaultChannel": "default",
          "sounds": ["./assets/notification.wav"]
        }
      ]
    ]
  }
}

UIBackgroundModes: ["remote-notification"] 是 iOS 静默推送必需的能力声明,缺失时 APNs 侧仍会送达但应用不会被唤醒。Android 的 useNextNotificationsApi 从 SDK 51 开始默认开启,但显式写出可以避免 expo-doctor 报警。图标必须是 96×96 白色透明 PNG,否则 Android 12+ 会显示为白色方块。

请求权限并获取 push token

iOS 从系统 13 起要求前台请求,Android 13(API 33)也在 2023 年跟进了运行时通知权限(POST_NOTIFICATIONS)。expo-notifications 已封装两端差异,直接调用 requestPermissionsAsync() 即可弹出系统 alert。以下是一个可复用的注册 hook:

// hooks/usePushToken.ts
import { useEffect, useState } from 'react';
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 () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export function usePushToken() {
  const [token, setToken] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    (async () => {
      if (!Device.isDevice) {
        setError('推送通知必须在真机上测试');
        return;
      }

      // Android 13+ 需要 POST_NOTIFICATIONS 权限
      if (Platform.OS === 'android') {
        await Notifications.setNotificationChannelAsync('default', {
          name: 'default',
          importance: Notifications.AndroidImportance.MAX,
          vibrationPattern: [0, 250, 250, 250],
          lightColor: '#0F766E',
        });
      }

      const { status: existingStatus } = await Notifications.getPermissionsAsync();
      let finalStatus = existingStatus;
      if (existingStatus !== 'granted') {
        const { status } = await Notifications.requestPermissionsAsync();
        finalStatus = status;
      }
      if (finalStatus !== 'granted') {
        setError('用户拒绝了通知权限');
        return;
      }

      const projectId = Constants.expoConfig?.extra?.eas?.projectId;
      const pushToken = await Notifications.getExpoPushTokenAsync({ projectId });
      setToken(pushToken.data);
    })().catch((e) => setError(String(e)));
  }, []);

  return { token, error };
}

获得的 ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx] 是设备 + 应用 + 环境的联合标识:同一台设备重装应用会得到新 token,切换 development / production build 也会变。要把 token 存到后端时,建议同时写入 deviceId、userId、platform 与 appVersion 四个维度,方便后续按用户、按机型、按版本做灰度和撤销。

Android 端 FCM v1 完整配置

Android 上推送通道由 Firebase Cloud Messaging 提供。2024 年 6 月之后所有旧版 server key(形如 AAAA...)都失效了;2026 年可用的鉴权方式只有服务账号 JSON。完整配置流程如下:

  1. 在 Firebase Console 创建或选择项目,Add App → Android,填入 com.example.relay,下载 google-services.json。
  2. 在项目根目录放置 google-services.json,同时把它写入 .gitignore。
  3. 回到 Firebase Console → 项目设置 → 服务账号 → 生成新的私钥,下载 JSON。这份 JSON 就是 FCM v1 的 OAuth 凭据。
  4. 在 Expo 网站打开项目 → Project settings → Credentials → FCM V1 service account,上传上一步的 JSON。
  5. 确认 Firebase 项目里 Cloud Messaging API (V1) 已经启用,如果显示「Legacy Cloud Messaging API is disabled」是正常的,v1 不受影响。

上传完成后,Expo Push Service 会用这份服务账号为你的应用签发 access token 并每小时刷新一次。你在应用侧不需要写任何 FCM 相关代码;EAS Build 生成的 APK/AAB 会在启动时读取 google-services.json,注册到 Firebase,然后被 expo-notifications 包装成 Expo push token。

Notification Channel:Android 8.0+ 的强制项

Android 8.0 起,任何通知必须归属于一个 Channel,否则系统会静默丢弃。上一节的 hook 已经创建了 default channel;生产项目一般会针对不同业务再拆分:交易类走高优先级,营销类走低优先级方便用户单独关闭。

await Notifications.setNotificationChannelAsync('transactional', {
  name: '交易与订单',
  importance: Notifications.AndroidImportance.HIGH,
  bypassDnd: false,
  showBadge: true,
});
await Notifications.setNotificationChannelAsync('promotions', {
  name: '活动与推荐',
  importance: Notifications.AndroidImportance.LOW,
});

iOS 端 APNs 密钥与 Bundle ID 配置

iOS 侧的推送凭据在 Apple Developer 后台 的 Certificates, Identifiers & Profiles → Keys 页面生成。相比早年的 p12 证书,从 2016 年起 Apple 推荐使用 p8 密钥:一份密钥可以同时用于 sandbox 和 production,不会过期,也可以为多个 App ID 复用。

  1. Keys → 新建 → 勾选 Apple Push Notifications service (APNs)。
  2. 下载 AuthKey_XXXXXXXXXX.p8,同时记录 Key ID 和 Team ID。
  3. 确保 App ID 已开启 Push Notifications 能力(Identifiers → 你的 Bundle ID → Capabilities)。
  4. 登录 Expo 网站 → Project settings → Credentials → iOS → Push Key,上传 p8 文件并填 Key ID、Team ID。

如果你走 eas credentials 命令行,Expo 会在第一次 build 时提示自动为你生成 APNs Key,一般来说保留默认即可。非常关键的一点:iOS 上 development build(走 sandbox)和 production build(走 production APNs)用的是不同 endpoint。Expo Push Service 会根据构建 profile 自动路由,但如果你直接调 APNs,务必用不同的 URL:sandbox 是 https://api.sandbox.push.apple.com,生产是 https://api.push.apple.com。混用会导致「发送成功但设备收不到」,也是 iOS 推送最常见的坑。我在上个项目提交 TestFlight 那天正好踩上,服务端 200,客户端一条不响,翻了两小时日志才发现是端点选错了。

如何从后端发送推送通知?

最省事的做法是把 token 存到后端后,通过 Expo Push API 一次批量发送。以下是一个 Node.js 20 + Fastify 的示例,使用官方的 expo-server-sdk:

// server/push.ts
import { Expo, ExpoPushMessage } from 'expo-server-sdk';

const expo = new Expo({ useFcmV1: true });

export async function sendOrderShipped(
  tokens: string[],
  order: { id: string; carrier: string }
) {
  const messages: ExpoPushMessage[] = tokens
    .filter((t) => Expo.isExpoPushToken(t))
    .map((to) => ({
      to,
      channelId: 'transactional',
      title: '包裹已发货',
      body: `${order.carrier} 已揽收,点击查看物流详情`,
      data: { type: 'order_shipped', orderId: order.id },
      priority: 'high',
      sound: 'default',
      ttl: 3600,
    }));

  const chunks = expo.chunkPushNotifications(messages);
  const tickets = [];
  for (const chunk of chunks) {
    const chunkTickets = await expo.sendPushNotificationsAsync(chunk);
    tickets.push(...chunkTickets);
  }
  return tickets;
}

Expo 会把每条消息拆成 100 条一组批量发送,返回的 ticket 里包含每条消息的 id。24 小时后可以用 getPushNotificationReceiptsAsync(ids) 拿到 DeviceNotRegistered、InvalidCredentials 等最终送达状态,从而清理失效 token。

直接调用 FCM v1(对照示例)

如果你不想经 Expo 中转,也可以用服务账号 JSON 自行签 access token,然后调用 FCM v1:

import { GoogleAuth } from 'google-auth-library';

const auth = new GoogleAuth({
  keyFile: './service-account.json',
  scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});

async function sendFcmV1(deviceToken: string) {
  const client = await auth.getClient();
  const projectId = 'your-firebase-project';
  const url = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;

  const res = await client.request({
    url,
    method: 'POST',
    data: {
      message: {
        token: deviceToken,
        notification: { title: '包裹已发货', body: '点击查看物流详情' },
        android: { priority: 'high', notification: { channel_id: 'transactional' } },
        data: { type: 'order_shipped', orderId: '10086' },
      },
    },
  });
  return res.data;
}

直接调 FCM v1 的成本是要自己处理 access token 缓存(有效期 1 小时)、指数退避重试和 401 错误。老实说,上手做 push 的头两个月,我强烈建议先用 Expo Push Service,等业务稳定了再考虑是不是有必要自建通道。

处理通知点击与深度链接跳转

用户点通知后跳到应用内某个具体页面,是留存的关键。Expo Router 的文件路由天然适合和推送的 data 字段结合使用。参考我们之前发布的 Expo Router v6 类型安全导航指南,可以把跳转路由做成一个纯函数,然后在通知回调里调用(我自己更喜欢把它单独放到 lib/pushRoutes.ts 里,方便单元测试)。

// app/_layout.tsx
import { useEffect } from 'react';
import { Stack, router } from 'expo-router';
import * as Notifications from 'expo-notifications';

function handleData(data: unknown) {
  if (typeof data !== 'object' || data === null) return;
  const d = data as { type?: string; orderId?: string };
  if (d.type === 'order_shipped' && d.orderId) {
    router.push(`/orders/${d.orderId}`);
  }
}

export default function RootLayout() {
  useEffect(() => {
    // 冷启动:应用被通知拉起
    Notifications.getLastNotificationResponseAsync().then((response) => {
      if (response) handleData(response.notification.request.content.data);
    });

    // 热启动:应用在后台被点击回前台
    const sub = Notifications.addNotificationResponseReceivedListener((response) => {
      handleData(response.notification.request.content.data);
    });
    return () => sub.remove();
  }, []);

  return <Stack />;
}

要点:getLastNotificationResponseAsync 用于处理应用完全关闭时被通知拉起的场景,返回 Promise,第一次调用后 payload 就消费掉了;addNotificationResponseReceivedListener 只处理已运行状态下的点击。两者都必须挂载在根 layout,不然进入某些子路由前的通知会丢失。

前台通知的显示策略

默认情况下,应用在前台运行时收到远程推送不会显示 banner。要显式开启,需要设置 NotificationHandler:

Notifications.setNotificationHandler({
  handleNotification: async (notification) => {
    const type = notification.request.content.data?.type;
    const silent = type === 'sync' || type === 'analytics';
    return {
      shouldShowBanner: !silent,
      shouldShowList: !silent,
      shouldPlaySound: !silent,
      shouldSetBadge: true,
    };
  },
});

富媒体通知与 Android 通道分组

纯文本通知的点击率通常在 3–6%,加图片后能翻倍。expo-notifications 目前支持三类富媒体:图片、大文本 / 收件箱、以及 iOS 的 Notification Content Extension(需要自定义 native 代码,篇幅所限本文不展开)。

// 服务端发送带图片的推送
const messages: ExpoPushMessage[] = [
  {
    to: 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]',
    title: '有 3 位好友加入了 Relay',
    body: '一起来看看他们发布了什么',
    richContent: {
      image: 'https://cdn.example.com/notifications/[email protected]',
    },
    data: { type: 'friends_invite' },
    channelId: 'social',
  },
];

iOS 侧图片会走 mutable-content flag,扩展下载后放到锁屏预览里。Android 侧图片直接嵌入 BigPictureStyle。图片必须是 HTTPS + 公开可访问,尺寸建议宽度不超过 1440px,大小控制在 1MB 以内,超过之后 APNs 会静默丢弃扩展。

通道分组(Android 8+)与静默推送

Android 端如果想让用户在系统设置里分类管理通知,用 setNotificationChannelGroupAsync:

await Notifications.setNotificationChannelGroupAsync('orders', { name: '订单' });
await Notifications.setNotificationChannelAsync('transactional', {
  name: '发货与配送',
  groupId: 'orders',
  importance: Notifications.AndroidImportance.HIGH,
});

静默推送(silent push)在 iOS 走 content-available: 1,在 Android 需要发送 data-only message。这类推送常用于「后台同步用户数据」或「远程刷新缓存」场景。要注意 iOS 对静默推送有强限流:低电量、后台冻结、用户 force-quit 都会拒收,不能当作可靠通道来用。

iOS/Android 收不到推送的排查清单

推送出问题时,先按平台分开验证,别一起排查。以下是 2026 年最常遇到的十种情况和对应处理方式。

症状常见原因验证方式
iOS 完全收不到APNs 环境不匹配(sandbox vs prod)用 development build 测试,看 Expo receipt 是否为 DeviceNotRegistered
Android 8.0+ 无提示缺少 NotificationChannel检查是否调用 setNotificationChannelAsync
Android 13+ 拒收未请求 POST_NOTIFICATIONS 运行时权限getPermissionsAsync() 结果是 undetermined
后端返回 InvalidCredentialsFCM v1 服务账号未上传或过期Expo Project settings → Credentials 页面重传
后端返回 DeviceNotRegistered用户卸载 / 重装 / 关权限从数据库中删除该 token,等下次登录再注册
iOS TestFlight 收不到用了 dev APNs 环境的凭据确认使用 production p8 或 eas credentials 自动生成
点击通知不跳转listener 未挂在根 layout用 getLastNotificationResponseAsync 处理冷启动
Android 图标是白方块图标不是 96×96 白色透明 PNG用 expo-notifications 插件的 icon 字段覆盖
本地开发正常,线上失效Bundle ID 与 Firebase 应用 ID 不匹配检查 google-services.json 里的 package_name
发送成功但延迟数分钟使用了 low priority 或 ttl 过短把 priority 设为 'high',去掉 ttl 或设为 3600

对于疑难杂症,Expo 提供了 Push Notifications Tool,输入 token 后可以直接发一条测试消息并查看 receipt。它避开了后端签名和证书链的所有复杂度,是定位「服务端问题还是设备问题」的最快方法。

如果你的应用同时用到了状态持久化(比如 Zustand v5 + MMKV 方案),别忘了在通知回调里更新 store,否则打开应用后角标、未读列表可能会与实际 payload 对不上。我一般的做法是让通知回调只负责 router.push,业务数据的更新交给应用启动时的 fetch/rehydrate 流程,这样职责更清晰,冷启动和热启动的行为也一致。

常见问题

Expo push token 和 FCM token 有什么区别?

Expo push token 是 Expo Push Service 对 FCM 与 APNs 原生 token 的封装,格式为 ExponentPushToken[...],发送时走 Expo 中转服务;FCM token 是 Firebase 直接返回的原生字符串,用于直接调用 FCM v1 API。前者跨平台一致、含有内置限流和 receipts,后者更适合合规敏感、不希望消息经第三方中转的项目。

FCM legacy API 什么时候正式停用?

Google 已于 2024 年 6 月 20 日关停 FCM 的 legacy HTTP 和 XMPP 接口,所有后续调用会返回 401 或 404。2026 年新项目必须使用 FCM v1 REST API,鉴权通过服务账号 JSON 签发的 OAuth 2.0 access token 完成。

为什么 iOS 真机在 TestFlight 收不到推送?

最常见原因是 development 与 production 的 APNs 环境不匹配。TestFlight 构建走 production APNs,如果 EAS credentials 里配置的是 sandbox 密钥,发送方看到成功但设备永远收不到。解决办法是用 eas credentials 让 Expo 自动生成 p8 push key,或者手动上传 production 用的 APNs Key。

Android 13 以后需要额外请求什么权限?

Android 13(API 33)新增了运行时权限 POST_NOTIFICATIONS。应用 targetSdkVersion≥33 时必须在展示通知前显式调用 Notifications.requestPermissionsAsync(),否则系统直接静默拒收。expo-notifications 会自动在 AndroidManifest 里声明这个权限,只需在业务代码里请求即可。

推送通知能在 Expo Go 中测试吗?

Expo SDK 53 起 Expo Go 已经不再支持远程推送——只有本地通知(scheduleNotificationAsync)可以在 Expo Go 中运行。要验证远程推送,必须做一次 development build(eas build --profile development --platform ios 或 android)并安装到真机。iOS 模拟器和 Android 模拟器不支持远程 APNs / FCM。

如何在服务端一次性给成千上万用户发推送?

用 expo-server-sdk 的 chunkPushNotifications 把消息按 100 条一批切分,然后在后台任务里循环调用 sendPushNotificationsAsync。24 小时后用 getPushNotificationReceiptsAsync 批量拉取 receipts,删除返回 DeviceNotRegistered 的 token。生产环境建议放到 Redis Queue 或 SQS 里异步发送,避免阻塞主 API 进程。

关于作者 Editorial Team

Our team of expert writers and editors.