4. Kotlin协程入门:协程基本概念、GlobalScope与viewModelScope、launch与async的区别
协程这东西,刚接触时我也觉得有点玄乎。说白了,它就是一套让异步代码看起来像同步写的工具。我在Android上处理网络请求、数据库读写,以前要么回调套回调,要么RxJava链式调用,代码读起来脑袋疼。用了协程之后,世界清静了不少。
4.1 协程基本概念:轻量级线程?没那么简单
很多人把协程叫做“轻量级线程”。这个说法不算错,但容易误解。线程是操作系统调度的,协程是编译器帮你切片的。你想想看,开一万个线程,系统直接崩给你看。但开一万个协程?我试过,笔记本风扇都没转。
协程的核心就三点:
- 挂起而非阻塞:遇到耗时操作,协程把自己挂起来,不占线程。等你结果回来,再恢复执行。线程在此期间可以干别的活儿。
- 结构化并发:协程跟作用域绑在一起。一个Activity销毁了,它里面的协程就该全部取消。不会出现那种“Activity没了,协程还在偷偷跑”的幽灵。
- 代码顺序化:不用写回调。用
suspend函数,像写同步代码一样写异步。
我记得刚入坑协程时,写过一段网络请求嵌套,直接写成这样:
suspend fun loadUserAndPosts(): UserWithPosts {
val user = api.fetchUser() // 挂起,等网络回来
val posts = api.fetchPosts() // 挂起,等网络回来
return UserWithPosts(user, posts)
}
看到没?没有回调,没有.subscribe()。编译器在背后帮你切线程,你只要写逻辑就行。
4.2 协程作用域:GlobalScope 与 viewModelScope
作用域是什么?说白了,就是给协程划个“生命周期边界”。
GlobalScope —— 尽量别用
GlobalScope是个全局作用域,它不绑定任何生命周期。你用GlobalScope.launch{ ... }启动的协程,会一直跑到结束,除非你手动取消。我曾经在项目里看到同事用GlobalScope做定时任务,结果Activity销毁后,协程还在后台跑,更新UI直接崩溃。嗯,这个坑我踩过。
GlobalScope。它在Compose里更是大忌——你无法保证生命周期。
viewModelScope —— 主流选择
viewModelScope是Android Jetpack提供的,绑定ViewModel的生命周期。当ViewModel清除(比如页面销毁),所有子协程自动取消。Compose配合它简直绝配。
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val data = repository.fetchData()
_uiState.value = data
}
}
}
你只需要知道:绝大多数场景,用viewModelScope就够了。它内部用的是Dispatchers.Main,但可以在launch里指定其他线程池。
| 作用域 | 生命周期 | 推荐场景 | 慎用场景 |
|---|---|---|---|
| GlobalScope | 应用进程 | 极少全局任务 | UI页面、Compose |
| viewModelScope | ViewModel | 页面数据加载 | 长期后台计算(容易丢失) |
| lifecycleScope | Activity/Fragment | UI生命周期绑定 | ViewModel外使用 |
| coroutineScope | 自定义范围 | 测试、函数内 | 忘记调用cancel |
rememberCoroutineScope()来启动协程,而是全部交给ViewModel的viewModelScope。这样状态与视图分离,测试也方便。
4.3 launch 与 async:一个没返回值,一个有返回值
这两个是启动协程最常用的构建器。区别其实就一条:
- launch:启动一个协程,不返回结果。适合“发后不管”的任务,比如写日志、更新UI、触发网络事件。
- async:启动一个协程,返回
Deferred<T>,可以通过.await()拿到结果。适合需要并行计算再汇总的场景。
我举个实际例子。以前有个需求:加载用户信息的同时加载配置,两个接口独立,然后合并展示。用async最顺手:
viewModelScope.launch {
val userDeferred = async { repository.getUser() }
val configDeferred = async { repository.getConfig() }
val user = userDeferred.await()
val config = configDeferred.await()
_uiState.value = CombinedState(user, config)
}
两个请求同时发出,等最后一起收结果。比串行快了几乎一倍。
launch返回Job,没有返回值。异常抛出到协程作用域。async返回Deferred<T>,需要调用.await()获取结果。如果你不调用.await(),那段协程相当于白跑。async内部的异常会包装到Deferred里,只有.await()时才会抛出。所以如果你忘记处理异常,可能悄悄被吃掉。
我曾经遇到过一个问题:用async获取多个结果,其中某个请求失败了,但因为没有及时.await(),异常一直没抛,直到后续某个时刻莫名其妙Crash。所以我的建议是:用async时,一定要在try/catch里调用.await(),或者在作用域顶层捕获。
async 启动的协程如果不调用 .await(),它仍然会执行完。但如果你不再需要结果,记得调用 .cancel() 取消它,否则白耗资源。
SVG 图:本章知识体系
图上的三个核心特性、两种作用域、两种构建器,基本把协程入门要掌握的点串起来了。你只要记住一点:生命周期绑定第一,返回值需求第二。剩下的写多了自然会习惯。
4.4 实战中容易踩的坑
最后分享三个我亲身经历过的坑:
- 在Composable里直接写
GlobalScope.launch:重组合时协程不会取消,造成内存泄漏。我早期用Compose做demo时干过,后来被自己的crash report打醒。 async不调用.await():协程照常跑,但结果丢了,异常也可能被吞。曾经有个上报日志的async,我忘了await,日志少了一半。- 忘记指定Dispatcher:默认用调用者的上下文。在
viewModelScope里直接launch就是Dispatchers.Main,如果你在里面做网络请求,会卡UI。记得用withContext(Dispatchers.IO){}切一下。
viewModelScope.launch(Dispatchers.IO){ ... }时,内部的挂起函数会自动挂起,但IO线程池不会被浪费。比传Dispatchers.Default更明确。
好了,协程入门就这些。下个知识点我们聊聊Compose里如何正确取消协程,避免泄漏。嗯,先消化这章,有问题随时翻翻官方文档,或者直接写个Demo验证一下。