Kotlin 协程:从入门到深度理解

29 阅读13分钟

深入理解 Kotlin 协程:从入门到实战


前言

如果你问 Android 或后端 Kotlin 开发者,过去几年里什么技术改变了他们的编程方式,协程(Coroutines)一定名列前茅。协程让异步编程变得像写同步代码一样自然,彻底告别了回调地狱。

但协程并不简单。很多开发者用了很久,仍然停留在"会用但不懂"的阶段——遇到问题时不知道为什么,出了 bug 时不知道怎么排查。

这篇博客的目标是:让你真正理解协程,而不只是会用它。


第一章:协程是什么?

1.1 协程的定义

协程(Coroutine)这个词来自计算机科学,最早可以追溯到 1958 年。它的全称是 Co-routine,即"协作式例程"。

官方对 Kotlin 协程的定义是:

协程是一种可以被挂起和恢复的计算实例(instance of suspendable computation)。

用更直白的话说:协程,本质上是"可以在执行过程中暂停,并在未来某个时刻继续执行"的代码块。

Kotlin 协程是一种轻量级线程框架,本质上是**挂起函数(suspend functions)**的调度机制。它不是线程,但可以运行在线程上,核心目标是:减少线程切换成本、简化异步代码、提升可读性。

它让你用顺序代码的方式写异步逻辑,解决回调地狱问题。

1.2 为什么叫"轻量级线程"?

这里需要区分几个概念,很多初学者会混淆:

  • 线程:操作系统级别的概念,创建和切换都有较大开销,一个进程通常只能创建几千个线程。
  • 协程:用户态的调度单元,创建成本极低,一个应用中可以轻松运行几十万个协程。

协程运行在线程之上。一个线程可以运行多个协程,当一个协程挂起时,线程去执行其他协程,而不是傻等着。这就是"轻量"的本质。

// 创建 10 万个协程,完全没问题
fun main() = runBlocking {
    val jobs = (1..100_000).map {
        launch {
            delay(1000)
        }
    }
    jobs.forEach { it.join() }
    println("10 万个协程全部完成")
}

如果换成线程,这段代码会直接 OOM(内存溢出)崩溃。

对比项线程协程
创建成本高(内核级资源,约 1MB 栈)极低(用户态,初始仅几十字节)
数量上限通常几千个可以几十万个
切换开销大(需要操作系统介入)极小(用户态切换)
阻塞行为阻塞整个线程挂起自身,线程继续工作
代码风格回调/Future顺序、直观
取消机制复杂,需要手动管理结构化取消,自动管理

1.3 协程的核心思想:挂起(suspend)而不是阻塞(block)

这是理解协程最关键的一句话。

阻塞:线程停在这里,什么也不做,等你完成。
挂起:协程暂停,让出线程,线程去做其他事,等你完成后协程再恢复。

阻塞模型:
线程 ——执行——[等待网络中...........停在这里不动]——继续执行——>

挂起模型:
协程A ——执行——[挂起]                    [恢复]——继续执行——>
线程  ——执行—————[执行协程B]——[执行协程C]——[恢复A]——>

第二章:为什么需要协程?

2.1 从一个真实问题说起

假设你在写一个 Android 应用,需要:

  1. 从网络请求用户信息
  2. 根据用户信息查询数据库
  3. 将结果显示到 UI

用传统的线程+回调方式,代码会长这样:

// 回调地狱的典型形态
fun loadUserData() {
    fetchUserFromNetwork { user ->
        if (user != null) {
            queryDatabase(user.id) { dbResult ->
                if (dbResult != null) {
                    runOnUiThread {
                        updateUI(dbResult)
                    }
                } else {
                    runOnUiThread {
                        showError("数据库查询失败")
                    }
                }
            }
        } else {
            runOnUiThread {
                showError("网络请求失败")
            }
        }
    }
}

这还只是两步操作。如果有五步、十步,代码会嵌套得让人崩溃。更糟糕的是:

  • 错误处理散落在各处,极难维护
  • 取消操作几乎无法实现
  • 生命周期管理全靠手动,容易泄漏

2.2 用协程改写

// 用协程改写后,代码像同步的,但实际上是异步的
fun loadUserData() {
    lifecycleScope.launch {
        try {
            val user = fetchUserFromNetwork()       // 挂起,不阻塞线程
            val dbResult = queryDatabase(user.id)  // 挂起,不阻塞线程
            updateUI(dbResult)                     // 自动在主线程执行
        } catch (e: Exception) {
            showError(e.message)
        }
    }
}

代码变成了线性的、清晰的,错误处理也集中了,生命周期也由 lifecycleScope 自动管理。这就是协程的魅力。


第三章:协程的核心概念

3.1 挂起函数(suspend function)

挂起函数是协程的基础单元。用 suspend 关键字修饰的函数,只能在协程或其他挂起函数中调用。

// 这是一个挂起函数
suspend fun fetchUserFromNetwork(): User {
    delay(1000) // delay 本身也是挂起函数,挂起 1 秒但不阻塞线程
    return User(id = 1, name = "张三")
}

关键理解suspend 并不意味着函数一定会挂起,它只是表示"这个函数有能力挂起"。编译器会在底层把挂起函数转换为一个状态机(State Machine),使得函数能在挂起点保存当前状态、暂停执行,并在恢复时从断点继续。

你可以把挂起函数想象成一个可以"暂停"的任务:

开始执行 → 遇到网络请求 → 【挂起,让出线程】→ 网络回来了 → 【恢复】→ 继续执行 → 完成

挂起函数只能在协程或另一个挂起函数中调用,如果你在普通函数里调用挂起函数,编译器会报错。这个限制是刻意的——它保证了挂起能力的"传染性",形成清晰的边界。

3.2 协程构建器

协程需要用构建器来启动。最常用的有三个:launchasyncrunBlocking

launch:启动一个不需要返回值的协程
val job = scope.launch {
    println("协程开始")
    delay(1000)
    println("协程结束")
}

// Job 代表协程本身,可以用来等待或取消
job.join()    // 等待协程完成
job.cancel()  // 取消协程
async:启动一个需要返回值的协程
// async 返回 Deferred<T>,可以获取结果
val deferred = scope.async {
    delay(1000)
    42  // 返回值
}

val result = deferred.await()  // 等待并获取结果
println("结果是:$result")  // 结果是:42
runBlocking:慎用,主要用于测试和顶层入口
fun main() = runBlocking {
    val result = fetchUserFromNetwork()
    println(result.name)
}

runBlocking 在实际开发中几乎不使用。 它会阻塞当前线程,和协程"挂起而不阻塞"的设计哲学背道而驰。它的用途主要有两个:

  1. main 函数中作为最顶层的协程入口(命令行程序或服务端启动)
  2. 在单元测试中使用(后来被 runTest 取代)

在 Android 开发中,你几乎不应该写 runBlocking。看到项目里有大量 runBlocking 的代码,通常意味着开发者对协程理解不够深入,把它当成了"让挂起函数能在普通函数里调用"的工具,这种用法是反模式的。

3.3 协程调度器(Dispatchers)

调度器决定协程在哪个线程或线程池上运行:

// Dispatchers.Main:主线程(只能做 UI 操作)
launch(Dispatchers.Main) {
    textView.text = "更新 UI"
}

// Dispatchers.IO:IO 密集型操作(网络、文件、数据库)
// 背后是一个大线程池,默认最多 64 个线程
launch(Dispatchers.IO) {
    val data = readFromFile()
}

// Dispatchers.Default:CPU 密集型操作(计算、排序、JSON 解析)
// 背后是 CPU 核心数量的线程池
launch(Dispatchers.Default) {
    val result = sortBigList()
}

// Dispatchers.Unconfined:不限制线程(几乎不用,除非你非常清楚自己在做什么)

一个典型的使用模式是用 withContext 切换线程:

// 整个函数在主线程启动,内部自动切换
lifecycleScope.launch {
    showLoading()
    
    val result = withContext(Dispatchers.IO) {
        // 自动切换到 IO 线程
        fetchDataFromNetwork()
    }
    // 自动切回主线程
    hideLoading()
    displayResult(result)
}

withContext 是协程内部切换线程的标准方式。它会挂起当前协程,在指定调度器上执行代码块,完成后切回原来的调度器,整个过程对调用方完全透明。


第四章:协程作用域——Android 开发的关键

这是 Android 开发中最容易踩坑、也最需要深入理解的部分。

4.1 为什么需要作用域?

协程不能凭空存在,它必须依附于某个作用域(CoroutineScope)。作用域的职责是:管理其内部所有协程的生命周期

当作用域被取消时,它内部所有正在运行的协程都会被取消。这个机制防止了资源泄漏——你不需要手动追踪每一个协程。

val scope = CoroutineScope(Dispatchers.IO)

scope.launch { /* 协程 A */ }
scope.launch { /* 协程 B */ }
scope.launch { /* 协程 C */ }

scope.cancel()  // A、B、C 全部取消,一行搞定

4.2 Android 中的三大作用域

在 Android 实际开发中,你几乎不需要手动创建 CoroutineScope。Jetpack 已经提供了和 Android 组件生命周期绑定的现成作用域:

viewModelScope——ViewModel 层的首选
class UserViewModel : ViewModel() {
    
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState = _uiState.asStateFlow()
    
    init {
        loadUser()
    }
    
    fun loadUser() {
        viewModelScope.launch {
            try {
                val user = repository.getUser()
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "未知错误")
            }
        }
    }
    
    // ViewModel 被销毁(onCleared 调用)时,viewModelScope 自动取消
    // 所有正在运行的协程也会被自动取消,无需任何手动操作
}

viewModelScope 的生命周期与 ViewModel 绑定。当 Activity 旋转屏幕时,Activity 重建但 ViewModel 不销毁,viewModelScope 内的协程也不会被取消——这正是我们想要的行为。

lifecycleScope——UI 层(Activity/Fragment)的首选
class MyActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // lifecycleScope 与 Activity 生命周期绑定
        // Activity 销毁时,协程自动取消
        lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                render(state)
            }
        }
    }
}

但注意一个细节:lifecycleScope.launch 启动的协程会在 Activity 销毁时取消,但 Activity 进入后台时不会暂停。这意味着即使 UI 不可见,协程仍在运行。

对于 Flow 的收集,更推荐使用 repeatOnLifecycle(后面专门讲)。

GlobalScope——几乎不要用
// 几乎永远不要这样写!
GlobalScope.launch {
    fetchData()
}

GlobalScope 的生命周期和整个应用进程一样长,它内部的协程不受任何组件生命周期的约束。这意味着:

  • Activity 销毁后,协程还在跑,可能继续持有 Activity 的引用,导致内存泄漏
  • 你无法统一取消这些协程
  • 无法追踪它们的运行状态

唯一合理使用 GlobalScope 的场景是:你明确希望某个操作在整个应用生命周期内都运行,比如应用启动时的初始化任务。即便如此,也应该先考虑是否有更合适的替代方案。

4.3 自定义 CoroutineScope——Repository/Service 层

有时你需要在不依赖 Android 组件的地方(比如纯 Kotlin 的 Repository 或 Service)管理协程。这时需要自定义作用域:

class DataSyncService {
    // 正确方式:创建有明确调度器和 SupervisorJob 的 scope
    private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    
    fun startSync() {
        serviceScope.launch {
            // 执行同步任务
        }
    }
    
    fun stopSync() {
        // 明确调用 cancel,释放资源
        serviceScope.cancel()
    }
}

这里用 SupervisorJob 而不是普通 Job 的原因:如果 scope 里某一个子协程失败,不会影响其他子协程(后面会详细讲)。

4.4 coroutineScope 函数——临时作用域

coroutineScope 是一个挂起函数(注意和 CoroutineScope 接口的区别),用于在挂起函数内部创建一个临时的子作用域:

suspend fun loadPageData(): PageData = coroutineScope {
    // 在这个临时作用域内并行启动多个请求
    val userDeferred = async { userRepository.getUser() }
    val postsDeferred = async { postRepository.getPosts() }
    
    // 两个请求都完成后,coroutineScope 才返回
    PageData(
        user = userDeferred.await(),
        posts = postsDeferred.await()
    )
}

coroutineScope 的特点:

  • 它是一个挂起函数,内部任何子协程未完成时它都会挂起
  • 如果任何子协程失败,其他子协程也会被取消,整个 coroutineScope 抛出异常
  • 它继承外部协程的上下文(调度器等)

实际开发中的经验总结:

场景推荐作用域
ViewModel 中的网络请求、数据加载viewModelScope
Activity/Fragment 中的 UI 操作lifecycleScope
挂起函数内部的并行操作coroutineScope { }
需要独立生命周期的 Service/Repository自定义 CoroutineScope(SupervisorJob() + Dispatchers.IO)
测试代码runTest { }
绝对不用GlobalScope(除极特殊情况)、runBlocking(除测试/main 函数)

第五章:结构化并发

5.1 什么是结构化并发?

结构化并发是 Kotlin 协程最精妙的设计之一。它的核心思想是:协程必须在特定的作用域内启动,父协程负责其所有子协程的生命周期。

这个设计解决了异步编程的一个经典难题:如何保证"你启动的任务,你一定能管住它"

fun main() = runBlocking {
    // 父协程
    launch {
        // 子协程 1
        launch {
            delay(1000)
            println("子协程 1 完成")
        }
        // 子协程 2
        launch {
            delay(2000)
            println("子协程 2 完成")
        }
        println("父协程的子协程都启动了")
    }
    // runBlocking 会等待所有子协程完成才退出
}

输出:

父协程的子协程都启动了
子协程 1 完成
子协程 2 完成

5.2 父子协程的三条规则

规则一:父协程等待所有子协程完成

val parentJob = launch {
    launch { delay(1000); println("子协程 A") }
    launch { delay(2000); println("子协程 B") }
    println("父协程体执行完了,但还在等子协程")
}
parentJob.join()
println("父协程真正完成了")

// 输出顺序:
// 父协程体执行完了,但还在等子协程
// 子协程 A
// 子协程 B
// 父协程真正完成了

规则二:取消父协程,所有子协程都被取消

val parentJob = launch {
    val childA = launch {
        try {
            delay(10000)
        } catch (e: CancellationException) {
            println("子协程 A 被取消")
        }
    }
    val childB = launch {
        try {
            delay(10000)
        } catch (e: CancellationException) {
            println("子协程 B 被取消")
        }
    }
}

delay(100)
parentJob.cancel()  // 取消父协程,A 和 B 都被取消

// 输出:
// 子协程 A 被取消
// 子协程 B 被取消

规则三:子协程异常会传播给父协程(使用默认 Job 时)

val scope = CoroutineScope(Job())
scope.launch {
    launch {
        throw RuntimeException("子协程出错了!")
        // 这个异常会导致父协程也被取消
    }
    launch {
        delay(1000)
        println("这行不会被执行,因为兄弟协程失败了")
    }
}

第六章:并发实战

6.1 串行 vs 并行——最常被误解的知识点

很多初学者容易犯的错误是:以为协程自动并行。实际上,在同一个协程里顺序调用挂起函数,默认是串行的。

// 串行执行(两个请求一个接一个)
suspend fun loadDataSerially() {
    val user = fetchUser()    // 耗时 1 秒,完成后才执行下一行
    val posts = fetchPosts()  // 耗时 1 秒
    // 总耗时:约 2 秒
}

// 并行执行(两个请求同时发出)
suspend fun loadDataParallel() = coroutineScope {
    val userDeferred = async { fetchUser() }    // 立即启动,不等待
    val postsDeferred = async { fetchPosts() }  // 立即启动,不等待
    
    val user = userDeferred.await()    // 等待结果
    val posts = postsDeferred.await()  // 此时可能已经完成了
    // 总耗时:约 1 秒(取最慢的那个)
}

实际开发中的并行模式

// 同时加载多个独立数据,等全部完成后渲染页面
data class HomePageData(
    val banner: List<Banner>,
    val articles: List<Article>,
    val userInfo: UserInfo
)

suspend fun loadHomePage(): HomePageData = coroutineScope {
    val bannerDeferred = async { bannerRepository.getBanners() }
    val articlesDeferred = async { articleRepository.getArticles() }
    val userDeferred = async { userRepository.getUser() }
    
    HomePageData(
        banner = bannerDeferred.await(),
        articles = articlesDeferred.await(),
        userInfo = userDeferred.await()
    )
}

6.2 带超时的请求

// withTimeout:超时抛出 TimeoutCancellationException
suspend fun fetchWithTimeout(): String {
    return withTimeout(3000) {
        fetchSlowData()
    }
}

// withTimeoutOrNull:超时返回 null(更常用,不需要 try-catch)
suspend fun fetchWithTimeoutOrNull(): String? {
    return withTimeoutOrNull(3000) {
        fetchSlowData()
    }
}

// 实际使用
lifecycleScope.launch {
    val result = withTimeoutOrNull(5000) {
        repository.fetchData()
    }
    if (result == null) {
        showError("请求超时,请检查网络")
    } else {
        displayData(result)
    }
}

6.3 重试机制

// 带重试的网络请求
suspend fun <T> retry(
    times: Int = 3,
    delay: Long = 1000L,
    block: suspend () -> T
): T {
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            if (e is CancellationException) throw e  // 取消异常不重试
            println("第 ${attempt + 1} 次失败,${delay}ms 后重试")
            delay(delay)
        }
    }
    return block()  // 最后一次,不捕获异常
}

// 使用
lifecycleScope.launch {
    try {
        val data = retry(times = 3, delay = 2000) {
            repository.fetchData()
        }
        displayData(data)
    } catch (e: Exception) {
        showError("重试 3 次后仍然失败:${e.message}")
    }
}

第七章:异常处理

7.1 协程的异常传播机制

协程的异常处理和普通代码有所不同,需要特别注意两种构建器的差异。

launch 中的异常:立即传播给父协程。

val scope = CoroutineScope(Job() + Dispatchers.Main)

scope.launch {
    launch {
        throw RuntimeException("子协程崩溃了")
        // 异常立即传播,导致 scope 内所有协程被取消
    }
    delay(1000)
    println("这行不会执行")  // 因为兄弟协程崩溃了
}

async 中的异常:暂存在 Deferred 中,调用 .await() 时才抛出。

val deferred = scope.async {
    throw RuntimeException("async 中的异常")
}

// 这里才会抛出异常
try {
    val result = deferred.await()
} catch (e: RuntimeException) {
    println("捕获到:${e.message}")
}

7.2 CoroutineExceptionHandler

为协程作用域设置全局异常处理器,捕获未被处理的异常:

val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
    // 可以在这里上报错误到 Sentry/Bugly 等
    // 可以显示全局错误提示
    println("未处理的协程异常:${throwable.message}")
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)

scope.launch {
    throw RuntimeException("这个会被 handler 捕获")
}

注意CoroutineExceptionHandler 只对 launch 启动的根协程有效(即直接在 scope 上 launch,而非在协程内部 launch 的子协程)。对 async 也无效。

在 Android 的 viewModelScopelifecycleScope 中,你可以在 launch 时传入:

viewModelScope.launch(exceptionHandler) {
    // 这个协程内未捕获的异常,会被 handler 处理
    val data = repository.fetchData()
}

7.3 SupervisorJob——隔离失败

默认情况下,一个子协程失败会导致整个父作用域崩溃,兄弟协程也被取消。这是"普通 Job"的行为。

SupervisorJob 改变了这一行为:某个子协程失败,其他子协程不受影响。

// 普通 Job:任意子协程失败,其他全部取消
val normalScope = CoroutineScope(Job())
normalScope.launch { throw Exception("我失败了") }
normalScope.launch { delay(1000); println("我也被取消了!") }

// SupervisorJob:子协程相互独立
val supervisorScope = CoroutineScope(SupervisorJob())
supervisorScope.launch { throw Exception("我失败了") }
supervisorScope.launch { delay(1000); println("我没事,继续运行") }

实际开发中viewModelScopelifecycleScope 内部都使用了 SupervisorJob,所以在这两个 scope 里启动的多个协程是相互独立的——一个失败不会影响其他的。

supervisorScope 函数用于在挂起函数内创建类似效果的子作用域:

suspend fun loadMultipleData() = supervisorScope {
    val result1 = async { fetchData1() }
    val result2 = async { fetchData2() }
    val result3 = async { fetchData3() }
    
    // 某个失败,其他继续,分别处理
    listOf(result1, result2, result3).mapNotNull { deferred ->
        try {
            deferred.await()
        } catch (e: Exception) {
            null  // 该请求失败,返回 null,不影响其他
        }
    }
}

7.4 try-catch 的正确姿势

lifecycleScope.launch {
    try {
        val result = fetchData()
        displayResult(result)
    } catch (e: HttpException) {
        showHttpError(e.code())
    } catch (e: IOException) {
        showNetworkError()
    } catch (e: CancellationException) {
        // 非常重要!CancellationException 不要吞掉,必须重新抛出
        // 否则取消机制会失效,协程无法正常被取消
        throw e
    }
}

或者更简洁:

lifecycleScope.launch {
    runCatching { fetchData() }
        .onSuccess { displayResult(it) }
        .onFailure { e ->
            if (e is CancellationException) throw e
            showError(e.message)
        }
}

第八章:取消与生命周期

8.1 协程取消的工作原理

协程的取消是协作式的——协程需要主动配合才能被取消。这是很多人忽视的一点。

val job = launch {
    repeat(1000) { i ->
        println("执行第 $i 次")
        delay(500)  // delay 是挂起点,取消会在这里生效
    }
}

delay(2000)
job.cancel()  // 请求取消,协程在下一个挂起点(delay)处响应取消
job.join()

8.2 让 CPU 密集型协程可取消

如果协程里没有挂起函数,它就不会响应取消:

// 这个协程无法被取消!
val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000) {
        i++  // 纯计算,没有挂起点
    }
}

job.cancel()  // 发出取消请求,但协程不响应

解决方案1:检查 isActive

val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000 && isActive) {
        i++
    }
}

解决方案2:使用 yield() 主动让出

val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000) {
        i++
        if (i % 1000 == 0) yield()  // 每 1000 次让出一次,响应取消
    }
}

8.3 取消后的资源清理

val job = launch {
    try {
        doSomeWork()
    } finally {
        // 协程被取消时,finally 块仍然执行,适合做清理
        closeConnection()
        println("连接已关闭")
    }
}

// 如果 finally 里需要调用挂起函数(默认情况下不允许,因为协程已取消)
val job2 = launch {
    try {
        doSomeWork()
    } finally {
        withContext(NonCancellable) {
            // NonCancellable 上下文中可以调用挂起函数
            saveDataToDatabase()  // 确保数据保存完成再退出
        }
    }
}

第九章:Flow——协程的数据流

9.1 什么是 Flow?

Flow 是协程的异步数据流 API,用于处理连续的多个异步值。可以把它理解为:异步版的 Sequence,或者比 RxJava 更简洁的响应式流。

  • 挂起函数:返回单个值(suspend fun getUser(): User
  • Flow:随时间推移发射多个值(fun userStream(): Flow<User>
// 创建一个简单的 Flow
fun timerFlow(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(1000)
        emit(i)  // 发射一个值
    }
}

// 收集 Flow
lifecycleScope.launch {
    timerFlow().collect { value ->
        println("收到:$value")
    }
}
// 每隔 1 秒输出一个数字:1, 2, 3, 4, 5

Flow 是冷流(Cold Flow):只有调用 collect 时,Flow 才开始执行。每次 collect 都是一次独立的执行。

9.2 Flow 的常用操作符

flow { emit(1); emit(2); emit(3); emit(4); emit(5) }
    .filter { it % 2 == 0 }    // 过滤
    .map { it * it }           // 转换
    .take(2)                   // 只取前两个
    .collect { println(it) }   // 输出:4  16
实用操作符:
// debounce:防抖,N 毫秒内无新值才发射
// 经典场景:搜索框输入防抖
searchQueryFlow.debounce(500).collect { query ->
    search(query)
}

// distinctUntilChanged:相邻重复值只发射一次
// 经典场景:避免相同关键词重复触发搜索
flow.distinctUntilChanged()

// flatMapLatest:只处理最新值,之前未完成的自动取消
// 经典场景:搜索——用户快速输入时,取消上一次搜索,只保留最新的
searchQueryFlow
    .debounce(500)
    .flatMapLatest { query ->
        flow { emit(api.search(query)) }
    }
    .collect { results -> showResults(results) }

// buffer:让生产者和消费者并发执行,避免背压问题
flow {
    for (i in 1..10) {
        delay(100)
        emit(i)
    }
}
.buffer()        // 生产者继续生产,不等消费者
.collect {
    delay(300)   // 消费者慢,但不会拖慢生产者
    println(it)
}

// conflate:只保留最新值,跳过消费者来不及处理的中间值
// 经典场景:位置更新、传感器数据,只关心最新状态
locationFlow
    .conflate()
    .collect { location -> updateMapMarker(location) }

// catch:在 Flow 中处理异常
flow { emit(fetchData()) }
    .catch { e -> emit(defaultData) }  // 出错时发射默认值
    .collect { displayData(it) }

// onEach:执行副作用,不改变数据
flow { emit(fetchData()) }
    .onEach { println("即将展示:$it") }  // 日志或埋点
    .collect { displayData(it) }

// onStart / onCompletion:Flow 开始和结束时的回调
flow { emit(fetchData()) }
    .onStart { showLoading() }
    .onCompletion { hideLoading() }
    .catch { e -> showError(e.message) }
    .collect { displayData(it) }

9.3 StateFlow 和 SharedFlow

这两个是热流(Hot Flow):无论是否有人收集,它们都处于活跃状态。

StateFlow:表示状态的 Flow

StateFlow 始终持有一个当前值,适合表示 UI 状态:

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()
    
    fun increment() {
        _count.value++
    }
    
    fun decrement() {
        _count.update { it - 1 }  // update 是线程安全的原子操作
    }
}

// Activity 中
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.count.collect { count ->
            textView.text = "计数:$count"
        }
    }
}

StateFlow 的特点:

  • 始终有值,新订阅者立即收到当前值
  • 相同的值不会重复发射(有去重机制)
  • 线程安全
  • 适合:UI 状态、配置数据
SharedFlow:表示事件的 Flow

SharedFlow 适合处理一次性事件(导航、弹窗、Toast 等):

class LoginViewModel : ViewModel() {
    // replay = 0:新订阅者不会收到历史事件
    private val _events = MutableSharedFlow<LoginEvent>()
    val events: SharedFlow<LoginEvent> = _events.asSharedFlow()
    
    fun login(username: String, password: String) {
        viewModelScope.launch {
            val result = repository.login(username, password)
            if (result.isSuccess) {
                _events.emit(LoginEvent.NavigateToHome)
            } else {
                _events.emit(LoginEvent.ShowError(result.errorMessage))
            }
        }
    }
}

sealed class LoginEvent {
    object NavigateToHome : LoginEvent()
    data class ShowError(val message: String) : LoginEvent()
}

// Activity 中
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.events.collect { event ->
            when (event) {
                is LoginEvent.NavigateToHome -> navigateToHome()
                is LoginEvent.ShowError -> showToast(event.message)
            }
        }
    }
}

StateFlow vs SharedFlow 对比

特性StateFlowSharedFlow
初始值必须有可以没有
当前值.value 属性
新订阅者立即收到当前值根据 replay 参数决定
相同值不会重复发射总是发射
适用场景UI 状态一次性事件

9.4 flowOn:指定上游的执行线程

// flowOn 改变上游操作的调度器,不影响下游(collect 所在的调度器)
fun loadArticles(): Flow<List<Article>> = flow {
    val articles = database.getAllArticles()  // 在 IO 线程执行
    emit(articles)
}
.map { articles ->
    articles.filter { it.isPublished }  // 也在 IO 线程执行
}
.flowOn(Dispatchers.IO)  // flowOn 影响它上面所有的操作

// collect 在主线程(调用者的调度器)
lifecycleScope.launch {
    loadArticles().collect { articles ->
        adapter.submitList(articles)  // 在主线程执行
    }
}

第十章:在 Android 中安全地收集 Flow

10.1 repeatOnLifecycle——推荐方式

repeatOnLifecycle 解决了一个重要问题:UI 不可见时不应该收集数据,避免浪费资源或产生副作用。

// 不推荐:即使 App 退到后台,仍在收集
lifecycleScope.launch {
    viewModel.uiState.collect { state ->
        render(state)
    }
}

// 推荐:只在 STARTED 状态时收集,进入后台自动暂停
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

repeatOnLifecycle 的工作原理:

  • 生命周期进入 STARTED 状态 → 启动内部协程,开始收集
  • 生命周期进入 STOPPED 状态(进入后台)→ 取消内部协程,停止收集
  • 再次进入 STARTED 状态(回到前台)→ 重新启动协程,重新收集

10.2 同时收集多个 Flow

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        // 同时收集多个 Flow
        launch {
            viewModel.uiState.collect { state -> render(state) }
        }
        launch {
            viewModel.events.collect { event -> handleEvent(event) }
        }
    }
}

第十一章:Mutex 和 Channel——协程间的通信与同步

11.1 Mutex:协程版的互斥锁

当多个协程访问同一共享资源时,需要同步机制:

val mutex = Mutex()
var sharedCounter = 0

// 多个协程同时修改计数器
coroutineScope {
    repeat(100) {
        launch {
            mutex.withLock {
                // 同一时间只有一个协程能进入这里
                sharedCounter++
            }
        }
    }
}
println("计数器值:$sharedCounter")  // 总是 100,没有竞争条件

11.2 Channel:协程间传递数据

Channel 类似于阻塞队列,用于在协程之间安全地传递数据:

// 生产者-消费者模式
fun main() = runBlocking {
    val channel = Channel<Int>()
    
    // 生产者
    launch {
        for (i in 1..5) {
            println("发送:$i")
            channel.send(i)
        }
        channel.close()  // 发送完毕,关闭 channel
    }
    
    // 消费者
    launch {
        for (value in channel) {
            println("接收:$value")
        }
    }
}

第十二章:测试协程

12.1 使用 runTest

测试协程使用 runTest(来自 kotlinx-coroutines-test),它会自动跳过 delay,让测试运行得很快:

// build.gradle
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.x")
@Test
fun `测试挂起函数`() = runTest {
    // runTest 内部的 delay 会被跳过,测试几乎瞬间完成
    val result = repository.getUser()
    assertEquals("张三", result.name)
}

@Test
fun `测试 delay 的时间`() = runTest {
    var result = ""
    
    launch {
        delay(1000)
        result = "完成"
    }
    
    // 虚拟时间推进 1 秒
    advanceTimeBy(1000)
    assertEquals("完成", result)
}

12.2 使用 TestDispatcher 测试 ViewModel

class UserViewModelTest {
    
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()  // 替换主线程调度器
    
    private lateinit var viewModel: UserViewModel
    private val fakeRepository = FakeUserRepository()
    
    @Before
    fun setup() {
        viewModel = UserViewModel(fakeRepository)
    }
    
    @Test
    fun `加载用户数据成功`() = runTest {
        viewModel.loadUser()
        advanceUntilIdle()  // 等待所有协程完成
        
        assertTrue(viewModel.uiState.value is UiState.Success)
    }
}

// 测试用的 MainDispatcherRule
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

第十三章:常见陷阱与最佳实践

13.1 陷阱:在非主线程更新 UI

// 错误:在 IO 线程直接更新 UI,会崩溃
viewModelScope.launch(Dispatchers.IO) {
    val data = fetchData()
    textView.text = data  // CalledFromWrongThreadException!
}

// 正确方式1:在 IO 线程做工作,结果通过 StateFlow 传到主线程
viewModelScope.launch(Dispatchers.IO) {
    val data = fetchData()
    _uiState.value = UiState.Success(data)  // StateFlow 是线程安全的
}

// 正确方式2:用 withContext 切回主线程
viewModelScope.launch(Dispatchers.IO) {
    val data = fetchData()
    withContext(Dispatchers.Main) {
        textView.text = data
    }
}

13.2 陷阱:async 的错误用法

// 错误:立即 await,等于串行
suspend fun badParallel() = coroutineScope {
    val a = async { fetchA() }.await()  // 等 A 完成
    val b = async { fetchB() }.await()  // 才开始 B
    // 完全失去了 async 的意义
}

// 正确:先全部启动,再全部 await
suspend fun goodParallel() = coroutineScope {
    val aDeferred = async { fetchA() }
    val bDeferred = async { fetchB() }
    val a = aDeferred.await()
    val b = bDeferred.await()
}

13.3 陷阱:在阻塞代码中使用挂起

// 错误:Thread.sleep 阻塞线程,协程无法取消
suspend fun badSleep() {
    Thread.sleep(5000)  // 阻塞 5 秒,协程无法响应取消
}

// 正确
suspend fun goodSleep() {
    delay(5000)  // 挂起 5 秒,可以被取消
}

// 如果必须调用阻塞的第三方 API,要在 IO 调度器上执行
suspend fun callBlockingApi(): String = withContext(Dispatchers.IO) {
    someBlockingLibrary.fetch()  // 阻塞调用在 IO 线程上是可以的
}

13.4 陷阱:忘记处理 CancellationException

// 错误:吞掉了 CancellationException,取消机制失效
suspend fun badCatch() {
    try {
        delay(10000)
    } catch (e: Exception) {
        // 所有异常都被吞掉了,包括 CancellationException
        println("出错了:${e.message}")
    }
}

// 正确
suspend fun goodCatch() {
    try {
        delay(10000)
    } catch (e: CancellationException) {
        throw e  // 取消异常必须重新抛出
    } catch (e: Exception) {
        println("出错了:${e.message}")
    }
}

13.5 Repository 层的正确写法

Repository 中的函数应该暴露挂起函数Flow,而不是内部自己启动协程:

// 错误:Repository 内部自己启动协程,调用方无法控制生命周期
class BadRepository {
    fun loadData() {
        GlobalScope.launch {  // 生命周期不受控
            val data = api.fetchData()
            // 怎么把结果传出去?用回调?又回到了回调地狱...
        }
    }
}

// 正确:暴露挂起函数,让调用方(ViewModel)决定在哪里运行
class GoodRepository(private val api: Api) {
    suspend fun loadData(): Data = withContext(Dispatchers.IO) {
        api.fetchData()
    }
    
    fun dataStream(): Flow<Data> = flow {
        while (true) {
            emit(api.fetchData())
            delay(30_000)
        }
    }.flowOn(Dispatchers.IO)
}

第十四章:综合实战案例

用一个完整的案例把前面所有内容串起来——带搜索防抖的新闻列表

// ======== 数据层 ========

data class Article(val id: Int, val title: String)

class NewsRepository(private val api: NewsApi) {
    suspend fun search(query: String): List<Article> = withContext(Dispatchers.IO) {
        api.searchArticles(query)
    }
}

// ======== ViewModel 层 ========

sealed class SearchUiState {
    object Idle : SearchUiState()
    object Loading : SearchUiState()
    data class Success(val articles: List<Article>) : SearchUiState()
    data class Error(val message: String) : SearchUiState()
}

class NewsViewModel(private val repository: NewsRepository) : ViewModel() {
    
    private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
    val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
    
    private val searchQuery = MutableStateFlow("")
    
    init {
        viewModelScope.launch {
            searchQuery
                .debounce(500)              // 防抖 500ms
                .filter { it.length >= 2 }  // 最少 2 个字符
                .distinctUntilChanged()     // 相同词不重复搜索
                .flatMapLatest { query ->   // 只关心最新的搜索,旧的自动取消
                    flow {
                        emit(SearchUiState.Loading)
                        try {
                            val articles = repository.search(query)
                            emit(SearchUiState.Success(articles))
                        } catch (e: CancellationException) {
                            throw e  // 取消异常必须重新抛出
                        } catch (e: Exception) {
                            emit(SearchUiState.Error(e.message ?: "搜索失败"))
                        }
                    }
                }
                .collect { _uiState.value = it }
        }
    }
    
    fun onSearchQueryChanged(query: String) {
        searchQuery.value = query
    }
}

// ======== UI 层 ========

class NewsActivity : AppCompatActivity() {
    private val viewModel: NewsViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        searchEditText.doAfterTextChanged { text ->
            viewModel.onSearchQueryChanged(text.toString())
        }
        
        // 使用 repeatOnLifecycle 安全收集 Flow
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    when (state) {
                        is SearchUiState.Idle -> showIdleState()
                        is SearchUiState.Loading -> showLoading()
                        is SearchUiState.Success -> {
                            hideLoading()
                            adapter.submitList(state.articles)
                        }
                        is SearchUiState.Error -> {
                            hideLoading()
                            showError(state.message)
                        }
                    }
                }
            }
        }
    }
}

总结

核心概念速查

基础

  • 协程 = 可挂起、可恢复的代码块,运行在线程上但不等同于线程
  • suspend = 函数有能力挂起,不一定真的挂起
  • launch = 启动无返回值协程,async = 启动有返回值协程
  • runBlocking = 只用于测试和 main 函数,开发中不用

作用域(重点)

  • viewModelScope = ViewModel 层首选,ViewModel 销毁时自动取消
  • lifecycleScope = UI 层首选,Activity/Fragment 销毁时自动取消
  • coroutineScope { } = 挂起函数内部的临时并行作用域
  • GlobalScope = 不要用
  • 自定义 scope = 用 SupervisorJob() + Dispatchers.IO

调度器

  • Dispatchers.Main = UI 操作
  • Dispatchers.IO = 网络、文件、数据库
  • Dispatchers.Default = CPU 密集型计算
  • withContext = 在协程内切换调度器

异常处理

  • try-catch 正常工作,但不要吞掉 CancellationException
  • CoroutineExceptionHandler = 全局兜底处理
  • SupervisorJob = 隔离子协程的失败

Flow

  • flow { emit(...) } = 冷流,每次 collect 才执行
  • StateFlow = 状态,始终有值,适合 UI 状态
  • SharedFlow = 事件,适合一次性事件
  • repeatOnLifecycle = 安全收集的标准方式

学习路径建议

  1. 先掌握基础suspendlaunchasyncwithContext、调度器
  2. 理解作用域viewModelScopelifecycleScope 的使用时机
  3. 掌握异常处理:正确使用 try-catchCancellationException
  4. 学习 Flow:从简单的 flow { } 开始,再学 StateFlowSharedFlow
  5. 实战项目:把所有知识应用到一个真实项目中

调试协程时,只需要把三个问题想清楚:这个协程的作用域是什么?它在哪个线程运行?它会在什么时候被取消? 把这三个问题想清楚,大多数问题都能迎刃而解。