Android 运行时权限实战:从声明权限到拒绝后的可用降级
摘要:Android 权限处理真正难的地方,不是调用一次授权 API,而是同时面对系统版本差异、用户拒绝、永久拒绝、设置页返回和业务降级。本文以拍照和选择图片为例,梳理运行时权限的完整流程,并用 Kotlin 封装一个可复用的权限状态模型,让页面能够明确处理授权、拒绝和"前往设置"三类结果。
为什么权限问题总在测试阶段暴露
很多页面只验证了"用户点击允许"这一条路径:声明权限、调用请求方法、拿到回调后继续业务。真实用户却可能拒绝权限、只允许本次使用、在系统设置中关闭权限,或者在低版本和高版本上看到完全不同的授权界面。
权限处理如果散落在 Activity、Fragment 和业务回调中,常见问题包括:
- 页面重建后重复弹窗,用户不知道应该做什么。
- 用户拒绝后仍继续调用相机或定位 API,触发异常或得到空结果。
- 用户勾选"不再询问"后,应用继续调用请求接口,但系统不再显示弹窗。
- Android 版本升级后仍申请过时的存储权限,增加审核和隐私风险。
- 权限被拒绝后没有替代流程,核心功能直接变成不可用。
因此,权限应该被视为一个有状态的业务前置条件,而不是一个布尔值。
先区分安装时权限和运行时权限
权限首先要写入 AndroidManifest.xml。以相机和通知为例:
xml
<manifest ...>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application ... />
</manifest>
Manifest 声明只是告诉系统应用可能使用某项能力,不代表用户已经授权。危险权限通常还需要在应用运行过程中向用户申请。
权限的处理可以按下面的维度判断:
| 维度 | 需要关注的内容 |
|---|---|
| 权限组 | 相机、定位、麦克风、通知、媒体等能力分别处理 |
| 系统版本 | 新版本可能拆分权限,旧版本可能使用不同权限名 |
| 使用时机 | 尽量在用户触发具体功能时申请,而不是启动就弹窗 |
| 拒绝策略 | 区分暂时拒绝、永久拒绝和系统不允许的情况 |
| 替代路径 | 没有权限时是否仍能完成部分任务 |
用 Activity Result API 请求权限
现代项目建议使用 ActivityResultContracts,让请求和回调保持在同一个组件边界内。单个权限的最小实现如下:
kotlin
class CameraActivity : AppCompatActivity() {
private val requestCamera = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
openCamera()
} else {
showCameraPermissionDenied()
}
}
private fun onTakePhotoClick() {
when {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED -> openCamera()
else -> requestCamera.launch(Manifest.permission.CAMERA)
}
}
}
这里有三个关键点:
- Launcher 在初始化阶段注册,点击事件只负责检查状态或调用
launch。 - 已授权时直接进入相机,避免每次都弹授权框。
- 回调只表达本次请求结果,后续如何降级由页面或 ViewModel 决定。
多个权限可以使用 RequestMultiplePermissions:
kotlin
private val requestMedia = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val imageGranted = result[Manifest.permission.READ_MEDIA_IMAGES] == true
val videoGranted = result[Manifest.permission.READ_MEDIA_VIDEO] == true
if (imageGranted || videoGranted) {
openMediaPicker()
} else {
showMediaFallback()
}
}
不要只看 Map 中是否存在 key。对于不同系统版本,应用真正需要的权限集合可能不同,应先根据版本构造请求列表。
处理 Android 版本差异
权限 API 的兼容代码最好集中在一个类里,不要让每个页面都写一遍版本判断。
以读取图片为例,Android 13 及以上使用媒体细分权限,较低版本通常使用外部存储读取权限:
kotlin
fun imageReadPermission(): String? {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
Manifest.permission.READ_MEDIA_IMAGES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ->
Manifest.permission.READ_EXTERNAL_STORAGE
else -> null
}
}
如果 imageReadPermission() 返回 null,表示当前系统不需要运行时申请。调用方可以直接打开选择器:
kotlin
fun chooseImage() {
val permission = imageReadPermission()
if (permission == null || hasPermission(permission)) {
openMediaPicker()
} else {
requestImagePermission.launch(permission)
}
}
不过,能使用系统 Photo Picker 时,优先使用 Photo Picker 往往比申请整个媒体库的读取权限更合适。用户只授权当前选择的资源,应用也能减少不必要的数据访问范围。
用状态模型区分拒绝类型
单纯返回 false 不足以指导界面。页面至少需要知道:当前是否已授权、是否还能再次请求、是否应该引导用户进入设置。
可以先定义一个稳定的状态模型:
kotlin
sealed interface PermissionState {
data object Granted : PermissionState
data object Requestable : PermissionState
data object PermanentlyDenied : PermissionState
}
将系统 API 封装起来:
kotlin
class PermissionChecker(private val activity: Activity) {
fun state(permission: String): PermissionState {
val granted = ContextCompat.checkSelfPermission(
activity,
permission
) == PackageManager.PERMISSION_GRANTED
if (granted) return PermissionState.Granted
val canAskAgain = ActivityCompat.shouldShowRequestPermissionRationale(
activity,
permission
)
return if (canAskAgain) {
PermissionState.Requestable
} else {
PermissionState.PermanentlyDenied
}
}
}
这里要注意一个容易误解的地方:shouldShowRequestPermissionRationale() 返回 false 并不只代表用户永久拒绝,也可能代表用户从来没有拒绝过。因此,首次请求前不要直接把 false 当成"已永久拒绝"。实际项目可以用本地状态记录是否已经请求过,再与系统结果一起判断。
一个简单的判断流程是:
kotlin
fun nextAction(
permission: String,
requestedBefore: Boolean
): PermissionState {
if (hasPermission(permission)) return PermissionState.Granted
val rationale = ActivityCompat.shouldShowRequestPermissionRationale(
activity,
permission
)
return when {
!requestedBefore -> PermissionState.Requestable
rationale -> PermissionState.Requestable
else -> PermissionState.PermanentlyDenied
}
}
这个模型不是为了准确猜测系统内部状态,而是为了把页面决策固定下来:第一次请求直接申请;用户明确拒绝后解释原因并允许重试;系统不再弹窗时引导设置或使用替代功能。
拒绝后的用户体验设计
权限被拒绝以后,页面应该给用户一个明确且可执行的选择,而不是只显示"权限不足"。以拍照为例,可以设计为:
kotlin
private fun showCameraPermissionDenied() {
val state = checker.state(Manifest.permission.CAMERA)
when (state) {
PermissionState.Requestable -> {
showPermissionDialog(
message = "拍照功能需要使用相机权限",
positive = "再次授权",
onPositive = {
requestCamera.launch(Manifest.permission.CAMERA)
}
)
}
PermissionState.PermanentlyDenied -> {
showPermissionDialog(
message = "相机权限已关闭,请在系统设置中开启",
positive = "去设置",
onPositive = ::openAppSettings
)
}
PermissionState.Granted -> openCamera()
}
}
跳转设置页时不要假设用户一定会开启权限。返回页面后重新检查:
kotlin
private val settingsLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (hasPermission(Manifest.permission.CAMERA)) {
openCamera()
} else {
showCameraPermissionDenied()
}
}
private fun openAppSettings() {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName")
)
settingsLauncher.launch(intent)
}
权限说明弹窗最好只在用户已经触发功能之后出现。启动页面就申请相机、定位等权限,既会降低授权率,也让用户难以理解权限和当前操作的关系。
把权限和业务动作隔离
权限请求应该是业务动作的前置步骤,但不应让 ViewModel 持有 Activity 或 ActivityResultLauncher。可以由页面负责系统交互,再把结果转换成业务事件:
kotlin
class ProfileViewModel : ViewModel() {
private val _events = MutableSharedFlow<ProfileEvent>()
val events = _events.asSharedFlow()
fun onAvatarSelected(uri: Uri) {
viewModelScope.launch {
_events.emit(ProfileEvent.UploadAvatar(uri))
}
}
}
Fragment 只负责权限和选择器:
kotlin
private val pickAvatar = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
uri ?: return@registerForActivityResult
viewModel.onAvatarSelected(uri)
}
这样做有两个收益:ViewModel 可以在单元测试中直接验证业务行为,页面也可以替换不同的系统入口,而不需要修改上传逻辑。
常见错误与排查顺序
只改 Manifest,不调用运行时申请
Manifest 声明不会自动弹窗。对于危险权限,必须在合适的用户操作中发起请求,并处理回调。
每次点击都直接申请
已授权时不需要重复请求。先检查 checkSelfPermission,把已授权路径和未授权路径分开。
只处理允许,不处理取消和拒绝
取消、拒绝、设置页返回、进程重建都属于正常路径。它们应该有对应的状态和界面反馈。
把永久拒绝当成所有 false
首次请求前 shouldShowRequestPermissionRationale 也可能返回 false。要结合本地"是否请求过"记录判断,不能只依赖一个系统方法。
为了选一张图片申请完整存储权限
优先考虑 Photo Picker、GetContent 等系统选择器。权限越少,隐私风险和审核解释成本越低。
在后台任务里临时申请权限
权限请求需要可见的 Activity 或 Fragment 和明确的用户交互。WorkManager、Repository 等后台组件不应该直接弹权限框,应先由前台页面完成授权,再启动任务。
测试清单
权限功能至少要覆盖以下场景:
- 首次进入功能,用户允许。
- 首次进入功能,用户拒绝。
- 用户拒绝后再次点击,再次允许。
- 用户拒绝后再次点击,继续拒绝。
- 用户关闭"不再询问"或系统不再弹窗,页面能够进入设置。
- 从设置页返回但仍未授权。
- 从设置页返回后已授权,原始业务动作能够继续。
- 横竖屏切换或页面重建时,不重复注册 Launcher。
- Android 不同版本使用正确的权限名或无需权限的系统选择器。
- 用户取消选择器、相机启动失败或返回空 Uri。
自动化测试可以把系统权限交互和业务逻辑拆开:权限封装测试状态转换,ViewModel 测试授权后的业务事件,UI 测试验证拒绝后的文案和按钮行为。这样失败时能够快速判断是系统交互、状态判断还是业务处理的问题。
总结
Android 权限的稳定实现,可以归纳为四条原则:
- Manifest 声明、运行时申请和业务使用是三个不同阶段。
- 权限请求应贴近用户触发的具体功能,并使用 Activity Result API 管理回调。
- 版本差异和系统选择器优先级应该集中封装,页面不重复编写判断。
- 拒绝不是异常,而是必须设计的状态;没有权限时要提供重试、设置和替代路径。
当权限从"弹一次窗"升级为可解释的状态模型后,页面行为会更稳定,用户也更清楚为什么需要授权以及下一步该怎么做。