5月27日 18:05

Expo应用中如何管理权限?有哪些最佳实践?

Expo 权限管理机制

Expo 应用中,权限管理是调用相机、定位、通知等敏感能力的前提。从 Expo SDK 43 起,统一的 expo-permissions 包已被弃用,改为各模块自带的权限方法。理解这套机制并正确处理权限流转,是避免 App Store 审核被拒、提升用户体验的关键。

权限请求的基本流程

每个需要权限的 Expo 模块都提供了两个核心方法:

  • getPermissionsAsync() —— 查询当前权限状态,不会弹窗
  • requestPermissionsAsync() —— 向用户请求权限,会弹出系统授权对话框

返回值是一个包含以下字段的对象:

typescript
{ status: 'granted' | 'denied' | 'undetermined' | 'limited', granted: boolean, // status === 'granted' 的快捷判断 canAskAgain: boolean, // 用户拒绝后是否还能再次弹出 expires: 'never' | number }

其中 limited 是 iOS 14+ 的"有限访问"状态,用户只授权了部分照片或联系人。

常用权限模块及用法

相机与麦克风权限

typescript
import { Camera } from 'expo-camera'; // 请求相机权限 const { status, granted } = await Camera.requestCameraPermissionsAsync(); // 请求麦克风权限(视频录制场景) const { status: audioStatus } = await Camera.requestMicrophonePermissionsAsync(); // 仅查询权限状态,不弹窗 const { status: currentStatus } = await Camera.getCameraPermissionsAsync();

位置权限

位置权限区分前台和后台,这是一个容易踩坑的点:

typescript
import * as Location from 'expo-location'; // 前台定位 const { status } = await Location.requestForegroundPermissionsAsync(); // 后台定位(需要额外配置,审核也更严格) const { status: bgStatus } = await Location.requestBackgroundPermissionsAsync(); // 获取当前位置 if (status === 'granted') { const location = await Location.getCurrentPositionAsync({}); }

后台位置权限在 iOS 上需要在 Info.plist 中添加 UIBackgroundModes,且 Apple 审核时会要求你说明为什么前台定位不够用。

通知权限

typescript
import * as Notifications from 'expo-notifications'; const { status } = await Notifications.requestPermissionsAsync(); // 配置通知的前台展示行为 Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: false, shouldSetBadge: false, }), });

媒体库权限

typescript
import * as MediaLibrary from 'expo-media-library'; const { status } = await MediaLibrary.requestPermissionsAsync(); // Android 13+ 支持细粒度媒体权限 const { status: granularStatus } = await MediaLibrary.requestPermissionsAsync({ granularPermissions: true, }); // 保存图片到相册 const asset = await MediaLibrary.createAssetAsync(localUri);

Android 13 引入了 READ_MEDIA_IMAGESREAD_MEDIA_VIDEO 等细粒度权限,替代了旧的 READ_EXTERNAL_STORAGE。通过 granularPermissions 选项可以让 Expo 自动处理这个差异。

联系人权限

typescript
import * as Contacts from 'expo-contacts'; const { status } = await Contacts.requestPermissionsAsync(); if (status === 'granted') { const { data } = await Contacts.getContactsAsync({ fields: [Contacts.Fields.PhoneNumbers], }); }

日历权限

typescript
import * as Calendar from 'expo-calendar'; const { status } = await Calendar.requestCalendarPermissionsAsync(); if (status === 'granted') { const calendars = await Calendar.getCalendarsAsync(); const eventId = await Calendar.createEventAsync(calendarId, { title: '会议', startDate: new Date(), endDate: new Date(Date.now() + 3600000), }); }

平台配置

权限代码写对了还不够,还需要在配置文件中声明。这一步如果遗漏,iOS 上会直接崩溃,Android 上则权限永远无法授予。

iOS 配置

app.json(或 app.config.js)中通过 infoPlist 声明权限用途描述,这是 App Store 审核的必填项:

json
{ "expo": { "ios": { "infoPlist": { "NSCameraUsageDescription": "需要相机权限来扫描二维码", "NSLocationWhenInUseUsageDescription": "需要位置权限来推荐附近门店", "NSMicrophoneUsageDescription": "需要麦克风权限来录制语音消息" } } } }

描述文案应当具体说明用途,写"需要此权限以提供功能"这类泛泛的描述可能导致审核被拒。

Android 配置

Android 权限通过 permissions 数组声明,同时支持 blockedPermissions 来排除被第三方库自动引入但不使用的权限:

json
{ "expo": { "android": { "permissions": [ "CAMERA", "ACCESS_FINE_LOCATION", "RECORD_AUDIO" ], "blockedPermissions": [ "android.permission.READ_EXTERNAL_STORAGE" ] } } }

blockedPermissions 非常实用——某些 Expo 库会自动注入权限声明,如果你不使用相关功能,可以通过这个字段移除它们,避免 Play Store 因"权限与功能不匹配"而拒审。

Config Plugins 处理库权限

某些 Expo 库通过 Config Plugin 自动注入权限配置,无需手动声明:

json
{ "expo": { "plugins": [ [ "expo-image-picker", { "photosPermission": "需要访问相册以选择图片", "cameraPermission": "需要相机以拍摄照片" } ] ] } }

使用 Config Plugin 时,权限描述写在插件配置中而非 infoPlist 里,两者不要重复声明。

权限请求的最佳实践

按需请求,不要提前索取

用户一打开 App 就弹出三四个权限请求,体验非常差。应该在用户真正需要使用某个功能时再请求对应权限:

typescript
function CameraScreen() { const [hasPermission, setHasPermission] = useState<boolean | null>(null); useEffect(() => { (async () => { const { status } = await Camera.requestCameraPermissionsAsync(); setHasPermission(status === 'granted'); })(); }, []); if (hasPermission === null) { return <Text>正在检查权限...</Text>; } if (hasPermission === false) { return <NoPermissionFallback />; } return <CameraView />; }

区分"首次拒绝"和"永久拒绝"

这是权限处理中最容易被忽略的逻辑。用户首次拒绝后 canAskAgain 为 true,你还可以再次请求;但如果用户选择了"不再询问",canAskAgain 变为 false,此时只能引导用户去系统设置手动开启:

typescript
import { Linking, Alert } from 'react-native'; async function requestPermissionWithRationale() { const { status, canAskAgain } = await Camera.requestCameraPermissionsAsync(); if (status === 'granted') return true; if (canAskAgain) { // 用户点了"拒绝",但还可以再问 Alert.alert('需要相机权限', '扫码功能需要相机权限才能使用', [ { text: '取消', style: 'cancel' }, { text: '重新授权', onPress: () => requestPermissionWithRationale() }, ]); } else { // 用户选了"不再询问",只能去设置页 Alert.alert('权限被拒绝', '请在系统设置中手动开启相机权限', [ { text: '取消', style: 'cancel' }, { text: '去设置', onPress: () => Linking.openSettings() }, ]); } return false; }

Linking.openSettings() 是 React Native 提供的 API,会直接跳转到当前应用的系统设置页,iOS 和 Android 都支持。

封装权限 Hook

在多个组件中复用权限逻辑时,封装成自定义 Hook 可以避免重复代码:

typescript
import { useState, useEffect } from 'react'; type PermissionStatus = 'granted' | 'denied' | 'undetermined' | 'limited'; function usePermission( getPermission: () => Promise<{ status: PermissionStatus; canAskAgain: boolean }> ) { const [status, setStatus] = useState<PermissionStatus>('undetermined'); const [canAskAgain, setCanAskAgain] = useState(true); useEffect(() => { getPermission().then(({ status, canAskAgain }) => { setStatus(status); setCanAskAgain(canAskAgain); }); }, [getPermission]); return { status, canAskAgain }; } // 使用 const { status: cameraStatus } = usePermission( () => Camera.getCameraPermissionsAsync() );

Expo 部分模块也内置了 usePermissions Hook,比如 MediaLibrary.usePermissions(),优先使用官方提供的。

平台差异与注意事项

iOS

  • 权限用途描述(Usage Description)为必填项,缺失会导致崩溃
  • 用户可以在"设置"中随时修改权限状态,App 从后台返回前台时应重新检查
  • 后台定位、后台音频等权限需要在 UIBackgroundModes 中额外声明
  • 某些权限(如通讯录)在 iOS 14+ 支持 limited 状态,需要专门处理

Android

  • 运行时权限从 Android 6.0 开始,之前的版本在安装时自动授予
  • Android 13+ 对媒体权限做了细分(READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO
  • 可以多次弹出权限请求,但用户体验上应遵循"解释后请求"的原则
  • 某些权限(如 SYSTEM_ALERT_WINDOWWRITE_SETTINGS)需要跳转到特殊设置页,不能通过标准 API 请求

通用注意

  • 权限声明的变更需要重新构建原生代码,不能通过 OTA 更新生效
  • 开发阶段测试权限拒绝场景时,可能需要卸载重装 App 来重置权限状态
  • Expo Go 中部分权限行为与独立构建不同,正式测试应使用 Development Build

常见问题排查

权限请求没有弹窗

可能的原因:配置文件中未声明对应权限(iOS 会崩溃,Android 静默失败),或用户已永久拒绝且 canAskAgain 为 false。检查 app.json 中的权限声明和 canAskAgain 状态。

iOS 权限描述审核被拒

Apple 要求描述必须具体说明权限用途。不要写"用于提供更好的体验",而要写"用于拍摄头像照片"或"用于扫描二维码"。

后台定位权限审核被拒

Apple 对后台定位审核很严格。需要在提交时说明为什么前台定位不满足需求,同时在 App 中提供明显的定位使用指示(如蓝色状态栏)。

Android 上出现未声明的权限

某些第三方库会在 Manifest 中自动合并权限。使用 blockedPermissions 来排除不需要的权限,或者在 android/app/src/main/AndroidManifest.xml 中用 tools:node="remove" 移除。

权限状态不一致

App 从后台返回时,用户可能已经在系统设置中修改了权限。应在 AppStatechange 事件中重新检查权限状态:

typescript
import { AppState } from 'react-native'; useEffect(() => { const subscription = AppState.addEventListener('change', (nextState) => { if (nextState === 'active') { // 重新检查权限状态 Camera.getCameraPermissionsAsync().then(({ status }) => { setHasPermission(status === 'granted'); }); } }); return () => subscription.remove(); }, []);

安全设计原则

权限管理不仅是技术问题,也关系到用户信任和应用合规:

  • 最小权限原则:只请求功能所必需的权限,不要为了"将来可能用到"而提前索取
  • 透明说明:每次请求权限时,向用户解释为什么需要这个权限,特别是首次请求前
  • 优雅降级:权限被拒绝时提供替代方案,而不是让功能完全不可用
  • 状态同步:监听权限变化,及时更新 UI 状态,避免出现功能已不可用但界面未更新的情况
标签:Expo