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 结构化并发关系图
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 将回调转为挂起函数。这样既能保证生命周期自动取消,又能以同步顺序写出异步逻辑,避免嵌套回调。