19、权限管理:运行时权限请求,Accompanist Permissions 库,相机与存储权限实战

权限管理,说白了就是你的 App 要跟用户「借东西」。借相册、借相机、借通讯录……用户不点头,你就拿不到。我刚开始做 Android 开发那会儿,还是 targetSdk 22 的时代,安装时把所有权限一口气全要了。用户也没得选,要么全给,要么别装。后来 Android 6.0 引入了运行时权限模型,嗯,世界清净了,但也麻烦了不少。

这节课,我们就来聊聊在 Jetpack Compose 里怎么优雅地处理运行时权限。我会结合我自己踩过的坑,带你一步步搞定相机和存储权限的实战。

核心知识点:运行时权限模型、Accompanist Permissions 库的使用、相机权限 + 存储权限的联合请求、权限状态的监听与 UI 反馈。

19.1 运行时权限模型:为什么这么设计?

你想想看,如果一个计算器 App 安装时就要读取你的联系人,你会不会觉得它图谋不轨?Google 也这么想。所以从 Android 6.0(API 23)开始,危险权限必须在运行时动态申请。用户可以在任何时候去设置里关掉某个权限,你的 App 必须能优雅地处理这种「被拒绝」的情况。

权限分两类:

  • 正常权限:如访问网络、设置闹钟。直接在 AndroidManifest.xml 声明即可,系统自动授予。
  • 危险权限:如相机、位置、存储、麦克风。必须运行时弹窗请求,用户可以选择「允许」「拒绝」或「不再询问」。

我个人习惯把危险权限再细分成两组:

权限组 典型权限 备注
CAMERA android.permission.CAMERA 相机相关
STORAGE READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE Android 10 以下使用;Android 10+ 推荐 MediaStoreSAF
LOCATION ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION 前台位置与后台位置权限不同
小提示:从 Android 11 开始,MANAGE_EXTERNAL_STORAGE 变成了「特殊权限」,需要引导用户去设置页面手动开启。普通存储权限已经不够用了。

19.2 Accompanist Permissions 库:Compose 时代的权限请求利器

在传统 View 体系里,我们通常用 ActivityCompat.requestPermissions() 配合 onRequestPermissionsResult() 回调来处理。但在 Compose 里,这种回调式的写法跟声明式 UI 格格不入。你想想看,每次权限结果回来,你还得手动更新一个 State,多麻烦。

Accompanist 是 Google 官方维护的一套 Compose 扩展库,其中的 permissions 模块把权限请求封装成了可组合函数。我项目中用过好几次,确实省心不少。

首先,在 build.gradle.kts 里添加依赖:

dependencies {
    implementation("com.google.accompanist:accompanist-permissions:0.34.0")
}

然后,在 Composable 函数里这样用:

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraPermissionExample() {
    val cameraPermissionState = rememberPermissionState(
        android.Manifest.permission.CAMERA
    )

    Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
        Text("请求相机权限")
    }

    when {
        cameraPermissionState.status.isGranted -> {
            Text("✅ 相机权限已授予")
        }
        cameraPermissionState.status.shouldShowRationale -> {
            Text("ℹ️ 需要向用户解释为什么需要此权限")
        }
        else -> {
            Text("❌ 权限被拒绝")
        }
    }
}

看到了吗?rememberPermissionState 返回一个状态对象,里面包含了 status,你只需要根据这个状态去更新 UI 即可。不需要写任何回调,不需要覆写任何方法。这就是 Compose 的优雅之处。

注意rememberPermissionState 必须在 @Composable 函数中调用,并且要确保 launchPermissionRequest() 是在协程作用域内触发的(比如 Button 的 onClick 里)。我曾经在 LaunchedEffect 里直接调用它,结果权限弹窗一闪而过,因为 Composable 重组时又把状态重置了。

19.3 实战:相机权限 + 存储权限联合请求

很多场景下,你需要同时拥有相机权限和存储权限。比如用户要拍照上传头像,拍完还得保存到本地。这时候如果分开请求,用户体验会很割裂。我建议的做法是:一次性请求多个权限,然后统一处理结果。

Accompanist 提供了 rememberMultiplePermissionsState 来处理这种情况:

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraAndStoragePermissionScreen() {
    val permissions = listOf(
        android.Manifest.permission.CAMERA,
        android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
        android.Manifest.permission.READ_EXTERNAL_STORAGE
    )

    val multiplePermissionsState = rememberMultiplePermissionsState(permissions)

    // 检查是否所有权限都已授予
    val allGranted = multiplePermissionsState.permissions.all { it.status.isGranted }

    Column(modifier = Modifier.padding(16.dp)) {
        if (allGranted) {
            Text("所有权限已就绪,可以拍照了!", color = Color.Green)
            // 这里可以显示相机预览或拍照按钮
        } else {
            Text("需要相机和存储权限才能使用此功能", color = Color.Gray)
            Button(
                onClick = { multiplePermissionsState.launchMultiplePermissionRequest() },
                enabled = !multiplePermissionsState.allPermissionsGranted
            ) {
                Text("授予权限")
            }
        }

        // 显示每个权限的状态
        multiplePermissionsState.permissions.forEach { permState ->
            Text(
                text = "${permState.permission}: ${permState.status}",
                style = MaterialTheme.typography.bodySmall
            )
        }
    }
}

这里有个细节:launchMultiplePermissionRequest() 会一次性弹出多个权限请求对话框。系统会按顺序逐个询问用户。如果用户拒绝了其中一个,allGranted 就会变成 false。你可以根据 shouldShowRationale 来判断是否要显示解释说明。

避坑指南:我曾经遇到过一个情况——用户第一次拒绝了「存储权限」,并且勾选了「不再询问」。第二次再请求时,弹窗不再出现,shouldShowRationale 返回 false,权限直接变成 denied。这时候你需要引导用户去系统设置里手动开启。代码里可以加一个判断:如果权限被永久拒绝,弹出一个 Dialog 提示用户去设置。

19.4 处理「不再询问」的情况

用户一旦勾选了「不再询问」,你的 App 就再也无法通过弹窗请求权限了。唯一的办法是跳转到系统设置页面,让用户手动开启。这个逻辑在 Compose 里怎么写呢?

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun HandleDeniedForever() {
    val cameraState = rememberPermissionState(android.Manifest.permission.CAMERA)

    // 检查是否被永久拒绝
    val isPermanentlyDenied = !cameraState.status.isGranted &&
            !cameraState.status.shouldShowRationale

    if (isPermanentlyDenied) {
        AlertDialog(
            onDismissRequest = { /* 关闭对话框 */ },
            title = { Text("需要相机权限") },
            text = { Text("相机权限已被永久拒绝,请前往设置页面手动开启。") },
            confirmButton = {
                TextButton(onClick = {
                    // 跳转到应用详情设置页
                    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                        data = Uri.fromParts("package", context.packageName, null)
                    }
                    context.startActivity(intent)
                }) {
                    Text("去设置")
                }
            },
            dismissButton = {
                TextButton(onClick = { /* 取消 */ }) {
                    Text("取消")
                }
            }
        )
    }
}

嗯,这里要注意:context 需要通过 LocalContext.current 获取。跳转设置页的 Intent 是 Settings.ACTION_APPLICATION_DETAILS_SETTINGS,这个在所有 Android 版本上都通用。

19.5 知识体系总览

为了让你更直观地理解整个权限请求的流程,我画了一张图:

运行时权限请求流程(Accompanist Permissions) 用户触发操作 检查权限状态(isGranted?) ✅ 已授权,直接使用 ❌ 未授权,发起请求 系统弹窗询问用户 用户选择 允许 → 更新状态为 Granted 拒绝 → 检查 shouldShowRationale

这张图把整个流程串起来了:从用户触发操作,到检查权限状态,再到发起请求、系统弹窗、用户选择,最后根据结果更新 UI。你可以把它当作一个通用的权限请求模板。

19.6 实战中的一些碎碎念

最后,分享几个我在项目中积累的小经验:

  • 不要一次性请求太多权限。用户会烦。最好在真正需要某个功能时才请求对应的权限。比如用户点击「拍照」按钮时再请求相机权限,而不是一进入页面就弹窗。
  • 权限状态要持久化。如果用户拒绝了权限,你最好记录一下这个状态,避免每次进入页面都弹窗。可以用 DataStore 或 SharedPreferences 存一个 flag。
  • Android 11 及以上存储权限变了。如果你的 App 只 target Android 10+,建议直接用 MediaStoreActivityResultContracts.TakePicture,根本不需要申请存储权限。我最近重构的一个项目就完全去掉了存储权限声明,省心不少。
  • 测试时记得重置权限。在模拟器或测试机上,你可以在「设置 → 应用 → 你的 App → 权限」里手动开关权限,或者用 adb 命令:adb shell pm revoke com.your.package android.permission.CAMERA
重要提醒:千万不要在 LaunchedEffectSideEffect 里直接调用 launchPermissionRequest()。这会导致无限重组,甚至 ANR。权限请求必须由用户主动触发,比如点击按钮。

好了,这节课的内容就到这里。权限管理看似简单,但细节很多。尤其是「不再询问」的处理,很多新手都会忽略。希望你能把这些代码直接用到自己的项目里,遇到问题随时翻翻这节课的流程图。


公众号:蓝海资料掘金营,微信deep3321