大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
原生模块与设备能力
这一课你将能做到
- 用 expo-image-picker 实现「从相册选图」和「调相机拍照」,拿到图片并显示或上传
- 用 expo-location 获取用户经纬度,理解 iOS 权限文案配置和权限被拒后的补救流程
- 用 expo-notifications 发送本地定时通知,并处理「权限被拒且不再弹窗」的引导逻辑
为什么要学
调相机、读相册、拿定位、发通知——这些是 App 区别于网页的核心能力,也是几乎所有真实项目都绕不开的功能:换头像要用相机相册、外卖和打车要用定位、提醒类应用离不开通知。好消息是 Expo 把这些原生能力全部封装成了统一的 JS API,你不用写一行 Swift 或 Kotlin。
但真正的难点不在调 API,而在权限。移动操作系统把敏感能力的生杀大权交给了用户:用户不点头,你的代码写得再漂亮也是死的。而且权限流程坑很多——iOS 上系统授权框只弹一次,用户拒绝之后就只能引导他去系统设置里手动开启;权限用途文案写不好,上架审核直接被打回。
人话:调原生能力就像去政务大厅办事——先取号排队(申请权限),窗口工作人员同意了才能办业务(调 API);如果被拒了,你得礼貌地告诉群众去哪里补办手续(用 Linking.openSettings() 引导去系统设置页),而不是反复按铃骚扰。
先建立图像
权限申请的标准流程图,这一课所有能力都遵循它:
要用功能
↓
检查权限状态
├─ 已授权 → 直接用
├─ 未询问 → 弹系统授权框 → 用户同意 → 用
│ ↘ 用户拒绝 → 降级提示
└─ 已拒绝且不可再问(iOS 典型)
→ 弹自己的解释对话框 → 引导去系统设置页手动开启
Expo 权限 API 的统一模样(所有模块都是这个套路):
// 套路 A:Hook 形式(推荐,组件里用)
const [permissionResponse, requestPermission] = ImagePicker.useCameraPermissions();
// permissionResponse.status: 'undetermined' | 'denied' | 'granted'
// permissionResponse.canAskAgain: false 表示 iOS 上系统不再弹框
// 套路 B:命令式函数(任何位置都能调)
const { status, canAskAgain } = await ImagePicker.requestCameraPermissionsAsync();
再看一眼选图完成后的完整数据流,后面每一步都在这张图里:
用户点「选择头像」
→ launchImageLibraryAsync() 打开系统相册
→ 用户选完图,返回 { canceled: false, assets: [{ uri, width, height, ... }] }
→ uri(file:// 开头的本地路径)→ 给 <Image source={{ uri }}> 显示
→ 或塞进 FormData 上传到服务器(第 7 课学过)
跟着做
步骤 1:装库并实现「从相册选头像」
npx expo install expo-image-picker
注意用 npx expo install 而不是 npm install——它会自动匹配与你 Expo SDK 版本兼容的原生库版本,省去版本对不上的崩溃烦恼。
新建 AvatarPicker.js,一个可直接运行的完整组件:
import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';
import { Alert, Button, Image, StyleSheet, View } from 'react-native';
export default function AvatarPicker() {
const [uri, setUri] = useState(null);
const pick = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'], // 只要图片(新版写法,不用 ImagePicker.MediaTypeOptions)
allowsEditing: true, // 弹出系统裁剪框
aspect: [1, 1], // 裁剪比例:正方形头像
quality: 0.8, // 压缩质量 0~1,原图太大必须压
});
if (result.canceled) return; // 用户点了取消,直接返回
const asset = result.assets[0]; // 新版返回结构:assets 数组
console.log(asset.uri, asset.width, asset.height, asset.fileSize);
setUri(asset.uri);
};
return (
<View style={styles.container}>
{uri ? (
<Image source={{ uri }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.placeholder]} />
)}
<Button title="选择头像" onPress={pick} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16 },
avatar: { width: 120, height: 120, borderRadius: 60 },
placeholder: { backgroundColor: '#ddd' },
});
跑起来,点按钮,系统相册弹出,选一张图,圆形头像立刻显示。整条链路一次通过的感觉非常爽。
步骤 2:调相机拍照(要先申请权限)
相机和相册不同:相册在新系统上基本免权限(用户手动选哪张给你哪张),相机则必须显式申请。给上面的组件加一个拍照按钮:
const takePhoto = async () => {
// 第一步:申请权限
const { status, canAskAgain } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
if (!canAskAgain) {
// iOS 上被拒过一次,系统不再弹框,只能引导去设置
Alert.alert('需要相机权限', '请在系统设置中允许访问相机', [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]);
} else {
Alert.alert('提示', '没有相机权限,无法拍照');
}
return;
}
// 第二步:权限到手,调起相机
const result = await ImagePicker.launchCameraAsync({
quality: 0.8,
allowsEditing: true,
});
if (!result.canceled) {
setUri(result.assets[0].uri);
}
};
文件顶部记得补 import:
import { Linking } from 'react-native';
重要提醒:iOS 模拟器没有相机硬件,调起来是黑屏或直接报错。相机功能请用真机调试(Expo Go 扫码即可,第 1 课的环境)。
步骤 3:获取定位(含 iOS 权限文案配置)
npx expo install expo-location
import * as Location from 'expo-location';
const getLocation = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
Alert.alert('提示', '没有定位权限,无法推荐附近内容');
return;
}
const loc = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced, // 精度与耗电的折中
});
console.log('纬度', loc.coords.latitude, '经度', loc.coords.longitude);
};
拿到经纬度后,通常配合地图组件(react-native-maps)展示,或发给后端换「附近的店」列表。
关键配置:iOS 上架时必须在 app.json 里声明权限用途文案,否则审核必拒。这段话会在系统授权框里展示给用户:
{
"expo": {
"ios": {
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "需要获取您的位置,用于推荐附近的内容和服务"
}
}
}
}
文案原则:说清楚「要这个权限干什么、给用户带来什么好处」。写「我们需要定位信息」这种空洞的话,苹果审核员会直接打回。
步骤 4:发一条本地通知
npx expo install expo-notifications
import * as Notifications from 'expo-notifications';
// App 启动时设置一次:通知在前台也要弹出来(默认前台静默)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
const remindMe = async () => {
const { granted } = await Notifications.requestPermissionsAsync();
if (!granted) {
Alert.alert('提示', '没有通知权限,收不到提醒');
return;
}
await Notifications.scheduleNotificationAsync({
content: {
title: '喝水提醒 💧',
body: '该起来活动一下了',
sound: true,
},
trigger: { type: 'timeInterval', seconds: 5 }, // 5 秒后触发
});
Alert.alert('已设置', '5 秒后会收到提醒,可以切到桌面等');
};
点按钮后立刻切到手机桌面,5 秒后通知横幅弹出——这是「代办提醒」「喝水打卡」类功能的雏形。注意 Android 13(API 33)起通知也需要运行时权限了,上面的 requestPermissionsAsync 会一并处理。
步骤 5:统一的权限拒绝引导组件
每个权限都写一遍「去设置」逻辑太啰嗦,抽一个可复用函数:
import { Alert, Linking } from 'react-native';
export function alertPermissionDenied(permissionName) {
Alert.alert(
`需要${permissionName}权限`,
`该功能需要您授权${permissionName}。请前往系统设置开启。`,
[
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
}
Linking.openSettings() 会直接跳到本 App 在系统设置里的专属页面,用户拨一下开关就行——这是权限被拒后唯一有效的补救路径。
讲清楚要点
- 权限永远「先申请、再使用」:不申请直接调 API,轻则静默失败(返回空),重则直接崩溃红屏。Expo 每个模块都提供
request...PermissionsAsync()或对应的use...Permissions()Hook,名字规律统一,看到模块名就能猜出来。 - iOS 权限文案是上架硬性要求:
app.json的ios.infoPlist里要为每个用到的敏感权限写用途说明(相机、定位、相册、麦克风……),文案要让用户秒懂「为什么给你」,空洞文案会被审核拒绝。 - ImagePicker 新版返回结构是
{ canceled, assets }:图片信息在result.assets[0]里,注意canceled是单 l 的美式拼写。网上老教程里的result.cancelled(双 l)和result.uri都是旧版 API,照抄会拿到 undefined。 - 拿到的 uri 是临时路径,重启后可能失效:系统会清理缓存目录。需要长期保存的图片,必须用 expo-file-system 把文件拷贝到 App 的文档目录(
FileSystem.documentDirectory),再存新路径。 - 本地通知 vs 远程推送是两套东西:本地通知完全在设备上调度,不联网、不需要服务端,提醒类场景足够用;远程推送要走「设备 token → Expo Push Service / APNs / FCM → 你的服务端」整条链路,是另一个量级的工程。
- 模拟器有硬边界:iOS 模拟器没有相机;定位在模拟器上可以模拟(iOS 模拟器菜单 Features → Location,安卓模拟器在设置里),但指南针、陀螺仪这类传感器都没有。设备能力相关功能,真机调试是唯一可靠方式。
你可能会懵的地方
-
为什么安卓要的权限比 iOS 多还碎? Android 把权限切得更细:定位分精确/模糊,相册分读图/读视频,通知从 13 开始也要运行时权限。好消息是用 Expo 时这些声明大多由库的默认配置帮你写进 AndroidManifest,你只需要在运行时调
requestPermissionsAsync申请。别手动往 app.json 里乱加权限,多声明了没用的权限反而会被 Google Play 要求解释。 -
能不能先弹一个自己设计的界面解释用途,再弹系统框? 不但允许,而且是最佳实践,行话叫「预授权弹窗(pre-permission prompt)」。在用户第一次进入相关功能时,先用自己的 UI 说明「开启定位可以帮你找到附近的朋友」,用户点了「开启」再调系统的 request——这样即使被拒,下次进功能还能再弹自己的界面引导,转化率显著高于冷不丁弹系统框。
-
选完图想立刻上传到服务器,怎么做? 用第 7 课学过的 FormData:
formData.append('file', { uri, name: 'avatar.jpg', type: 'image/jpeg' }),然后fetch(url, { method: 'POST', body: formData })。注意这里传的是包含 uri/name/type 三字段的对象,不是 Blob——这是 RN 的特有写法,后端收到的就是标准 multipart 文件。 -
通知被点击后,怎么跳到 App 里的指定页面? 用
Notifications.addNotificationResponseReceivedListener(response => ...)监听点击事件,从response.notification.request.content.data里取你调度时塞的自定义数据(比如{ screen: 'OrderDetail', id: 123 }),再调第 5 课的 navigationRef 跳转。 -
** Expo 没有的冷门能力(比如蓝牙打印机)怎么办?** 三条路:找社区封装好的库;用 Expo Modules API 自己写 Swift/Kotlin 封装(比想象中简单);
npx expo prebuild生成原生工程后按纯 RN 方式集成。初学者遇到这种需求,先确认 Expo 生态里真的没有再说。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 不申请权限直接 launchCameraAsync | 崩溃或静默失败 | 先 request 再 launch |
用旧版 result.cancelled、result.uri | undefined,逻辑全失效 | 新版:result.canceled + result.assets[0].uri |
| 权限被拒后反复调 request | iOS 只弹一次,之后调用无效 | 检查 canAskAgain,false 就引导去设置页 |
| 忘记配 iOS 权限用途文案 | 上架审核被拒 | app.json 的 ios.infoPlist 写清用途 |
| 在 iOS 模拟器上调试相机 | 黑屏/报错 | 换真机(Expo Go 扫码) |
| 持久化时只存临时 uri | 几天后图片打不开 | 用 expo-file-system 拷到文档目录再存路径 |
| Android 13+ 不发通知权限申请 | 通知永远不弹 | requestPermissionsAsync 统一处理 |
| 权限文案写「我们需要XX权限」 | 审核打回 | 写用户视角的收益:「用于推荐附近内容」 |
卡点急救
- 改了 app.json 权限配置不生效:配置进的是原生包,Expo Go 内置了常用权限所以开发时看不出差别;正式发布前用 EAS Build 重新打包才生效。自定义权限文案同理。
- Android 真机定位拿不到:依次检查——手机系统定位总开关是否打开、App 权限是否给了、是否在室内 GPS 搜不到星(走到窗边试试)。
- 通知设了时间不弹:检查系统设置里本 App 的通知总开关;确认 trigger 的 seconds 大于 0;前台默认不弹横幅,记得调
setNotificationHandler。 - 选图后 Image 不显示:先
console.log(uri)确认非空;source={{ uri }}注意是对象包一层;样式里必须给宽高,RN 的 Image 不给尺寸不渲染。 - launchImageLibraryAsync 闪退:多见于 npm 直装版本不匹配,改用
npx expo install expo-image-picker重装对齐版本。
面试常问
-
RN 是如何调用原生能力的? JS 代码通过 JSI(旧架构是异步 Bridge)调用原生侧注册的模块方法,原生执行完把结果回调给 JS。Expo 把相机、定位、通知等常用能力用 Expo Modules API 预封装成 JS 接口;遇到没封装的,开发者可以用同一套 API 自己写原生模块,JS 侧无感知。
-
iOS 上用户拒绝了权限,产品要求「下次进页面再问问」,怎么做? 系统授权框只弹一次,这是硬限制。能做的:检测到 status 为 denied 且 canAskAgain 为 false 时,弹自己的解释对话框,用户确认后用 Linking.openSettings() 跳到本 App 的系统设置页引导手动开启。进页面时可以再调 getPermissionsAsync(不弹框、只查询)检查状态,若已开启则恢复正常流程。
-
本地通知和远程推送的区别与适用场景? 本地通知由设备本地调度,不需要网络和服务端,适合闹钟、打卡提醒这类时间确定的场景。远程推送由服务端经 APNs(iOS)/ FCM(Android)下发,适合消息、订单状态变更这类由服务端事件触发的场景,需要设备 token 上报、证书配置和服务端集成,复杂度高一个量级。
自测三问
- 调用相机的完整流程是什么?(提示:权限申请 → 判断 status → 处理 canAskAgain → launch)
- ImagePicker 选图成功后,图片 uri 从哪个字段拿?为什么这个 uri 不能直接长期存储?
- iOS 用户拒绝了定位权限且系统不再弹框,你的补救代码该怎么写?
小结一句话
原生能力的核心套路就两步:先申请权限(被拒要优雅引导去设置页),再调 Expo 封装好的模块 API——相机、定位、通知全是这个模子,掌握一个等于掌握全部。
延伸
- Expo ImagePicker 文档
- Expo Location 文档
- Expo Notifications 文档
- 下一课:动画——用 Animated 和 Reanimated 让界面活起来,告别 PPT 式生硬切换。