19、权限管理:运行时权限请求,Accompanist Permissions 库,相机与存储权限实战
权限管理,说白了就是你的 App 要跟用户「借东西」。借相册、借相机、借通讯录……用户不点头,你就拿不到。我刚开始做 Android 开发那会儿,还是 targetSdk 22 的时代,安装时把所有权限一口气全要了。用户也没得选,要么全给,要么别装。后来 Android 6.0 引入了运行时权限模型,嗯,世界清净了,但也麻烦了不少。
这节课,我们就来聊聊在 Jetpack Compose 里怎么优雅地处理运行时权限。我会结合我自己踩过的坑,带你一步步搞定相机和存储权限的实战。
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+ 推荐 MediaStore 或 SAF |
| LOCATION | ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION |
前台位置与后台位置权限不同 |
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 知识体系总览
为了让你更直观地理解整个权限请求的流程,我画了一张图:
这张图把整个流程串起来了:从用户触发操作,到检查权限状态,再到发起请求、系统弹窗、用户选择,最后根据结果更新 UI。你可以把它当作一个通用的权限请求模板。
19.6 实战中的一些碎碎念
最后,分享几个我在项目中积累的小经验:
- 不要一次性请求太多权限。用户会烦。最好在真正需要某个功能时才请求对应的权限。比如用户点击「拍照」按钮时再请求相机权限,而不是一进入页面就弹窗。
- 权限状态要持久化。如果用户拒绝了权限,你最好记录一下这个状态,避免每次进入页面都弹窗。可以用 DataStore 或 SharedPreferences 存一个 flag。
- Android 11 及以上存储权限变了。如果你的 App 只 target Android 10+,建议直接用
MediaStore或ActivityResultContracts.TakePicture,根本不需要申请存储权限。我最近重构的一个项目就完全去掉了存储权限声明,省心不少。 - 测试时记得重置权限。在模拟器或测试机上,你可以在「设置 → 应用 → 你的 App → 权限」里手动开关权限,或者用 adb 命令:
adb shell pm revoke com.your.package android.permission.CAMERA。
LaunchedEffect 或 SideEffect 里直接调用 launchPermissionRequest()。这会导致无限重组,甚至 ANR。权限请求必须由用户主动触发,比如点击按钮。
好了,这节课的内容就到这里。权限管理看似简单,但细节很多。尤其是「不再询问」的处理,很多新手都会忽略。希望你能把这些代码直接用到自己的项目里,遇到问题随时翻翻这节课的流程图。