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
viewModelScopeViewModel页面数据加载长期后台计算(容易丢失)
lifecycleScopeActivity/FragmentUI生命周期绑定ViewModel外使用
coroutineScope自定义范围测试、函数内忘记调用cancel
个人小习惯: 在Compose中,我一般不在Composable函数里直接调用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 图:本章知识体系

Kotlin协程入门:核心概念与选择 协程 Coroutine 挂起而非阻塞 结构化并发 代码顺序化 作用域 Scope GlobalScope 不推荐 viewModelScope 主流推荐 构建器 Builder launch 无返回值 async 有返回值 关键决策 UI联动 → viewModelScope + launch | 并发取数 → async + await | 不要用 GlobalScope 核心心法:理解生命周期,选择正确的Scope和Builder

图上的三个核心特性、两种作用域、两种构建器,基本把协程入门要掌握的点串起来了。你只要记住一点:生命周期绑定第一,返回值需求第二。剩下的写多了自然会习惯。

4.4 实战中容易踩的坑

最后分享三个我亲身经历过的坑:

  1. 在Composable里直接写GlobalScope.launch:重组合时协程不会取消,造成内存泄漏。我早期用Compose做demo时干过,后来被自己的crash report打醒。
  2. async不调用.await():协程照常跑,但结果丢了,异常也可能被吞。曾经有个上报日志的async,我忘了await,日志少了一半。
  3. 忘记指定Dispatcher:默认用调用者的上下文。在viewModelScope里直接launch就是Dispatchers.Main,如果你在里面做网络请求,会卡UI。记得用withContext(Dispatchers.IO){}切一下。
一个小技巧: 你在写viewModelScope.launch(Dispatchers.IO){ ... }时,内部的挂起函数会自动挂起,但IO线程池不会被浪费。比传Dispatchers.Default更明确。

好了,协程入门就这些。下个知识点我们聊聊Compose里如何正确取消协程,避免泄漏。嗯,先消化这章,有问题随时翻翻官方文档,或者直接写个Demo验证一下。