Kotlin 协程基础

说实话,协程这个词我第一次听的时候觉得挺唬人的。什么「轻量级线程」、「非阻塞挂起」……后来真在项目里用上了,才发现它其实就是个能帮你把异步代码写得像同步一样的工具。尤其我们做 BLE 回调时,那些回调地狱真能把人搞疯,协程就是解药。

这一章我会把协程的四个核心概念拆开揉碎了讲:协程是什么、怎么启动、跑在哪个线程上、以及作用域怎么管。我习惯用一个比喻来理解——协程就像你写代码时能随时暂停的「小任务」,调度器决定它跑在哪条跑道上,作用域则管着它什么时候该结束。

协程基础核心知识结构 Kotlin 协程基础体系 协程 (Coroutine) 启动方式 调度器 作用域 结构化并发 launch async / await runBlocking withContext Dispatchers.Main Dispatchers.IO Dispatchers.Default Dispatchers.Unconfined GlobalScope CoroutineScope viewModelScope lifecycleScope 父协程取消 → 子全取消 子协程异常 → 父取消 等待所有子完成 SupervisorJob(可选) 💡 核心定律 选择合适的作用域 + 调度器 = 协程不泄漏、不卡死 在 BLE 场景里,千万不要随意用 GlobalScope

协程到底是个啥?

我的理解很简单:协程就是一个可挂起、可恢复的代码块。挂起的时候不阻塞线程,恢复的时候从挂起点接着跑。你想想看,传统的回调要么往线程池里丢 Runnable,要么层层嵌套,调试起来真要命。协程把这些都封装成了顺序代码。

我刚开始用的时候写过一个 BLE 扫描超时逻辑,用 Handler 加 Runnable 写了一堆状态判断。后来改用协程,一行 delay 加上 withTimeoutOrNull 就搞定了,代码量直接砍半。

启动方式,你得选对工具

Kotlin 给了我们几种启动协程的方式,每种都有它的适用场景。我按使用频率排个序:

启动方式 返回值 典型场景
launchJob发一个通知、写日志、触发 UI 更新
asyncDeferred<T>并行请求然后合并结果
runBlockingT测试、main 函数、旧代码桥接
withContextT在协程内部切换线程池

launch:点火就跑,不关心结果

我最常用它。比如在 BLE 连接成功之后发个通知,你不需要等它返回什么数据。

// 典型 launch 用法
scope.launch(Dispatchers.IO) {
    val success = doHeavyWork() // 在 IO 线程执行
    withContext(Dispatchers.Main) {
        updateUI(success) // 切回主线程更新 UI
    }
}

async:需要结果时再用

有一次我需要同时读两个 BLE 特征值,传统的写法是两个回调串行执行,浪费了连接窗口。用 async 并行,时间几乎减半。但要小心——await 在协程被取消时也会抛出 CancellationException。

// async 并行读取两个特征
scope.launch {
    val deferred1 = async(Dispatchers.IO) { readCharacteristic(characteristic1) }
    val deferred2 = async(Dispatchers.IO) { readCharacteristic(characteristic2) }
    val result1 = deferred1.await()
    val result2 = deferred2.await()
    // 合并结果
}
⚠️ 不要滥用 async — 如果只是做一件事,就用 launch。我见过有人把所有协程都用 async 写,结果代码里全是 .await(),既啰嗦又容易因未处理异常导致崩溃。

runBlocking:知道就行,别在生产里滥用

runBlocking 会阻塞当前线程直到内部所有协程执行完毕。写单元测试时很好用,但绝不能在 Android 主线程里调它,否则必 ANR。

// 单元测试中的例子
@Test
fun testCoroutine() = runBlocking {
    val result = myRepository.fetchData()
    assertEquals("expected", result)
}

调度器:协程跑在哪个线程上?

协程本身不绑定线程。调度器决定了它跑的「跑道」。Kotlin 内置了四个调度器:

  • Dispatchers.Main — Android 主线程,用于 UI 操作。
  • Dispatchers.IO — 专为 IO 密集型任务(文件读写、网络请求、BLE 通信)设计的线程池。
  • Dispatchers.Default — CPU 密集型任务,线程数等于核心数。
  • Dispatchers.Unconfined — 不指定线程,跟着调用者的线程跑。一般不推荐。

切记:回调中不要直接启动协程不指定调度器。我在 BLE 回调里曾经漏写 Dispatchers.IO,结果协程跑在了 Binder 线程上,卡死了整个蓝牙服务。后来我养成了一个习惯:所有 BLE 回调里的协程启动,要么传 Dispatchers.IO,要么切到 Main。

💡 我个人的经验法则
BLE 的 onCharacteristicRead、onServicesDiscovered 等回调,用 withContext(Dispatchers.IO) 包裹耗时操作;UI 更新再用 withContext(Dispatchers.Main)。

作用域:协程的“生命周期”

这是我最想强调的部分。协程必须在某个作用域内启动。作用域决定了:

  • 协程什么时候被取消
  • 协程的异常如何传递

Kotlin 提供了三个常用的作用域工厂:

作用域 生命周期 BLE 场景建议
GlobalScope应用程序级别❌ 不要用
viewModelScopeViewModel onCleared✅ 页面级 BLE 交互
lifecycleScopeLifecycle Owner 销毁时✅ Activity/Fragment 内使用
自定义 CoroutineScope由你控制✅ 适合 Service 或长连接
⚠️ 曾经我踩过的坑: 记得有一次用 GlobalScope 在 BLE 回调里启动了协程,页面退出了但协程还在跑,不断尝试连接已关闭的设备,导致内存泄漏和日志刷屏。从那以后我再也没在正式代码里放过 GlobalScope。

为什么 GlobalScope 危险?

GlobalScope 启动的协程没有父协程管理,它的生命周期和应用一样长。你想想看,如果 BLE 设备断开后页面已经销毁,但协程还在后台拿着设备引用,GC 都回收不了。所以,Android 开发中你能用到 GlobalScope 的地方几乎为零。

结构化并发:协程的组织方式

这可能是协程最优雅的设计。结构化并发要求:

  • 协程必须在某个 CoroutineScope 内启动
  • 父协程取消时,所有子协程自动取消
  • 子协程抛异常,父协程也会被取消(除非用 SupervisorJob)

说白了,就是协程像文件目录一样有了父子关系。你创建一个 scope,在这个 scope 里 launch 多个协程,scope 结束了,里面的子协程全结束。这对于 BLE 连接管理特别有用:当连接断开时,你只需要取消对应的 scope,所有正在进行的读写操作自动终止。

// 自定义 scope 管理 BLE 连接
class BleConnection(private val device: BluetoothDevice) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    fun connect() {
        scope.launch {
            val gatt = device.connectGatt(...)
            gatt.discoverServices()
            // 更多操作
        }
    }

    fun disconnect() {
        scope.cancel() // 取消所有关联协程
    }
}
🔑 记住这个要点: 用 SupervisorJob 可以避免某个子协程异常直接杀死父协程。在 BLE 场景里,读取一个特征值失败了,你不希望整个连接 scope 都崩溃。我通常在 BLE 连接 scope 里使用 SupervisorJob + Dispatchers.IO。