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 推送通知的现状与选型
安装与项目配置:expo-notifications 与 EAS
请求权限并获取 push token
Android 端 FCM v1 完整配置
iOS 端 APNs 密钥与 Bundle ID 配置
如何从后端发送推送通知?
处理通知点击与深度链接跳转
富媒体通知与 Android 通道分组
iOS/Android 收不到推送的排查清单
常见问题
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+ 会显示为白色方块。
警告: 不要把 google-services.json 提交到公开仓库。生产环境应通过 EAS Environment Variables 以 secret 文件形式注入,Build 阶段自动写入到 ./google-services.json。
请求权限并获取 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 四个维度,方便后续按用户、按机型、按版本做灰度和撤销。
提示: 如果你不想依赖 Expo Push Service,把上面代码里的 getExpoPushTokenAsync 换成 getDevicePushTokenAsync() 就能拿到原生 FCM / APNs token。这个函数在 Managed workflow 下同样可用,前提是构建带上 expo-notifications 插件。
Android 端 FCM v1 完整配置
Android 上推送通道由 Firebase Cloud Messaging 提供。2024 年 6 月之后所有旧版 server key(形如 AAAA...)都失效了;2026 年可用的鉴权方式只有服务账号 JSON。完整配置流程如下:
在 Firebase Console 创建或选择项目,Add App → Android,填入 com.example.relay,下载 google-services.json。
在项目根目录放置 google-services.json,同时把它写入 .gitignore。
回到 Firebase Console → 项目设置 → 服务账号 → 生成新的私钥,下载 JSON。这份 JSON 就是 FCM v1 的 OAuth 凭据。
在 Expo 网站打开项目 → Project settings → Credentials → FCM V1 service account,上传上一步的 JSON。
确认 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 复用。
Keys → 新建 → 勾选 Apple Push Notifications service (APNs)。
下载 AuthKey_XXXXXXXXXX.p8,同时记录 Key ID 和 Team ID。
确保 App ID 已开启 Push Notifications 能力(Identifiers → 你的 Bundle ID → Capabilities)。
登录 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,客户端一条不响,翻了两小时日志才发现是端点选错了。
提示: 模拟器无法接收远程推送。iOS 16.4 起 xcrun simctl push 支持模拟本地通知负载,但那只是给 UI 调试用的,不会真正走 APNs。真机 + development build(TestFlight 也可以)是唯一可靠的测试方式。
如何从后端发送推送通知?
最省事的做法是把 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
后端返回 InvalidCredentials FCM 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 进程。