Android 运行时权限实战:从声明权限到拒绝后的可用降级

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)
        }
    }
}

这里有三个关键点:

  1. Launcher 在初始化阶段注册,点击事件只负责检查状态或调用 launch。
  2. 已授权时直接进入相机,避免每次都弹授权框。
  3. 回调只表达本次请求结果,后续如何降级由页面或 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 权限的稳定实现,可以归纳为四条原则:

  1. Manifest 声明、运行时申请和业务使用是三个不同阶段。
  2. 权限请求应贴近用户触发的具体功能,并使用 Activity Result API 管理回调。
  3. 版本差异和系统选择器优先级应该集中封装,页面不重复编写判断。
  4. 拒绝不是异常,而是必须设计的状态;没有权限时要提供重试、设置和替代路径。

当权限从"弹一次窗"升级为可解释的状态模型后,页面行为会更稳定,用户也更清楚为什么需要授权以及下一步该怎么做。

相关推荐
a5986521 小时前
docker-android 6080 端口访问失败?
android·docker·容器·云手机
天意__2 小时前
Android手机上部署Linux服务器 在Termux里安装Ubuntu
android·linux·服务器
恋猫de小郭2 小时前
Flutter Android 改动:APK 不再允许通过 Intent 动态修改 Engine 参数
android·前端·flutter
修船大队长3 小时前
KL15 高电平请求 FullCom:AUTOSAR 项目调用流程
android·java·服务器
三少爷的鞋3 小时前
毁掉 Android 现代架构,只需要一个自定义的“FlowBus”
android
jack1868915 小时前
安卓手机忘记锁屏密码怎么办? 无需密码也可解锁
android·智能手机
应用市场18 小时前
不用一张图片的像素小游戏:小人是字符串,房间是矩形,平衡靠两千个假玩家
android·kotlin·游戏开发
stevenzqzq19 小时前
android 对渠道的理解
android
QEasycloud19 小时前
BOM接口抛转失败钉钉通知策略实战教程:MySQL异常日志到钉钉消息的端到端配置
android·mysql·钉钉