8、权限与协程:使用协程封装权限请求、suspend函数设计、超时处理

说实话,权限请求这块,以前我写起来挺烦的。

每次都要写个回调,onRequestPermissionsResult 里塞一堆逻辑。代码一多,自己都绕晕了。后来协程普及了,我试着把权限请求封装成 suspend 函数,体验一下子清爽了很多。

这一章,我就跟你聊聊我是怎么用协程来封装权限请求的。包括 suspend 函数怎么设计、超时怎么处理、还有那些我踩过的坑。

8.1 为什么用协程处理权限?

你想想看,权限请求本质上是一个异步操作。用户点“允许”或“拒绝”,系统回调结果。这个过程天然适合用协程来表达。

用协程的好处很明显:

  • 代码线性化:不用嵌套回调,逻辑从上往下读就行。
  • 生命周期感知:配合 ViewModelScope 或 lifecycleScope,自动取消。
  • 超时控制:用户一直不点?协程可以轻松设置超时。
  • 错误处理:try-catch 一把梭,比回调里的错误分支清晰多了。

我在项目中遇到过一种情况:用户打开权限弹窗后,切到别的应用,过了几分钟才回来。这时候回调早就超时了,但代码还在等。用协程加超时,这个问题就解决了。

8.2 核心思路:用 suspendCancellableCoroutine 封装

Kotlin 协程提供了一个神器——suspendCancellableCoroutine。它能把基于回调的 API 转成 suspend 函数。

基本模式是这样的:

suspend fun requestPermission(context: Context, permission: String): Boolean {
    return suspendCancellableCoroutine { continuation ->
        // 1. 检查是否已有权限
        if (ContextCompat.checkSelfPermission(context, permission) ==
            PackageManager.PERMISSION_GRANTED
        ) {
            continuation.resume(true)
            return@suspendCancellableCoroutine
        }

        // 2. 注册回调
        val callback = object : ActivityResultCallback<Map<String, Boolean>> {
            override fun onActivityResult(result: Map<String, Boolean>) {
                val granted = result[permission] ?: false
                continuation.resume(granted)
            }
        }

        // 3. 发起请求(这里用 ActivityResultLauncher)
        launcher.launch(arrayOf(permission))

        // 4. 取消时清理
        continuation.invokeOnCancellation {
            // 清理资源,比如移除回调
        }
    }
}

嗯,这里要注意:suspendCancellableCoroutinesuspendCoroutine 的区别。前者支持取消,后者不支持。权限请求这种场景,用户可能随时离开页面,取消能力是必须的。

8.3 设计一个通用的权限请求 suspend 函数

光一个权限不够,实际项目中经常要请求多个权限。我设计了一个通用函数:

suspend fun requestPermissions(
    activity: ComponentActivity,
    permissions: Array<String>
): Map<String, Boolean> {
    return suspendCancellableCoroutine { continuation ->
        // 过滤已授权的权限
        val needRequest = permissions.filter {
            ContextCompat.checkSelfPermission(activity, it) !=
                PackageManager.PERMISSION_GRANTED
        }.toTypedArray()

        if (needRequest.isEmpty()) {
            // 全部已授权,直接返回
            continuation.resume(permissions.associateWith { true })
            return@suspendCancellableCoroutine
        }

        // 注册 ActivityResult 回调
        val launcher = activity.registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()
        ) { result ->
            // 合并结果:已授权的 + 刚请求的
            val fullResult = mutableMapOf<String, Boolean>()
            permissions.forEach { perm ->
                fullResult[perm] = result[perm] ?: true
            }
            continuation.resume(fullResult)
        }

        launcher.launch(needRequest)

        continuation.invokeOnCancellation {
            launcher.unregister()
        }
    }
}

这个函数有几个设计点:

  • 自动过滤已授权权限:避免重复弹窗,用户体验更好。
  • 返回完整结果:调用方拿到所有权限的状态,不用自己拼。
  • 取消时清理:防止内存泄漏。
我的习惯:我会把权限请求封装到一个单独的 PermissionManager 类里,用依赖注入提供。这样单元测试也好 mock。

8.4 超时处理:用户一直不点怎么办?

这个问题我遇到过好几次。用户打开权限弹窗,然后去接电话了,或者干脆把手机放一边。你的协程就一直挂在那里,不超时也不取消。

解决方案很简单:用 withTimeoutwithTimeoutOrNull 包裹请求逻辑。

suspend fun requestPermissionWithTimeout(
    activity: ComponentActivity,
    permission: String,
    timeoutMillis: Long = 10_000L
): Boolean? {
    return withTimeoutOrNull(timeoutMillis) {
        requestPermission(activity, permission)
    }
}

调用方可以这样用:

val result = requestPermissionWithTimeout(activity, Manifest.permission.CAMERA)
when (result) {
    null -> {
        // 超时了,用户没操作
        showToast("操作超时,请重试")
    }
    true -> {
        // 授权成功
        openCamera()
    }
    false -> {
        // 被拒绝了
        showRationaleDialog()
    }
}
注意withTimeout 超时后会抛出 TimeoutCancellationException,是 CancellationException 的子类。如果你用 try-catch 捕获,记得别把它吞掉,否则协程的取消机制会受影响。

我曾经踩过这个坑:在 try-catch 里写了 catch (e: Exception) { },结果协程取消时静默失败了,导致后续的权限请求全部乱套。后来改成 catch (e: TimeoutCancellationException) { } 才解决。

8.5 完整的封装示例

把上面这些整合起来,一个完整的权限请求工具类大概长这样:

class PermissionManager(private val activity: ComponentActivity) {

    private val requestMap = mutableMapOf<String, ActivityResultLauncher<Array<String>>>()

    suspend fun request(
        vararg permissions: String,
        timeoutMillis: Long = 15_000L
    ): Map<String, Boolean> {
        return withTimeout(timeoutMillis) {
            val needRequest = permissions.filter { !isGranted(it) }.toTypedArray()
            if (needRequest.isEmpty()) {
                return@withTimeout permissions.associateWith { true }
            }

            suspendCancellableCoroutine { continuation ->
                val launcher = activity.registerForActivityResult(
                    ActivityResultContracts.RequestMultiplePermissions()
                ) { result ->
                    val fullResult = permissions.associateWith { perm ->
                        result[perm] ?: isGranted(perm)
                    }
                    continuation.resume(fullResult)
                }
                requestMap[permissions.joinToString()] = launcher
                launcher.launch(needRequest)

                continuation.invokeOnCancellation {
                    launcher.unregister()
                    requestMap.remove(permissions.joinToString())
                }
            }
        }
    }

    private fun isGranted(permission: String): Boolean {
        return ContextCompat.checkSelfPermission(activity, permission) ==
                PackageManager.PERMISSION_GRANTED
    }

    fun cleanup() {
        requestMap.values.forEach { it.unregister() }
        requestMap.clear()
    }
}

8.6 知识体系总览

下面这张图,我把这一章的核心逻辑串起来了:

权限请求协程封装核心逻辑 suspend 函数入口 检查权限是否已授权 直接返回 true 发起权限请求 withTimeout 超时控制 返回 Map<String, Boolean> 协程取消 → 清理资源 主流程 取消/清理 超时处理

8.7 避坑指南

最后,分享几个我实际踩过的坑:

  • 不要在非 UI 线程启动权限请求:ActivityResultLauncher 必须在 Activity 的生命周期内使用。在后台协程里启动,会直接崩溃。
  • 重复注册问题:同一个 launcher 不要多次注册。我习惯在请求前先 unregister 旧的。
  • 权限被永久拒绝:用户勾选了“不再询问”后,弹窗不会再出现。这时候要引导用户去设置页手动开启。这个逻辑也要在协程里处理。
  • Fragment 场景:如果在 Fragment 里请求权限,记得用 registerForActivityResult 时传入 Fragment 本身,而不是 Activity。否则生命周期会错乱。

核心总结:用协程封装权限请求,本质上是把异步回调转成同步写法。核心工具是 suspendCancellableCoroutine,核心保障是 withTimeout。设计时注意生命周期绑定和资源清理,就能写出既优雅又健壮的权限请求代码。


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