主题
12 · RN 权限、推送与后台能力
目标:掌握权限四步法与审核坑;分清本地/远程通知与推送限制;用 Expo 跑通推送与后台定位的系统级路径(不靠 JS 轮询)。
前置:11 · 手势与 Reanimated
下一篇:13 · 安全区与核心组件
一、 权限处理的标准流程(四步法)
现代操作系统(iOS / Android)对权限的掌控非常严苛。标准的 RN 权限处理流程如下:
text
1. 检查当前权限状态 (Check Status)
│
├───► 已授权 (granted) ──────► 直接调用 API (如拍照/定位)
│
└───► 未授权/未询问 (undetermined)
│
▼
2. 弹出原生/自定义弹窗申请 (Request)
│
├───► 用户同意 ──────────────► 成功,继续业务
│
└───► 用户拒绝 (denied / blocked)
│
▼
3. 引导提示 (Show Rationale) ───► 跳转到手机系统设置页 (Open Settings)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
二、 实战代码解析(以 Expo 相机权限为例)
在 Expo 中,几乎所有涉及敏感隐私的模块(如 expo-camera, expo-location, expo-notifications)都统一提供了 useCameraPermissions 这样的 Hook,把状态管理封装得非常优雅。
tsx
import { View, Text, Button, StyleSheet, Linking } from 'react-native';
import { useCameraPermissions } from 'expo-camera';
export default function CameraScreen() {
// 1. 获取权限状态 status 和 申请权限的方法 requestPermission
const [permission, requestPermission] = useCameraPermissions();
// 情况 A:权限状态还在加载中(刚打开页面)
if (!permission) {
return <View />;
}
// 情况 B:用户尚未允许权限
if (!permission.granted) {
return (
<View style={styles.container}>
<Text style={styles.message}>我们需要使用您的相机来扫描二维码</Text>
{/*
canAskAgain 为 true:表示还可以再次弹出系统原生授权框
canAskAgain 为 false:表示用户勾选了“不再询问”或 iOS 已经永久拒绝
*/}
{permission.canAskAgain ? (
<Button onPress={requestPermission} title="授予相机权限" />
) : (
<Button
onPress={() => Linking.openSettings()} // 2. 引导用户手动去“系统设置”开启
title="去系统设置中开启权限"
/>
)}
</View>
);
}
// 情况 C:申请过了,权限正常,渲染相机组件
return (
<View style={styles.container}>
<Text>相机预览已开启...</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
message: { textAlign: 'center', marginBottom: 10 },
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
三、 你必须知道的 3 个“深坑”与审核规则
你说的“申请不过就提示用户”在实际落地时,有以下几个必须注意的细节:
1. iOS 的“一次拒绝,终生绝响”机制 ⚠️
- 现象:在 iOS 上,系统原生的权限弹窗这辈子只会对用户弹一次!
- 后果:如果用户第一次点了“不允许”,你下一次再调用
requestPermission(),iOS 会直接返回denied,完全不会再弹窗。 - 解法:当检测到
permission.canAskAgain === false时,你只能调用Linking.openSettings()弹出提示框,引导用户手动跳到手机的“设置 -> 你的 App -> 打开开关”。
2. 预请求弹窗 (Pre-permission Modal) —— 提升通过率
- 如果刚打开 App,还没等用户明白怎么回事,直接就啪啪啪弹出一堆系统权限申请,用户大多会习惯性点“拒绝”。
- 行业最佳实践:先用你 App 自己的 UI 弹出一个温馨提示框:“为了方便你上传头像,我们需要使用你的相册权限,点击继续开启”。当用户点击“继续”后,再触发系统的原生权限申请。
3. 原生配置文件描述 (app.json) —— 没写直接崩溃/被拒
无论是 iOS 还是 Android,代码里写了申请权限还不够,必须在 app.json 或 Info.plist 里申明“为什么要用这个权限”。
例如在 Expo 的 app.json 中配置:
json
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "我们需要在您拍照上传头像时使用相机。",
"NSPhotoLibraryUsageDescription": "我们需要访问您的相册以选择照片。"
}
},
"android": {
"permissions": [
"CAMERA",
"READ_EXTERNAL_STORAGE"
]
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
⚠️ 苹果审核巨坑:iOS 审核人员会严格检查这里的文案!如果你只写“需要相机权限”(太宽泛),苹果会直接拒绝你的 App 上架。文案必须明确写清具体用于什么功能(如:“用于扫描二维码”、“用于拍照上传头像”)。
总结
处理 RN 权限的完整闭环是:
app.json声明:写清申请原因(应对打包和App Store审核)。- 业务点触发:不要一进 App 就全弹出来,在用户点击对应功能(如按拍照按钮)时再申请。
- 分情况应对:
- 没申请过 $\rightarrow$ 调 API 弹系统框。
- 申请通过 $\rightarrow$ 执行业务。
- 被永久拒绝 $\rightarrow$ 提示用户并提供
Linking.openSettings()一键跳转手机设置。
简单直接地回答这两个问题:
1. 并不是手机里的所有通知都是远程服务器推送的。2. 服务器推送有非常严格的规则、限制以及物理瓶颈。
下面为你详细拆解手机通知的分类以及推送的各种“限制”。
一、 手机通知的两种类型:远程 vs 本地
手机上弹出的 Notification(Banner 通知),本质上分为两种来源:
text
┌──► 1. 本地通知 (Local Notification) ─── 由手机上的 App 自身触发
用户看到的手机通知 Banner ──┤
└──► 2. 远程通知 (Remote Notification) ── 由云端服务器经过 APNs/FCM 触发1
2
3
2
3
1. 本地通知 (Local Notification) —— 内部定时器
- 过程:完全不需要联网,也不经过任何云端服务器。
- 原理:App 在前台运行或者你在设置里操作时,App 内部向手机操作系统注册了一个“闹钟/定时任务”。到时候,操作系统会自动弹出通知。
- 常见场景:
- 手机闹钟 / 日历日程提醒。
- 备忘录 / 待办事项(如“晚上 8 点提醒我买牛奶”)。
- 游戏提醒(如“你的体力已恢复满” —— 游戏在你退出时计算好 3 小时后体力满,给系统注册了一个 3 小时后的本地通知)。
2. 远程推送 (Remote Notification) —— 真正的云端消息
- 过程:你的后端服务器 $\rightarrow$ 苹果/谷歌云端推送服务 (APNs / FCM) $\rightarrow$ 手机操作系统 $\rightarrow$ 弹出通知。
- 常见场景:
- 微信 / 钉钉收到新消息。
- 外卖 App 提示“骑手已接单”。
- 新闻 App 推送头条新闻。
二、 远程推送(Server Push)有哪些限制?
作为开发者,你不能想怎么推就怎么推。推送受到 推送厂商(苹果/谷歌/厂商)、操作系统算法、以及用户个人设置 三重限制。
1. 频率与速率限制 (Rate Limiting)
推送服务商(APNs / FCM / 华为推送等)为了防止服务器宕机以及防止 App 发垃圾广告(Spam),设置了非常严格的吞吐量限制:
- 并发与频次限制:
- APNs (Apple):通常对单个 App 没有明确的每日上限总量,但对单秒并发连接数(TPS)有限制。如果你 1 秒内往 APNs 发送几十万条请求,APNs 会返回
429 Too Many Requests直接拒绝服务。 - Android 厂商推送(如 FCM / 华为 / 钛马/ 极光):中国大陆的 Android 生态(小米、华为、OPPO、vivo)限制极度严格。厂商通常会将通知分为“系统消息”(如交易通知)和“运营消息”(如营销广告)。
- 运营消息:单日对单个用户最多只能推 1~2 条,超过频次的直接被厂商服务器丢弃!
2. 消息体大小限制 (Payload Size Limit)
远程推送不是让你用来发大段数据或图片的,它只是一个“打招呼”的信号。
- APNs 限制:普通通知 Payload 最大只有 4 KB。
- FCM 限制:通知消息最大只有 4 KB。
- 如果你试图把一整篇新闻文章或者一张 Base64 图片放进推送包里发过去,推送接口会直接报错拒绝。
3. 系统级的防打扰与频次抑制 (Frequency Capping)
即使你的服务器成功把 100 条推送发到了用户的手机上,手机操作系统(iOS / Android)也不会真的弹出来 100 次:
- iOS 通知折叠与频率抑制:iOS 会根据用户的打开习惯,自动把同一个 App 的多条通知合并折叠。如果是静默推送(Background Data Push),iOS 内部有针对 App 耗电和唤醒频次的智能算法,如果系统判定你频发唤醒 App,会强制暂停对该 App 的后台唤醒。
- Android 频次限制:Android 系统限制单个 App 在短时间内(如 1 秒内)最多只能在通知栏更新或弹出有限次数的 Banner,防止通知栏炸裂。
4. 终极限制:用户权限与“一键关停”
在手机端,权限大于一切:
- 权限开关:用户随时可以在手机设置里关闭“允许通知”。一旦关闭,服务器依然可以成功把消息发给 APNs/FCM,但手机操作系统会直接静默拦截,绝不弹出。
- 免打扰模式 (Do Not Disturb / Focus Mode):iOS 的“专注模式”和 Android 的“勿扰模式”开启后,所有推送都会被隐蔽显示或延迟提醒。
总结
在 React Native (Expo) 中,当 App 被用户切到后台(Background)甚至被系统彻底杀死(Killed / Quit)时,手机操作系统(iOS / Android)会对应用进行极严格的 CPU 和网络限制。
要解决后台消息通知和后台持续定位,必须通过专门的系统级后台机制来实现。
一、 后台消息通知 (Push Notifications)
当 App 在后台或被关闭时,绝对不能靠 App 内部写 JS 定时器去轮询服务器(因为 JS 线程在后台几秒内就会被系统挂起冻结)。
标准的解决方案是利用 远程推送服务 (Remote Push Notifications)。
1. 架构运作原理
text
[你的后端服务器] ────(包含消息数据)────> [APNs (苹果) / FCM (谷歌)]
│
(操作系统级通道)
│
▼
[用户的手机操作系统]
│
┌──────────────────────┴──────────────────────┐
▼ ▼
【App 在后台 / 已被杀死】 【App 在前台】
由系统直接弹出原生 Banner 通知 由 App 内接收并静默处理1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
- 即使 App 完全关闭,iOS 和 Android 的操作系统本身依然与 Apple (APNs) 和 Google (FCM) 保持着一条长连接。
- 只要你的服务器把消息推给 APNs/FCM,手机操作系统就会替你弹出一条通知 Banner。
2. Expo 中的实现步骤 (expo-notifications)
第一步:向用户申请通知权限并获取 Push Token
tsx
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export async function registerForPushNotificationsAsync() {
// 1. 申请权限
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
alert('未能获取推送权限!');
return;
}
// 2. 获取当前设备的 Expo Push Token (设备的唯一推送身份证)
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId: 'your-expo-project-id', // Expo 项目 ID
});
console.log(' Expo Push Token:', tokenData.data);
// 3. 把这个 token 发送并保存到你的后端数据库 (与 userId 关联)
// await sendTokenToBackend(userId, tokenData.data);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
第二步:监听用户点击通知行为
当用户在后台看到系统弹出的通知消息并点击时,打开 App 并跳转到具体页面:
tsx
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';
export default function NotificationListener() {
const router = useRouter();
useEffect(() => {
// 监听:用户点击了后台发来的通知
const subscription = Notifications.addNotificationResponseReceivedListener(response => {
// 获取后端发推送时附带的自定义数据 (Data Payload)
const data = response.notification.request.content.data;
// 比如点击“聊天消息”通知,直接路由跳转到对应的聊天室
if (data?.chatRoomId) {
router.push(`/chat/${data.chatRoomId}`);
}
});
return () => subscription.remove();
}, []);
return null;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
二、 后台获取位置信息 (Background Location)
相比推送,后台持续定位属于高耗电、高敏感权限行为。
在 Expo 中,不能直接在页面内调用 Location.getCurrentPositionAsync(),必须配合 expo-task-manager(后台任务管理器) 将定位任务注册到原生操作系统的后台服务中。
1. 核心步骤与实现
第一步:在根作用域定义后台任务 (Task Definition)
⚠️ 关键点:TaskManager.defineTask 必须在组件外部(通常是 index.js 或全局文件顶层)定义,确保 App 在后台被唤醒时就能被加载。
tsx
import * as TaskManager from 'expo-task-manager';
import * as Location from 'expo-location';
export const LOCATION_TASK_NAME = 'background-location-task';
// 1. 定义后台任务处理函数
TaskManager.defineTask(LOCATION_TASK_NAME, ({ data, error }) => {
if (error) {
console.error('后台定位出错:', error);
return;
}
if (data) {
const { locations } = data as { locations: Location.LocationObject[] };
const currentLocation = locations[0];
console.log('📍 后台捕获到最新位置:', currentLocation.coords.latitude, currentLocation.coords.longitude);
// 2. 在这里把位置静默上报给后端接口
// fetch('https://your-api.com/user/location', {
// method: 'POST',
// body: JSON.stringify({
// lat: currentLocation.coords.latitude,
// lng: currentLocation.coords.longitude,
// })
// });
}
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
第二步:申请“始终允许”权限并开启后台定位
tsx
import React from 'react';
import { Button, View, Alert } from 'react-native';
import * as Location from 'expo-location';
import { LOCATION_TASK_NAME } from './locationTask';
export default function LocationScreen() {
const requestAndStartBackgroundLocation = async () => {
// 1. 必须先申请“前台定位权限”
const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync();
if (foregroundStatus !== 'granted') {
Alert.alert('拒绝前台定位权限,无法开启后台定位');
return;
}
// 2. 再申请“后台/始终定位权限” (Always Allow)
const { status: backgroundStatus } = await Location.requestBackgroundPermissionsAsync();
if (backgroundStatus !== 'granted') {
Alert.alert('需要选择“始终允许”定位才能在后台记录轨迹');
return;
}
// 3. 检查任务是否已经在运行
const hasStarted = await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
if (!hasStarted) {
// 4. 启动后台定位服务
await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
accuracy: Location.Accuracy.Balanced, // 精度与耗电平衡
timeInterval: 10000, // 最小时间间隔 (毫秒)
distanceInterval: 10, // 最小移动距离 (米)
showsBackgroundLocationIndicator: true,// iOS 顶部显示蓝色定位指示条
foregroundService: { // Android 必须配置前台服务通知栏(防止被系统杀死)
notificationTitle: "应用正在后台使用定位",
notificationBody: "用于记录您的骑行/行车轨迹",
notificationColor: "#fff"
}
});
Alert.alert('后台定位已成功开启!');
}
};
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Button title="开启后台持续定位" onPress={requestAndStartBackgroundLocation} />
</View>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2. 配置文件配置 (app.json) —— 审核必踩坑点
iOS 和 Android 对后台定位的审核极度严格,你必须在 app.json 中配置原生权限说明和后台模式(Background Modes):
json
{
"expo": {
"ios": {
"infoPlist": {
"UIBackgroundModes": [
"location",
"fetch"
],
"NSLocationWhenInUseUsageDescription": "我们需要在您使用应用时获取位置以提供导航服务。",
"NSLocationAlwaysAndWhenInUseUsageDescription": "我们需要在后台获取位置,以在应用关闭时持续记录您的运动轨迹。"
}
},
"android": {
"permissions": [
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION",
"ACCESS_BACKGROUND_LOCATION",
"FOREGROUND_SERVICE"
]
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
三、 总结:后台处理的核心哲学
| 后台需求 | 实现方案 | 关键 API / 模块 | 核心原则 |
|---|---|---|---|
| 新消息通知 | 远程推送 (Push Notification) | expo-notifications + APNs/FCM | 绝不在后台做 JS 轮询,统一由系统级通道推送。 |
| 后台定位 | TaskManager 任务 | expo-task-manager + expo-location | 必须在全局定义 Task,iOS 配置 Background Modes,Android 开启 Foreground Service。 |
| 切换前后台响应 | 生命周期监听 | AppState (RN 原生 API) | 适合切前台时刷新 Token/数据,切后台时挂起非必要任务。 |
