你可能正处在这个阶段:应用能跑,用户已登录,产品团队开始要求原生感的再互动流程。购物车提醒、评价提示、新消息通知、版本发布公告。第一反应往往是“先把推送接上”,结果一周后你在排查为什么某台设备收不到通知,模拟器注册看起来正常,却没人说得清为什么点击后没打开正确的页面。
Expo 推送通知要么简单得让人惊喜,要么脆弱得让人意外。
Expo 为 React Native 团队提供了 APNs 和 FCM 之上的实用层,这正是许多团队选择它的原因。但从 Demo 到生产级实现之间的差距是真实存在的。Token 生命周期、权限时机、监听器配置、Payload 设计以及后端清理都至关重要。如果你还在频繁发布应用逻辑变更,对运营纪律的要求会更加严格,尤其是当你的留存工作依赖于可靠的消息推送和发布速度时。只有当围绕推送的用户体验是可预测的,推送才真正有用。
目录
- 用 Expo Push Notifications 提升用户参与度的基础
- 项目初始设置与配置
- 请求权限并获取 Push Token
- 从服务器发送通知
- 在 App 中处理接收到的通知
- 生产环境最佳实践与常见坑点
用 Expo Push Notifications 提升用户参与度的基础
一次 Expo 推送通知 它的配置之所以吸引人,最重要的原因是省去了团队在起步阶段不想自己维护的大量原生消息推送复杂度。你不必先搭建直连 APNs 和 FCM 的底层管道,而是可以使用 Expo 的网关,把精力放在产品行为、路由、权限 UX 和后端消息逻辑上。
这种抽象并不会让推送变得“不真实”,只是改变了工程精力投入的方向。
这项服务的速度也足够快,性能通常不是首先要担心的问题。从 2023 年 3 月 14 日到 2023 年 6 月 12 日,Expo 的推送通知 API 显示出 42 毫秒的中位响应时间, 273 毫秒的 p99 延迟,以及一个 日均错误率 0.17% 覆盖 每日数千万条消息,根据 Knock 的 Expo 推送 API 基准测试分析这应该能让任何怀疑 Expo 是否只适合做原型的团队放心。
Expo 到底抽象掉了什么
当团队说“Expo push”时,往往是指几个被混在一起的不同事项:
- 服务商路由: Expo 会把消息转发给 APNs 用于 iOS,以及 FCM 用于 Android。
- Token 格式: 你的服务器存储并发送 Expo Push Token,而不必先处理各平台特定的 Token。
- 请求契约: 你向 Expo 的推送 API 发送 payload,而不是直接集成原生提供商 API。
这很有帮助,但也容易造成误解。团队有时会误以为 Expo 负责所有投递问题。实际上,许多故障源于应用 code、过期 token、格式错误的 payload 或糟糕的权限流程。
实战规则: 将 Expo 视为可靠的传输层,而不是健全客户端和后端设计的替代品。
生产就绪到底意味着什么
一个能跑的 demo 只能证明某台设备曾接受过一次 payload。生产就绪意味着别的:
| 关注点 | 演示思维 | 生产思维 |
|---|---|---|
| 权限 | 立即提问 | 在用户价值明确后,结合上下文提问 |
| 令牌 | 保存一次 | 刷新、去重、过期与对账 |
| 负载 | 全部塞进去 data |
保持负载精简且以行动为导向 |
| 应用行为 | 显示警报 | 正确导航并处理前台状态 |
| 操作 | 手动测试 | 回执、清理、日志及事故处理 |
这就是“发送通知”与“通知支持真实产品工作流”之间的区别。
项目初始设置与配置
许多 Expo 推送的痛点始于首次权限提示之前。如果项目配置混乱,客户端 code 可能看起来正确,但应用在不同构建中仍会表现不一致。

从安装正确的库和匹配构建路径的开发环境开始。如果你在使用 Expo Go 之外的环境,建议将发布工作流与以下指南对齐: Capgo 的 Expo EAS 替代指南,因为通知行为通常需要在更接近生产环境的构建中验证,而不是简单的沙箱运行。
安装通知相关的 package
通常,你至少需要:
expo-notifications用于权限请求、token 获取、监听器以及通知展示。expo-device因为你应该用 ... 来保护 token 获取Device.isDevice.
典型的安装命令取决于你的包管理器,但关键在于版本要与你的 Expo SDK 保持一致。不要随意混用不同版本的包。让 Expo 解析出兼容的版本。
添加项目级配置
保持配置显式。一个最小的 app.json 或 app.config.js 应该反映出通知是你应用契约的一部分,而不是事后补充。
{
"expo": {
"name": "MyApp",
"slug": "my-app",
"plugins": ["expo-notifications"],
"ios": {
"bundleIdentifier": "com.example.myapp"
},
"android": {
"package": "com.example.myapp"
},
"extra": {
"eas": {
"projectId": "your-project-id"
}
}
}
}
这里有几个细节很重要:
- Bundle 标识符和包名 需要与你实际发布的应用相匹配。
- 通知插件 确保在构建期间为原生项目配置所需的设置。
- EAS 项目 ID 当令牌获取要求应用与正确的 Expo 项目关联时,这一点至关重要。
尽早设置通知处理器
很多基础教程迟迟不定义通知行为。别这样。把这段放在应用启动代码附近,让前台行为可预测。
import * as Notifications from 'expo-notifications';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: true,
}),
});
团队要决定前台通知是显示提醒、播放声音,还是影响角标。具体行为取决于你的产品。聊天应用和支付应用不会做同样的选择。
如果没有主动定义前台行为,团队最后就会去调试那些“丢失”的通知——其实通知已经收到,只是没按产品预期展示。
Android 需要配置通知渠道
在实际开发中,Android 通知渠道不是可选项。如果跳过它们,你的通知可能看起来不一致,或不符合用户预期。
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
export async function configureAndroidNotifications() {
if (Platform.OS !== 'android') return;
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
});
}
请在应用初始化时完成设置。然后保持 channel ID 稳定。随意更改会让以后的通知行为更难判断。
请求权限并获取 Push Token
这是很多团队从代码片段里照抄、最后却后悔的部分。
权限请求要把握时机、考虑平台差异,并规范处理异步。获取 token 只能在真机上进行,只能在权限处理完成后,并且只有你准备好立即把结果存到后端时才做。

作为基准的客户端函数
用类似这样的函数作为起点:
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
type RegisterResult =
| { ok: true; token: string }
| { ok: false; reason: string };
export async function registerForExpoPushNotificationsAsync(): Promise<RegisterResult> {
if (!Device.isDevice) {
return { ok: false, reason: 'Push notifications require a physical device.' };
}
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
});
}
const permissions = await Notifications.getPermissionsAsync();
let finalStatus = permissions.status;
if (finalStatus !== 'granted') {
const request = await Notifications.requestPermissionsAsync();
finalStatus = request.status;
}
if (finalStatus !== 'granted') {
return { ok: false, reason: 'Notification permission was not granted.' };
}
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
return { ok: false, reason: 'Missing EAS project ID configuration.' };
}
const tokenResponse = await Notifications.getExpoPushTokenAsync({ projectId });
return { ok: true, token: tokenResponse.data };
}
顺序很重要。先检查设备类型,设置 Android channel 行为,处理权限,验证项目配置,最后请求 Expo token。
原因 Device.isDevice 并非可选
这是少数几个看似无害、实则会产生大量干扰的错误之一。专家团队只在以下情况下才请求权限: Device.isDevice 为 true 时,而一个常见的坑是跳过这个 guard,导致开发者向无效的模拟器 token 发送通知,问题其实出在应用配置上却归咎于 Expo,正如以下所述: Eagerworks 的 Expo 通知实现笔记.
这就是为什么检查要放在函数顶部。不要把它藏在 helper 后面,要让它一目了然。
模拟器结果对 UI 测试很有用,但用来验证 push token 注册并不可信。
在合适的时机请求权限
不要在启动页请求权限。不要在用户理解其价值之前请求。最佳时机通常是在用户做了能体现通知价值的操作之后,例如开启配送更新、加入对话或收藏关注的项目。
好的实现通常遵循以下流程:
- 用户到达有意义的功能边界。
- 应用在自有 UI 中说明通知的价值。
- 应用请求系统权限。
- 如果已获得权限,应用立即将 token 存储到后端。
最后这一步往往是许多应用失败的地方。它们获取 token,只在本地记录日志,却推迟后端注册。之后,支持团队无法判断哪台设备在哪个时间点对应哪个 token。
下面是一个注册后存储 token 的简单示例:
export async function enablePushForCurrentUser(userId: string) {
const result = await registerForExpoPushNotificationsAsync();
if (!result.ok) {
return result;
}
await fetch('https://api.example.com/push-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer user-session-token',
},
body: JSON.stringify({
userId,
token: result.token,
platform: Platform.OS,
}),
});
return result;
}
在后续流程中,这份操作指南可作为有用的视觉参考:
对于构建发布密集型应用的团队,将 token 注册视为应用运行状态的一部分,而不仅仅是 onboarding 流程的一环,也很有帮助。这种思维模式与更广泛的 Expo 应用交付工作流非常契合,在这些场景中,应用行为可能频繁变化,后端状态需要保持同步。
从服务器发送通知
一旦后端拥有有效的 Expo Push Token,发送通知就变得很简单。难点不在于请求本身,而在于决定 payload 中应包含哪些内容,以及你对客户端状态赋予多少信任。
下面是一个使用 fetch:
type ExpoPushMessage = {
to: string;
title: string;
body: string;
sound?: 'default' | null;
data?: Record<string, unknown>;
};
export async function sendExpoPushNotification(token: string) {
const message: ExpoPushMessage = {
to: token,
title: 'New review received',
body: 'Tap to open the order details.',
sound: 'default',
data: {
type: 'new_review',
orderId: 'ord_123',
screen: 'OrderDetails',
},
};
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
Accept: 'application/json',
'Accept-encoding': 'gzip, deflate',
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
const result = await response.json();
return result;
}
Payload 各字段的作用
别把 payload 当成杂物堆。每个字段都要有明确用途。
| 字段 | 用途 | 实战建议 |
|---|---|---|
to |
定位 Expo Push Token | 验证它是否属于当前设备记录 |
title |
通知标题 | 保持简短、易读 |
body |
主要可见文本 | 让操作清晰明确 |
sound |
系统声音行为 | 高价值提醒请谨慎使用 |
data |
应用专属元数据 | 优先使用 ID 和路由提示,而非富内容 |
本指南中介绍的 data object 是产品工作流真正发挥作用的地方。你可以传递 type 和记录 ID,然后在用户点击时让应用获取最新数据。这比直接在 payload 中嵌入大型或敏感数据块更安全。
保持 Payload 简单、别搞花样
根据 Courier 的 Expo 通知指南,应将 Expo Push Tokens 视为临时性的;payload 超过大约 4 KB 的限制,可能会被丢弃;可靠的做法是发送小的 metadata payload,例如 { "type": "new_review", "id": 123 } 而不是大段 JSON 或内联媒体。这条建议符合真实系统的实践。小 payload 失败率更低,在 App 逻辑变化时也更耐用。
只需发送足以路由用户的数据。其余数据在 App 打开后再获取。
实用的服务端习惯
测试时用基础的发送函数就够了。生产版本通常还要多承担几项职责:
- 持久化发送尝试: 保存通知意图,包含用户 ID、token、payload 类型和时间戳。
- 将内容生成与传输解耦: 在一层构建消息文案,在另一层处理 Expo API 请求。
- 处理失效反馈: 如果 Expo 随后报告
DeviceNotRegistered,请将该 token 标记为失效,并停止盲目重试。 - 采用对 webhook 友好的设计: 如果你的系统已经发出事件,请将通知触发器通过类似的 后端 webhook 处理模式 进行路由,就像你在其他地方使用的那样。
在调试客户端监听器之前,先发送一条手动测试推送。如果 token 能收到一条带有极小 payload 的普通通知,说明你的传输路径可能正常。否则,不要一开始就修改导航 code。应先验证 token、payload 结构和权限状态。
在 App 中处理接收到的通知
送达只是功能的一半。应用必须在通知到达时以及用户点击时做出连贯的反应。
这意味着需要处理两个独立的时刻:
- 应用处于前台时收到通知
- 用户从系统托盘或锁屏点击通知

前台接收与用户响应是不同的事件
可靠的配置通常包含这两个监听器:
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
export function useNotificationObservers(
onForegroundMessage: (notification: Notifications.Notification) => void,
onNotificationTap: (response: Notifications.NotificationResponse) => void
) {
useEffect(() => {
const receivedSub = Notifications.addNotificationReceivedListener(
(notification) => {
onForegroundMessage(notification);
}
);
const responseSub = Notifications.addNotificationResponseReceivedListener(
(response) => {
onNotificationTap(response);
}
);
return () => {
receivedSub.remove();
responseSub.remove();
};
}, [onForegroundMessage, onNotificationTap]);
}
addNotificationReceivedListener 应用处于活跃状态时触发。 addNotificationResponseReceivedListener 用户点击已送达的通知时触发。不要混淆它们,它们服务于不同的用户体验路径。
读取 data payload 并有目的地跳转
以下是一个实用的点击处理模式:
type NotificationData = {
type?: string;
orderId?: string;
screen?: string;
};
export function handleNotificationTap(
response: Notifications.NotificationResponse,
navigation: any
) {
const data =
response.notification.request.content.data as NotificationData;
if (data.screen === 'OrderDetails' && data.orderId) {
navigation.navigate('OrderDetails', { orderId: data.orderId });
return;
}
if (data.type === 'new_review') {
navigation.navigate('Inbox');
return;
}
navigation.navigate('Home');
}
该模式具有韧性,因为 payload 包含路由提示而非完整文档。如果通知发送后订单已变更,应用可在导航后获取服务器最新状态。
点击通知应导向一个明确的目标。如果回退路径模糊,用户会立刻察觉。
前台行为应与用户上下文匹配
当应用已经打开时,盲目弹出系统样式的弹窗会显得很笨拙。有时更合适的做法是应用内横幅、角标更新或静默刷新。用户已经在看客服收件箱里的对话时,这个页面可能并不需要再显示可见弹窗。
因此,前台监听器应按路由和通知类型分别处理。例如:
- 聊天界面打开时: 追加消息,避免显示多余的横幅
- Dashboard 打开时: 显示轻量级应用内 toast
- 关键账户事件: 使用更醒目的 UI 提示
一种简单做法如下:
export function handleForegroundNotification(
notification: Notifications.Notification,
currentRouteName: string
) {
const data = notification.request.content.data as { type?: string };
if (currentRouteName === 'ChatThread' && data.type === 'new_message') {
// refresh local thread state
return;
}
// otherwise show your own in-app UI or update badges
}
如果你的应用不区分这些场景,用户会更快产生通知疲劳,即使推送在技术上是正确的。
生产环境最佳实践与常见坑点
大多数出问题的 Expo 推送通知配置,并不是因为 Expo 功能太有限,而是因为团队误以为 token 永久有效、payload 能携带任何内容,且应用更新不会影响通知逻辑。
这个假设在 production 环境中站不住脚。

Token 是临时的,不是身份记录
Expo Push Token 最好被视为一种租约,而非终身设备标识符。Token 可能在重装、系统更新或其他生命周期事件后轮换。如果某个 token 最终返回 DeviceNotRegistered,你的后端应停止将其视为活跃状态。
一个实用的后端模型应存储:
- 用户 ID
- 平台
- 安装范围元数据
- 当前 token
- 最后活跃时间戳
- 状态,如 active、stale 或 revoked
别只在用户表里存一个 token 字段就完事了。用户有多台设备,且设备状态会变化。
刷新策略比大多数教程承认的更重要
官方生态指南在这里留下了一个真实的运维缺口。现有 Expo 推送通知内容通常没有说明如何在 App Store 审核周期和 OTA 更新期间维持 token 有效性,这对发布实时变更的团队尤其重要,因为推送可靠性取决于当前 token 状态和后端同步,正如 Expo 通知文档.
中所述。这会影响你如何设计刷新触发器。适合同步 token 状态的时机包括:
- 更新后启动应用
- 用户登录
- 权限设置变更
- 发布流程中的凭证轮换工作
- 推送相关支持工单后的恢复流程
安全与合规不应被挤到 Sprint 最后
很多 Expo 教程只讲实现机制,却跳过运维风险。对业余项目来说没问题,但对医疗相关、金融科技或受监管的电商产品就不行。
Courier 围绕 Expo 通知能力缺口的企业级讨论 并强调在同意记录、审计追踪和减少敏感 payload 暴露方面缺乏实用指导。直接的工程结论很简单:
- 不要把敏感业务数据放进通知文本或 payload 元数据。
- 在服务端记录用户同意状态变更。
- 记录哪个通知意图发给了哪个 token。
- 在 payload 中使用 ID,并在应用打开后获取受保护内容。
对于将发布流程与更广泛的 应用商店合规性及 API 安全实践相结合的团队,推送通知应纳入与身份验证、分析和后端事件日志相同的审核规范中。
推送通知虽然是面向用户的消息,但本质上也是分布式系统问题。对待推送通知应像对待身份验证状态和支付事件一样谨慎。
哪些做法通常有效,哪些会出问题
| 通常有效 | 通常会出问题 |
|---|---|
| 在清楚说明价值后再请求权限 | 在首屏即弹出权限请求 |
| 在真机上测试 | 信任模拟器注册 |
| 存储带有设备上下文的 token | 每个用户记录对应一个 token |
| 发送小型元数据 payload | 嵌入大型或敏感数据块 |
| 分别处理前台和点击事件 | 假设所有通知都走同一条路径 |
| 主动让过期 token 失效 | 对失效 token 无限重试 |
一套稳健的 Expo 推送配置并不复杂,关键在于纪律。
如果你的团队频繁发布应用逻辑变更,并且需要更精细地控制发布行为、回滚和交付可见性, Capgo 值得了解一下。它帮助移动团队快速推送更新,无需等待应用商店审核,这在通知流程、路由逻辑或客户端修复需要快速触达用户时尤为有用。