Android 权限适配全指南:从 6.0 到 15,一次讲透

举报
shenlan9755 发表于 2026/08/23 22:28:11 2026/08/23
【摘要】 Android 权限适配全指南:从 6.0 到 15,一次讲透Android 权限体系从 6.0(API 23)引入运行时权限开始,几乎每个大版本都在收紧。本文按版本演进梳理权限变化、给出可落地的适配代码与避坑清单,帮你一次过审、少踩坑。 目录权限分类与基础运行时权限(API 23+)分区存储 Scoped Storage(API 29+)包可见性 Package Visibility(A...

Android 权限适配全指南:从 6.0 到 15,一次讲透

Android 权限体系从 6.0(API 23)引入运行时权限开始,几乎每个大版本都在收紧。本文按版本演进梳理权限变化、给出可落地的适配代码与避坑清单,帮你一次过审、少踩坑。

目录

  1. 权限分类与基础
  2. 运行时权限(API 23+)
  3. 分区存储 Scoped Storage(API 29+)
  4. 包可见性 Package Visibility(API 30+)
  5. 前台服务类型(API 34+)
  6. 照片/视频选择器 Photo Picker(API 33+)
  7. 精确闹钟与后台限制(API 31+)
  8. 通知权限运行时化(API 33+)
  9. 蓝牙权限拆分(API 31+)
  10. Android 14/15 新增权限
  11. 权限请求最佳封装
  12. 清单与避坑速查表

权限分类与基础

Android 权限分四类:

类别 说明 示例
Normal 安装即授予,无需用户确认 INTERNETVIBRATEWAKE_LOCK
Dangerous 运行时申请,需用户授权 READ_CONTACTSCAMERAACCESS_FINE_LOCATION
Signature 仅相同签名应用可获授 READ_PHONE_STATE(部分)
Special 需跳系统设置页用户手动开启 WRITE_SETTINGSSYSTEM_ALERT_WINDOWMANAGE_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)需通过 MediaStoreSAF
  • 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" />

可选类型:locationcameramicrophonemediaPlaybackdataSyncphoneCallhealthconnectedDevicemediaProjectionshortServicespecialUse

启动方式

// 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_LOCATIONACCESS_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_SCANandroid: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_NOTIFICATIONSREAD_MEDIA_*
34 14 前台服务类型必填 foregroundServiceType + FOREGROUND_SERVICE_*
35 15 edge-to-edge 强制、16KB 页面对齐 处理 insets、NDK 重新编译

常见坑

  1. targetSdk 没跟进 → 行为按旧版本,但 Google Play 拒审。每年至少跟进一次。
  2. READ_EXTERNAL_STORAGE 在 13+ 失效 → 改用 READ_MEDIA_IMAGES/VIDEO/AUDIO
  3. 未声明 <queries> 导致微信分享拉不起来 → 加 <package android:name="com.tencent.mm"/>
  4. 前台服务崩溃在 14 → 清单加 foregroundServiceType,启动传类型,并声明对应 FOREGROUND_SERVICE_* 权限。
  5. 通知不显示在 13+ → 申请 POST_NOTIFICATIONS,并先建 channel。
  6. shouldShowRequestPermissionRationale 误判 → 首次请求时它返回 false(并非"不再询问"),需配合"已请求过"标记判断。
  7. Photo Picker uri 进程外失效 → 需要持久化时用 takePersistableUriPermission,或复制到专属目录。
  8. MANAGE_EXTERNAL_STORAGE 滥用被拒审 → 仅文件管理器类应用可用,普通应用改用 SAF/Photo Picker。
  9. 权限组误以为"授权一个得全部" → 系统可能同组一起授,但不保证,仍需逐个检查。
  10. 后台位置权限单独弹窗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 权限适配是一场持续战:每年新版本都会收紧。建议:

  1. 每年跟进 targetSdk:Google Play 强制要求,行为变化随之生效
  2. 能用系统组件就不申请权限:Photo Picker、SAF、Share Sheet 优先
  3. 权限请求有上下文:先解释再请求,转化率与体验双赢
  4. 统一封装:用 ActivityResultContracts + 工具类,告别 onRequestPermissionsResult
  5. 测试覆盖各 targetSdk 行为:尤其 29/30/33/34 四个分水岭
  6. 关注 Play Console 政策QUERY_ALL_PACKAGESMANAGE_EXTERNAL_STORAGEUSE_EXACT_ALARM 都有专项审核

权限的本质是用最小授权完成功能。当你习惯"能不能不申请这个权限"的思考方式,适配就不再痛苦。


本文基于 Android 15(API 35)编写。各厂商 ROM 可能有额外限制(如 MIUI 后台弹窗权限),需结合真机测试。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。