Android 权限适配全指南:从 6.0 到 15,一次讲透
Android 权限适配全指南:从 6.0 到 15,一次讲透
Android 权限体系从 6.0(API 23)引入运行时权限开始,几乎每个大版本都在收紧。本文按版本演进梳理权限变化、给出可落地的适配代码与避坑清单,帮你一次过审、少踩坑。
目录
- 权限分类与基础
- 运行时权限(API 23+)
- 分区存储 Scoped Storage(API 29+)
- 包可见性 Package Visibility(API 30+)
- 前台服务类型(API 34+)
- 照片/视频选择器 Photo Picker(API 33+)
- 精确闹钟与后台限制(API 31+)
- 通知权限运行时化(API 33+)
- 蓝牙权限拆分(API 31+)
- Android 14/15 新增权限
- 权限请求最佳封装
- 清单与避坑速查表
权限分类与基础
Android 权限分四类:
| 类别 | 说明 | 示例 |
|---|---|---|
| Normal | 安装即授予,无需用户确认 | INTERNET、VIBRATE、WAKE_LOCK |
| Dangerous | 运行时申请,需用户授权 | READ_CONTACTS、CAMERA、ACCESS_FINE_LOCATION |
| Signature | 仅相同签名应用可获授 | READ_PHONE_STATE(部分) |
| Special | 需跳系统设置页用户手动开启 | WRITE_SETTINGS、SYSTEM_ALERT_WINDOW、MANAGE_EXTERNAL_STORAGE |
声明位置
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> <!-- 33+ 改用细粒度 -->
关键原则:
targetSdkVersion决定权限行为,而非minSdkVersion。Google Play 强制targetSdkVersion跟进最新版本。
运行时权限(API 23+)
1. 检查与请求
private const val REQ_CAMERA = 1001
fun checkAndRequestCamera(activity: ComponentActivity) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) {
openCamera()
} else {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.CAMERA),
REQ_CAMERA
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_CAMERA) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
openCamera()
} else {
if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
// 用户勾选"不再询问" → 引导去设置页
showGoToSettingsDialog()
} else {
showPermissionDeniedToast()
}
}
}
}
2. 推荐方式:ActivityResult API(替代 onRequestPermissionsResult)
class CameraActivity : ComponentActivity() {
private val cameraLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) openCamera() else handleDenied()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val permission = Manifest.permission.CAMERA
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
openCamera()
} else if (shouldShowRequestPermissionRationale(permission)) {
showRationaleDialog { cameraLauncher.launch(permission) }
} else {
cameraLauncher.launch(permission)
}
}
}
3. 一次请求多个权限
private val multiPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val allGranted = result.values.all { it }
if (allGranted) startFeature() else showDeniedGuide()
}
fun requestNeededPermissions() {
val needed = listOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
).filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needed.isEmpty()) startFeature()
else multiPermissionLauncher.launch(needed.toTypedArray())
}
4. 判断"不再询问"
fun shouldShowRationale(activity: Activity, permission: String): Boolean {
return ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
}
// 返回 false 且未授权 → 用户勾选了"不再询问",需引导设置页
5. 跳转应用详情设置页
fun openAppSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
分区存储 Scoped Storage(API 29+)
变化要点
- 应用只能访问自己的应用专属目录(
getExternalFilesDir),无需任何权限 - 访问公共目录(Pictures、DCIM、Documents、Music、Downloads)需通过 MediaStore 或 SAF
Environment.getExternalStorageDirectory()失效READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE在 API 33+ 完全失效
适配策略
策略 1:用应用专属目录(无需权限)
val file = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "my_photo.jpg")
// 直接读写,无需申请权限,卸载时清除
策略 2:用 MediaStore 写入公共目录
fun saveImageToGallery(context: Context, bitmap: Bitmap, displayName: String): Uri {
val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "$displayName.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/MyApp")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val uri = context.contentResolver.insert(collection, values)!!
context.contentResolver.openOutputStream(uri).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
context.contentResolver.update(uri, values, null, null)
return uri
}
策略 3:用 SAF 让用户选择目录
private val pickDirLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
if (uri != null) {
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
// 保存 uri 供后续使用
}
}
fun pickFolder() {
pickDirLauncher.launch(null)
}
策略 4:临时豁免(仅 API 29,不推荐长期使用)
<application android:requestLegacyExternalStorage="true">
API 30+ 此属性被忽略,必须真正适配分区存储。
读取图片(API 33+ 细粒度权限)
<!-- API 33+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- 兼容旧版本 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
fun getRequiredImagePermission(): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Manifest.permission.READ_MEDIA_IMAGES
else
Manifest.permission.READ_EXTERNAL_STORAGE
包可见性 Package Visibility(API 30+)
现象
调用 PackageManager.getInstalledApplications() 或 queryIntentActivities() 时,第三方应用默认不可见,返回空列表。
解决:声明 <queries>
方式 1:按包名
<manifest>
<queries>
<package android:name="com.tencent.mm" /> <!-- 微信 -->
<package android:name="com.alipay.sdk" />
</queries>
</manifest>
方式 2:按 Intent action
<queries>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="image/*" />
</intent>
</queries>
方式 3:全部可见(仅限调试/工具类应用,上架可能被拒)
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
Google Play 对
QUERY_ALL_PACKAGES审核严格,需填写用途说明,普通应用慎用。
前台服务类型(API 34+)
变化
Android 14 起,启动前台服务必须声明类型,且类型需在清单中声明、与运行时一致。未声明将抛 ForegroundServiceTypeNotAllowed。
清单声明
<service
android:name=".LocationService"
android:foregroundServiceType="location"
android:exported="false" />
可选类型:location、camera、microphone、mediaPlayback、dataSync、phoneCall、health、connectedDevice、mediaProjection、shortService、specialUse。
启动方式
// Android 9+ 已有 startForegroundService
ContextCompat.startForegroundService(context, Intent(context, LocationService::class.java))
// 在 Service 中
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
return START_STICKY
}
类型对应所需权限
| 类型 | 所需权限 |
|---|---|
location |
ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION |
camera |
CAMERA |
microphone |
RECORD_AUDIO |
mediaPlayback |
无(FOREGROUND_SERVICE 默认) |
mediaProjection |
运行时需用户授权 MediaProjection |
Android 14 还需声明
FOREGROUND_SERVICE_<TYPE>权限,例如FOREGROUND_SERVICE_LOCATION。
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
照片/视频选择器 Photo Picker(API 33+)
替代 READ_MEDIA_IMAGES 的官方推荐方案:系统级选择器,无需任何权限,用户隐私友好。
使用
private val pickMediaLauncher = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
// 使用 uri,临时权限自动授予,进程存活期间有效
showImage(uri)
}
}
fun pickSingleImage() {
pickMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
fun pickMultipleImages() {
// 多选需用 PickMultipleVisualMedia
val multiPicker = registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(10)
) { uris -> /* ... */ }
}
向下兼容
// 用 androidx.activity:activity-ktx 1.7+ 的 PickVisualMedia,
// 系统会自动在低版本回退到 ACTION_PICK 或 ACTION_OPEN_DOCUMENT
强烈建议:API 33+ 用 Photo Picker,避免申请
READ_MEDIA_IMAGES,过审更顺利。
精确闹钟与后台限制(API 31+)
变化
Android 12 起,SCHEDULE_EXACT_ALARM 默认不再授予,普通应用应改用 setExact() 的非精确版本或申请 USE_EXACT_ALARM(受 Google Play 审核限制)。
适配
// 检查是否能设置精确闹钟
fun canScheduleExactAlarms(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
AlarmManagerCompat.canScheduleExactAlarms(context.getSystemService(AlarmManager::class.java))
} else true
}
// 跳转授权页
fun requestExactAlarmPermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = Uri.parse("package:${context.packageName}")
})
}
}
<!-- 闹钟/提醒类应用可用 -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- 其他场景用普通精确(不保证精确) -->
通知权限运行时化(API 33+)
变化
Android 13 前通知权限随安装授予;13+ 必须运行时申请 POST_NOTIFICATIONS,未授权则通知不显示。
适配
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
private val notifPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) createNotificationChannel() else showNotifDeniedTip()
}
fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notifPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
建议时机:在用户理解通知价值后再请求,避免一启动就弹,转化率更高。
蓝牙权限拆分(API 31+)
变化
Android 12 把 BLUETOOTH / BLUETOOTH_ADMIN(旧)拆为细粒度运行时权限:
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- 兼容旧版本 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
请求
fun requestBluetoothPermissions() {
val perms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
multiPermissionLauncher.launch(perms)
}
建议在清单中给
BLUETOOTH_SCAN加android:usesPermissionFlags="neverForLocate",声明不用于定位,可省去位置权限。
Android 14/15 新增权限
Android 14(API 34)
| 权限 | 说明 |
|---|---|
FOREGROUND_SERVICE_* |
各类型前台服务需单独声明 |
READ_MEDIA_VISUAL_USER_SELECTED |
用户通过 Photo Picker "选择"了部分照片后的访问权限(API 34+) |
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
Android 15(API 35)
edge-to-edge强制:targetSdk 35时应用默认全屏,需主动处理 insets,不再需要windowInsetsController的部分行为- 16KB 页面大小:NDK 库需兼容 16KB 对齐,否则加载失败
- 前台服务超时:
shortService类型上限 3 分钟 ART更严格:部分隐藏 API 反射被进一步限制
持续关注 Behavior changes 官方文档跟进每个 DP。
权限请求最佳封装
1. 统一工具类
object PermissionHelper {
fun isGranted(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
fun areAllGranted(context: Context, permissions: Array<String>): Boolean =
permissions.all { isGranted(context, it) }
fun shouldShowRationale(activity: Activity, permission: String): Boolean =
ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
}
class PermissionRequester(
private val activity: ComponentActivity,
private val permissions: Array<String>,
private val onAllGranted: () -> Unit,
private val onDenied: (denied: List<String>, permanently: Boolean) -> Unit
) {
private val launcher = activity.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val denied = result.filter { !it.value }.keys.toList()
if (denied.isEmpty()) onAllGranted()
else {
val permanently = denied.any { !PermissionHelper.shouldShowRationale(activity, it) }
onDenied(denied, permanently)
}
}
fun request() {
val toRequest = permissions.filterNot { PermissionHelper.isGranted(activity, it) }
if (toRequest.isEmpty()) onAllGranted()
else launcher.launch(toRequest.toTypedArray())
}
}
2. 使用示例
class CameraFeatureActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val requester = PermissionRequester(
activity = this,
permissions = arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
onAllGranted = { startCamera() },
onDenied = { denied, permanently ->
if (permanently) showGoToSettingsDialog()
else showRationaleAndRetry()
}
)
requester.request()
}
}
3. Compose 中使用( accompanist 或自封装)
@Composable
fun rememberPermissionState(permission: String): PermissionState {
val context = LocalContext.current
var granted by remember {
mutableStateOf(PermissionHelper.isGranted(context, permission))
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted = it }
return remember(granted) {
PermissionState(
isGranted = granted,
launch = { launcher.launch(permission) }
)
}
}
class PermissionState(val isGranted: Boolean, val launch: () -> Unit)
@Composable
fun CameraScreen() {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
if (cameraPermission.isGranted) CameraPreview()
else Button(onClick = cameraPermission.launch) { Text("授权相机") }
}
清单与避坑速查表
各版本关键权限变化
| API | 版本 | 关键变化 | 适配动作 |
|---|---|---|---|
| 23 | 6.0 | 引入运行时权限 | requestPermissions 流程 |
| 29 | 10 | 分区存储 | MediaStore / SAF / 专属目录 |
| 30 | 11 | 包可见性 | <queries> 声明 |
| 31 | 12 | 蓝牙拆分、精确闹钟限制 | 细粒度蓝牙权限、canScheduleExactAlarms |
| 33 | 13 | 通知运行时、媒体细粒度、Photo Picker | POST_NOTIFICATIONS、READ_MEDIA_* |
| 34 | 14 | 前台服务类型必填 | foregroundServiceType + FOREGROUND_SERVICE_* |
| 35 | 15 | edge-to-edge 强制、16KB 页面对齐 | 处理 insets、NDK 重新编译 |
常见坑
targetSdk没跟进 → 行为按旧版本,但 Google Play 拒审。每年至少跟进一次。READ_EXTERNAL_STORAGE在 13+ 失效 → 改用READ_MEDIA_IMAGES/VIDEO/AUDIO。- 未声明
<queries>导致微信分享拉不起来 → 加<package android:name="com.tencent.mm"/>。 - 前台服务崩溃在 14 → 清单加
foregroundServiceType,启动传类型,并声明对应FOREGROUND_SERVICE_*权限。 - 通知不显示在 13+ → 申请
POST_NOTIFICATIONS,并先建 channel。 shouldShowRequestPermissionRationale误判 → 首次请求时它返回 false(并非"不再询问"),需配合"已请求过"标记判断。- Photo Picker uri 进程外失效 → 需要持久化时用
takePersistableUriPermission,或复制到专属目录。 MANAGE_EXTERNAL_STORAGE滥用被拒审 → 仅文件管理器类应用可用,普通应用改用 SAF/Photo Picker。- 权限组误以为"授权一个得全部" → 系统可能同组一起授,但不保证,仍需逐个检查。
- 后台位置权限单独弹窗 →
ACCESS_BACKGROUND_LOCATION不能与前台位置同时请求,需先获前台再请求后台。
后台位置权限(API 29+)
// 先确保已获 ACCESS_FINE_LOCATION,再请求后台
fun requestBackgroundLocation() {
if (!PermissionHelper.isGranted(this, Manifest.permission.ACCESS_FINE_LOCATION)) {
// 先请求前台
foregroundLocationLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
!PermissionHelper.isGranted(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
) {
// 必须跳设置页,无法直接弹窗(系统限制)
startActivity(Intent(Settings.ACTION_APPLICATION_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
})
}
}
结语
Android 权限适配是一场持续战:每年新版本都会收紧。建议:
- 每年跟进 targetSdk:Google Play 强制要求,行为变化随之生效
- 能用系统组件就不申请权限:Photo Picker、SAF、Share Sheet 优先
- 权限请求有上下文:先解释再请求,转化率与体验双赢
- 统一封装:用
ActivityResultContracts+ 工具类,告别onRequestPermissionsResult - 测试覆盖各 targetSdk 行为:尤其 29/30/33/34 四个分水岭
- 关注 Play Console 政策:
QUERY_ALL_PACKAGES、MANAGE_EXTERNAL_STORAGE、USE_EXACT_ALARM都有专项审核
权限的本质是用最小授权完成功能。当你习惯"能不能不申请这个权限"的思考方式,适配就不再痛苦。
本文基于 Android 15(API 35)编写。各厂商 ROM 可能有额外限制(如 MIUI 后台弹窗权限),需结合真机测试。
- 点赞
- 收藏
- 关注作者
评论(0)