2. Kotlin Coroutine 基础回顾:作用域、协程构建器

先问大家一个问题:你写得最多的协程代码长什么样?

我猜八成是 viewModelScope.launch { ... } 或者 lifecycleScope.launch { ... }。这些写法我们每天都在用,但你真的理解背后的「作用域」和「构建器」是怎么一回事吗?

不瞒你说,我早期做蓝牙项目时,就是因为没搞懂这俩概念,搞出了内存泄漏和协程泄露。蓝牙扫描一直停不下来,Activity 销毁了协程还在跑,回调里还在刷新 UI —— 那体验,啧啧。

2.1 什么是协程作用域?

协程作用域,说白了就是一个「容器」。它决定了协程的生命周期,也决定了异常传播的边界。

在 Kotlin 里,每个协程都必须在一个作用域内启动。没有作用域,就不能直接调用 launch 或 async。你可以把作用域想象成 Android 里的 Activity 或 Fragment —— 它有自己的生老病死。作用域被取消时,它里面所有的协程都会被自动取消。

核心定义: CoroutineScope 是一个接口,持有一个 CoroutineContext(上下文)。它自身的生命周期由外部框架或开发者管理。

举个例子:

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 销毁时协程自动取消。

💡 我的习惯:凡是涉及 UI 更新的协程,一律用 lifecycleScope 或 viewModelScope;后台任务(如日志上报、数据缓存)可以用生命周期更长的作用域,但尽量显式管理。

2.4 协程上下文与 Job

每个协程都有一个 CoroutineContext,它是一组元素的集合:

  • Dispatcher:指定协程跑在哪个线程(Main、IO、Default、Unconfined)。
  • Job:任务句柄,控制协程的取消和生命周期。
  • CoroutineName:调试时有用,给协程取个名字。
  • CoroutineExceptionHandler:集中处理未捕获异常。

父子协程之间,上下文是继承的。子协程默认继承父协程的 Dispatcher 和 Job(除非显式覆盖)。这一点非常重要:当你用一个带 SupervisorJob 的作用域启动子协程时,子协程的失败不会影响其他子协程——这在做蓝牙多个设备同时连接时特别有用。一个连接抛异常了,其他连接不应该受影响。

2.5 一张图理清关系

下面这张 SVO 图展示了作用域、构建器、Job、上下文之间的核心关系。我把它当作「协程第一性原理图」来记忆:

协程作用域与构建器核心关系图 CoroutineScope(作用域) 持有 CoroutineContext(协程上下文) Dispatcher Job / SupervisorJob ExceptionHandler 通过构建器创建 launch async runBlocking 子Job(可取消) Deferred(异步结果) 阻塞线程 作用域取消时,所有子 Job 自动取消 → 蓝牙任务必须绑定生命周期作用域才能避免泄漏

从图上可以看到:作用域通过上下文来配置协程的行为,不同的构建器产生不同类型的子协程任务。但最终,所有子协程的生命周期都绑定在作用域上——作用域一取消,子协程全部凉凉。

2.6 总结几个要点,记住就够用

  1. 作用域是关键:要么用框架提供的(viewModelScope/lifecycleScope),要么自己创建并手动管理生命周期。
  2. launch 用于 fire-and-forget:蓝牙这种持续事件流,用 launch 配合回调最合适。
  3. async 用于并发合并结果:比如同时获取蓝牙设备信息和服务列表,等两个都完成了再一起处理。
  4. 不要使用 GlobalScope 做长任务:它不会自动取消,很容易就泄漏了。
  5. SupervisorJob 是你的朋友:当你有多个独立的子协程(比如同时连接多个蓝牙设备),用它可以避免一个失败拖累全体。

一句话记忆法: 协程是三件套 —— 作用域管生死,构建器管启动,上下文管线程。