2. 协程基础回顾:作用域、启动模式、挂起函数与结果传递

2.1 结构化并发原理

协程的核心价值在于将异步逻辑结构化。结构化并发确保每个协程都有明确的作用域和生命周期:父协程取消时,所有子协程自动取消;父协程等待所有子协程完成后才结束。这种机制从根本上避免了“即发即忘”导致的任务泄漏,是实现可靠权限请求流程的基石。

设计启示: 权限请求必须在明确的界面生命周期(Activity/Fragment 可见时)内启动,并在界面退出时及时取消。结构化并发让这一约束天然成立。

2.2 协程作用域对比

选择合适的作用域决定了协程的生存边界。下表对比了 Android 开发中最常用的三种作用域:

作用域生命周期取消策略适用场景
GlobalScope进程级(永不结束)不自动取消极少使用;仅限跨进程常驻任务
viewModelScope绑定 ViewModel(界面销毁时取消)ViewModel.onCleared()权限结果处理、业务逻辑编排
lifecycleScope绑定 Activity/Fragment 生命周期Lifecycle.Event.ON_DESTROY前台 UI 操作、权限请求触发
常见错误: 在 Activity 中使用 GlobalScope.launch 发起权限请求,若界面跳转后协程仍在运行,将导致权限回调泄漏或异常触发已销毁的 UI。

2.3 启动模式:launch 与 async

协程的启动模式决定了任务执行的语义和结果传递方式:

  • launch:即发即忘,返回 Job。适合触发事件、写日志、单向通知。不可直接获取返回值。
  • async:带结果的启动,返回 Deferred。通过 await() 挂起等待结果。适合并发请求再聚合结果。

通过 CoroutineStart 参数可以进一步控制启动时机:

启动模式行为典型用途
DEFAULT立即调度,可取消大部分场景
LAZY延迟启动,直到调用 start/await条件触发、按需加载
ATOMIC立即调度,启动前不可取消一次性原子操作
UNDISPATCHED在当前线程立即执行至第一个挂起点避免线程切换的开销
// launch 示例:触发权限请求
lifecycleScope.launch {
    requestCameraPermission()
}

// async 示例:并发请求两个权限
val cameraResult = coroutineScope.async { requestCameraPermission() }
val locationResult = coroutineScope.async { requestLocationPermission() }
val bothGranted = cameraResult.await() && locationResult.await()

2.4 挂起函数工作原理

suspend 关键字是协程的基石。挂起函数不会阻塞线程,它会在遇到挂起点(如 delay、await)时,将协程状态保存到 Continuation 对象中,然后让出线程。当恢复条件满足时,协程调度器将 Continuation 重新调度到线程上继续执行。

权限场景映射: requestPermission() 被实现为挂起函数后,它在等待用户授权确认时挂起协程,既不占用主线程,也不阻塞 UI 交互,待用户点击“同意/拒绝”后恢复执行。
// 将权限请求回调桥接为挂起函数
suspend fun requestCameraPermission(): Boolean = suspendCancellableCoroutine { continuation ->
    requestPermissions(
        Manifest.permission.CAMERA,
        REQUEST_CODE
    ) { _, results ->
        val granted = results.firstOrNull() == PackageManager.PERMISSION_GRANTED
        continuation.resume(granted)
    }
    // 处理协程取消时的清理
    continuation.invokeOnCancellation {
        // 移除回调、关闭对话框
    }
}

2.5 结果传递与协程间通信

除了 async/await,还有多种方式在协程间传递结果:

  • withContext:在指定调度器上执行代码块并返回结果,常用于切换线程池。
  • Channel:适用于一对多或多对一的流式数据传递。
  • Flow:冷数据流,适合事件流或状态更新。
  • suspendCoroutine / suspendCancellableCoroutine:桥接传统回调 API。
// withContext 示例:在 IO 线程处理数据,结果回到主线程
suspend fun processBitmap(uri: Uri): Bitmap = withContext(Dispatchers.IO) {
    // 耗时解码
    BitmapFactory.decodeStream(context.contentResolver.openInputStream(uri))
}

// Channel 示例:逐步推送权限请求结果
val resultChannel = Channel<Boolean>(Channel.CONFLATED)
lifecycleScope.launch {
    for (result in resultChannel) {
        updateUI(result)
    }
}
性能陷阱: withContext 存在调度开销。对于微秒级的操作(如读取一个内存变量),直接在主线程执行比用 withContext(Dispatchers.IO) 更高效。避免在小操作上滥用线程切换。

2.6 调试与内存分析

协程调试需要开启调试模式和利用专门工具:

  • JVM 参数:添加 -Dkotlinx.coroutines.debug 可在日志中输出协程文件名和挂起点行号。
  • AndroidManifest:通过 meta-data 开启调试。
  • Android Studio:Coroutines Debugger 面板可以查看所有活跃协程的创建栈和挂起位置。
<application ...>
    <meta-data
        android:name="kotlinx.coroutines.debug"
        android:value="on" />
</application>
调试技巧: 在权限请求挂起函数的入口和出口分别添加日志,可以清晰追踪协程的挂起与恢复点,帮助定位“请求未弹出”或“回调未执行”的时序问题。

2.7 结构化并发关系图

结构化并发作用域与协程生命周期 CoroutineScope launch { ... } async { ... } suspend fun requestPermission() 结果: Boolean 生命周期绑定 父协程取消 → 子协程自动取消 界面销毁 → 协程自动清理

2.8 常见错误与稳定性优化

问题原因解决方案
作用域泄漏在 GlobalScope 或未绑定的自定义 Scope 中启动协程,导致协程在界面销毁后继续运行始终使用 lifecycleScope 或 viewModelScope;如需自定义作用域,务必在 onDestroy 中调用 cancel()
未处理的 CancellationException协程被取消时,挂起函数会抛出 CancellationException,若被 try-catch 吞掉将导致协程无法正常结束捕获异常时用 try-finally 或确保重新抛出 CancellationException
错误线程调用在非主线程直接操作 UI 或在不合适的线程执行阻塞操作UI 操作使用 Dispatchers.Main;阻塞操作使用 Dispatchers.IO;用 withContext 显式切换
LAZY 模式未触发使用 LAZY 启动后忘记调用 start() 或 await(),协程永远不会执行明确是否需要延迟;若不必要,使用 DEFAULT 模式
不合理使用 withContext频繁在快速操作上切换线程,产生大量上下文切换开销对于微秒级操作,放弃线程切换直接执行
最佳实践: 权限请求流程应始终在 lifecycleScope.launch 中启动,并使用 suspendCancellableCoroutine 将回调转为挂起函数。这样既能保证生命周期自动取消,又能以同步顺序写出异步逻辑,避免嵌套回调。