2. Kotlin Coroutine 基础回顾:作用域、协程构建器
先问大家一个问题:你写得最多的协程代码长什么样?
我猜八成是 viewModelScope.launch { ... } 或者 lifecycleScope.launch { ... }。这些写法我们每天都在用,但你真的理解背后的「作用域」和「构建器」是怎么一回事吗?
不瞒你说,我早期做蓝牙项目时,就是因为没搞懂这俩概念,搞出了内存泄漏和协程泄露。蓝牙扫描一直停不下来,Activity 销毁了协程还在跑,回调里还在刷新 UI —— 那体验,啧啧。
2.1 什么是协程作用域?
协程作用域,说白了就是一个「容器」。它决定了协程的生命周期,也决定了异常传播的边界。
在 Kotlin 里,每个协程都必须在一个作用域内启动。没有作用域,就不能直接调用 launch 或 async。你可以把作用域想象成 Android 里的 Activity 或 Fragment —— 它有自己的生老病死。作用域被取消时,它里面所有的协程都会被自动取消。
举个例子:
class MyActivity : AppCompatActivity() {
private val myScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onDestroy() {
super.onDestroy()
myScope.cancel() // 手动清理
}
fun doSomething() {
myScope.launch {
// 协程代码
}
}
}
我早期踩过的一个坑:直接在 Activity 里用 GlobalScope.launch 启动蓝牙扫描。结果页面退出了,协程还在后台跑,蓝牙回调里还在更新 View —— 直接崩溃。嗯,这就是作用域没绑定的后果。
2.2 三大协程构建器
Kotlin 提供了几个常用的构建器,每个都有自己的脾性。我列了个表格,方便对比:
| 构建器 | 是否挂起当前线程 | 是否有返回值 | 异常处理方式 | 典型场景 |
|---|---|---|---|---|
launch |
否 | 无(返回 Job) | 抛到作用域外部 | 发起异步操作,不关心结果 |
async |
否 | 有(返回 Deferred<T>) | .await() 时抛出 | 需要并发获取多个返回值 |
runBlocking |
是 | 有 | 内部 try-catch | 仅用于测试或 main 函数桥接 |
先说 launch,它在项目中占 80% 以上的使用量。启动一个协程,立刻返回一个 Job。你可以用这个 Job 来单独取消这个协程,也可以等它执行完。launch 的异常会直接抛出到父作用域里,默认会取消兄弟协程。我曾经在这个细节上吃过亏:蓝牙连接的一个子协程抛出异常,导致整个作用域里其他协程跟着遭殃。后来加上了 SupervisorJob 才解决。
async 是用来做并行任务的。它返回 Deferred,你可以在需要结果的地方调用 await()。但注意:await() 是挂起函数,只能在协程或挂起函数里用。还有一点,async 内部的异常不会立刻抛出,而是等到你 await() 的时候才抛。这个行为跟 launch 不一样,容易让人迷惑。
runBlocking 我一般只在单元测试里用,或者在 main 函数里临时桥接阻塞代码。生产代码里基本不用,因为它会阻塞当前线程,破坏了协程的轻量优势。
runBlocking 启动长时间任务!它会把线程卡住,导致 ANR。
2.3 作用域的类型与生命周期绑定
在 Android 开发中,我们很少自己创建裸的 CoroutineScope。框架已经给我们提供了现成的:
viewModelScope—— ViewModel 生命周期内有效,ViewModel 被 clear 时自动取消。lifecycleScope—— Activity/Fragment 生命周期内有效,可以绑定到特定 Lifecycle 状态。GlobalScope—— 与应用进程共存亡。请慎用!用不好就是内存泄漏温床。
拿蓝牙扫描场景举例:蓝牙扫描通常是长生命周期的,但你扫描的结果却要更新到某个 Fragment 的 UI 上。如果你用 GlobalScope 启动扫描,Fragment 退出了协程还在跑,那 UI 更新就变成空指针。正确的做法是用 lifecycleScope 绑定到 Fragment,Fragment 销毁时协程自动取消。
lifecycleScope 或 viewModelScope;后台任务(如日志上报、数据缓存)可以用生命周期更长的作用域,但尽量显式管理。
2.4 协程上下文与 Job
每个协程都有一个 CoroutineContext,它是一组元素的集合:
- Dispatcher:指定协程跑在哪个线程(Main、IO、Default、Unconfined)。
- Job:任务句柄,控制协程的取消和生命周期。
- CoroutineName:调试时有用,给协程取个名字。
- CoroutineExceptionHandler:集中处理未捕获异常。
父子协程之间,上下文是继承的。子协程默认继承父协程的 Dispatcher 和 Job(除非显式覆盖)。这一点非常重要:当你用一个带 SupervisorJob 的作用域启动子协程时,子协程的失败不会影响其他子协程——这在做蓝牙多个设备同时连接时特别有用。一个连接抛异常了,其他连接不应该受影响。
2.5 一张图理清关系
下面这张 SVO 图展示了作用域、构建器、Job、上下文之间的核心关系。我把它当作「协程第一性原理图」来记忆:
从图上可以看到:作用域通过上下文来配置协程的行为,不同的构建器产生不同类型的子协程任务。但最终,所有子协程的生命周期都绑定在作用域上——作用域一取消,子协程全部凉凉。
2.6 总结几个要点,记住就够用
- 作用域是关键:要么用框架提供的(viewModelScope/lifecycleScope),要么自己创建并手动管理生命周期。
- launch 用于 fire-and-forget:蓝牙这种持续事件流,用 launch 配合回调最合适。
- async 用于并发合并结果:比如同时获取蓝牙设备信息和服务列表,等两个都完成了再一起处理。
- 不要使用 GlobalScope 做长任务:它不会自动取消,很容易就泄漏了。
- SupervisorJob 是你的朋友:当你有多个独立的子协程(比如同时连接多个蓝牙设备),用它可以避免一个失败拖累全体。
一句话记忆法: 协程是三件套 —— 作用域管生死,构建器管启动,上下文管线程。