Kotlin 协程基础
说实话,协程这个词我第一次听的时候觉得挺唬人的。什么「轻量级线程」、「非阻塞挂起」……后来真在项目里用上了,才发现它其实就是个能帮你把异步代码写得像同步一样的工具。尤其我们做 BLE 回调时,那些回调地狱真能把人搞疯,协程就是解药。
这一章我会把协程的四个核心概念拆开揉碎了讲:协程是什么、怎么启动、跑在哪个线程上、以及作用域怎么管。我习惯用一个比喻来理解——协程就像你写代码时能随时暂停的「小任务」,调度器决定它跑在哪条跑道上,作用域则管着它什么时候该结束。
协程到底是个啥?
我的理解很简单:协程就是一个可挂起、可恢复的代码块。挂起的时候不阻塞线程,恢复的时候从挂起点接着跑。你想想看,传统的回调要么往线程池里丢 Runnable,要么层层嵌套,调试起来真要命。协程把这些都封装成了顺序代码。
我刚开始用的时候写过一个 BLE 扫描超时逻辑,用 Handler 加 Runnable 写了一堆状态判断。后来改用协程,一行 delay 加上 withTimeoutOrNull 就搞定了,代码量直接砍半。
启动方式,你得选对工具
Kotlin 给了我们几种启动协程的方式,每种都有它的适用场景。我按使用频率排个序:
| 启动方式 | 返回值 | 典型场景 |
|---|---|---|
| launch | Job | 发一个通知、写日志、触发 UI 更新 |
| async | Deferred<T> | 并行请求然后合并结果 |
| runBlocking | T | 测试、main 函数、旧代码桥接 |
| withContext | T | 在协程内部切换线程池 |
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()
// 合并结果
}
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 | 应用程序级别 | ❌ 不要用 |
| viewModelScope | ViewModel onCleared | ✅ 页面级 BLE 交互 |
| lifecycleScope | Lifecycle Owner 销毁时 | ✅ Activity/Fragment 内使用 |
| 自定义 CoroutineScope | 由你控制 | ✅ 适合 Service 或长连接 |
为什么 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() // 取消所有关联协程
}
}