Kotlin 协程系统指南(从入门到高级实战)

目标读者:Android/Kotlin 开发者
阅读目标:不仅会“用 API”,还要理解协程的设计思想、边界和工程落地方式。


目录

  1. 协程为什么出现:先解决了什么问题
  2. 协程核心概念全景图
  3. 第一段协程代码(逐行讲解)
  4. suspend 的本质:挂起不是阻塞
  5. 作用域与生命周期:结构化并发的入口
  6. Dispatcher 与线程切换策略
  7. 并发组合:launch/async/await 正确使用
  8. 取消与超时:可控结束比成功更重要
  9. 异常传播与 Supervisor 思维模型
  10. Flow 入门:什么是冷流(Cold Flow)
  11. 冷流 vs 热流:StateFlow/SharedFlow 全面对比
  12. Flow 常见操作符与背压处理
  13. Android 架构落地:ViewModel + Repository
  14. Channel 与 Actor:协程间通信与串行化状态
  15. 高级主题:并发安全、性能优化、调试手段
  16. 协程测试:runTest、虚拟时间与可测性
  17. 完整业务案例:审批页并发加载 + 重试 + 超时 + 事件流
  18. 常见误区与实践清单(可直接贴团队规范)

1. 协程为什么出现:先解决了什么问题

1.1 传统线程模型的痛点

  • 线程创建和上下文切换成本高
  • 回调嵌套(callback hell)使代码难维护
  • 异步任务生命周期难统一管理(容易泄漏)
  • 错误处理分散(try-catch 到处写)

1.2 协程提供的价值

  • 用“同步代码风格”表达异步逻辑
  • 轻量级并发(可创建大量协程)
  • 结构化并发保证任务生命周期可控
  • 配合 Flow/StateFlow 可自然描述 UI 数据流

1.3 一个直观类比

把“线程”想成“整间会议室”,创建和切换都昂贵;
把“协程”想成“会议中的议程项”,挂起时让出时间片,成本很低。


2. 协程核心概念全景图

  • Coroutine:可挂起可恢复的任务单元
  • suspend:标记可挂起函数
  • CoroutineScope:协程的生命周期容器
  • Job:协程句柄(取消、状态跟踪)
  • Dispatcher:运行调度器(Main/IO/Default)
  • CoroutineContext:上下文集合(Job + Dispatcher + Name + Handler)

可以先记住一句话:

协程 = 任务(suspend) + 生命周期(Scope/Job) + 执行策略(Dispatcher)


3. 第一段协程代码(逐行讲解)

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("main start: ${Thread.currentThread().name}")

    val job = launch {
        println("child launch")
        delay(500)
        println("child done")
    }

    println("main waiting")
    job.join()
    println("main end")
}

说明:

  • runBlocking:桥接普通函数与协程世界,阻塞当前线程直到内部完成(测试/示例常用)
  • launch:启动无返回值协程
  • delay:挂起,不阻塞线程
  • join:等待 job 完成

4. suspend 的本质:挂起不是阻塞

4.1 什么是“挂起”

挂起是:当前协程暂时停下,把线程让给别人,等条件满足再恢复。

4.2 生活类比

你在餐厅点餐后不会一直站在柜台前堵住别人(阻塞),而是拿号去等(挂起),叫号再回来继续。

4.3 代码示例

suspend fun fetchUser(): String {
    delay(300)
    return "Tom"
}

suspend fun loadPage() {
    val user = fetchUser()
    println("user=$user")
}

4.4 常见误区

  • suspend 不是“自动异步线程切换”
  • suspend 函数内部如果做 CPU 重活且不切到 Default,依旧可能卡主线程

5. 作用域与生命周期:结构化并发的入口

5.1 为什么必须有 Scope

没有作用域就像“放飞无人机不设返航”:任务何时结束、失败如何收口都不清楚。

5.2 结构化并发原则

  • 子协程属于父协程
  • 父协程结束/取消,子协程自动结束
  • 避免 GlobalScope 这种脱离生命周期的“野生协程”

5.3 示例:coroutineScope

suspend fun loadUserPage(repo: Repo): UserPage = coroutineScope {
    val profile = async { repo.getProfile() }
    val posts = async { repo.getPosts() }

    UserPage(
        profile = profile.await(),
        posts = posts.await()
    )
}

coroutineScope 保证:任何子任务失败,整体感知并收敛。


6. Dispatcher 与线程切换策略

6.1 常见 Dispatcher

  • Dispatchers.Main:UI 更新
  • Dispatchers.IO:网络、文件、数据库
  • Dispatchers.Default:CPU 计算

6.2 推荐线程策略

  • ViewModel 层在 Main
  • Repository 做 IO 切换
  • 重计算切到 Default

6.3 示例

suspend fun loadDetail(api: Api): Detail = withContext(Dispatchers.IO) {
    api.getDetail()
}

suspend fun calc(items: List<Int>): Int = withContext(Dispatchers.Default) {
    items.sum()
}

7. 并发组合:launch/async/await 正确使用

7.1 何时用 launch

  • 只关心执行,不关心返回值(如上报日志、触发事件)

7.2 何时用 async

  • 需要结果,且可能并发等待多个结果

7.3 页面并发加载示例

suspend fun loadDashboard(api: Api): Dashboard = coroutineScope {
    val profile = async { api.profile() }
    val notices = async { api.notices() }
    val stats = async { api.stats() }

    Dashboard(
        profile = profile.await(),
        notices = notices.await(),
        stats = stats.await()
    )
}

7.4 注意

  • async 的异常在 await() 时抛出
  • 不要“创建了 async 却忘记 await”

8. 取消与超时:可控结束比成功更重要

8.1 为什么取消重要

App 页面退出、任务过时、用户主动中断,都需要及时取消,避免浪费资源。

8.2 基础取消

val job = scope.launch {
    repeat(10) {
        delay(200)
        println("work $it")
    }
}

job.cancel()

8.3 协作式取消(CPU 任务)

scope.launch(Dispatchers.Default) {
    while (isActive) {
        // heavy compute
    }
}

8.4 超时

val result = withTimeoutOrNull(1000) {
    api.longTask()
}

9. 异常传播与 Supervisor 思维模型

9.1 默认传播模型

在普通 coroutineScope 中,某个子协程失败通常会取消同级。

9.2 CoroutineExceptionHandler 适用范围

主要对根 launch 有效;async 要在 await 处处理。

9.3 Supervisor:局部失败不拖垮全局

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

scope.launch {
    val a = launch { error("A fail") }
    val b = launch { delay(300); println("B still running") }
    joinAll(a, b)
}

适合场景:页面多个模块并行加载,某个模块失败不影响其他模块展示。


10. Flow 入门:什么是冷流(Cold Flow)

10.1 定义

冷流:只有在被收集(collect)时才开始生产数据;每个收集者都会触发一套新的生产过程。

10.2 生活类比

冷流像“点播视频”:你点开才开始播放;每个人点开都是自己的播放进度。

10.3 示例(证明“每次 collect 都重新执行”)

fun tickerFlow() = flow {
    println("flow start")
    repeat(3) {
        delay(300)
        emit(it)
    }
}

scope.launch {
    tickerFlow().collect { println("collector A -> $it") }
}

scope.launch {
    tickerFlow().collect { println("collector B -> $it") }
}

你会看到 flow start 打印两次,因为两次收集是两套独立执行。

10.4 适用场景

  • 用户操作触发一次网络请求流
  • 需要“懒执行”数据处理流水线

11. 冷流 vs 热流:StateFlow/SharedFlow 全面对比

11.1 什么是热流

热流:不依赖是否有人收集,数据源可能一直在产生数据。

生活类比:

  • 热流像“广播电台”,电台一直播,听众随时加入
  • 冷流像“点播节目”,你点开才播放

11.2 三者定位

  • Flow:冷流,按需执行
  • StateFlow:热流,保存并提供“最新状态”
  • SharedFlow:热流,广播事件,可配置缓存/replay

11.3 StateFlow 示例(状态)

data class UiState(
    val loading: Boolean = false,
    val data: String? = null,
    val error: String? = null
)

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState

fun setLoading() {
    _uiState.value = _uiState.value.copy(loading = true)
}

特点:

  • 必须有初始值
  • 永远有最新值
  • 新订阅者会立刻拿到当前值

11.4 SharedFlow 示例(事件)

sealed interface UiEvent {
    data class Toast(val message: String): UiEvent
    data object NavigateBack: UiEvent
}

private val _events = MutableSharedFlow<UiEvent>(
    replay = 0,
    extraBufferCapacity = 1
)
val events: SharedFlow<UiEvent> = _events

suspend fun sendToast(msg: String) {
    _events.emit(UiEvent.Toast(msg))
}

特点:

  • 适合一次性事件(Toast、导航、弹窗)
  • 可控制 replay/buffer 行为

11.5 常见选择规则

  • 页面“状态” -> StateFlow
  • 页面“一次性事件” -> SharedFlow
  • 计算/请求流水线 -> Flow

12. Flow 常见操作符与背压处理

12.1 常见操作符

flowOf(1, 2, 3, 4)
    .map { it * 2 }
    .filter { it > 4 }
    .onEach { println("emit=$it") }
    .catch { e -> println("error=${e.message}") }
    .collect()

12.2 高频输入场景(搜索框)

queryFlow
    .debounce(300)
    .distinctUntilChanged()
    .flatMapLatest { keyword -> repository.search(keyword) }
    .collect { result -> render(result) }

解释:

  • debounce:防抖
  • distinctUntilChanged:去重
  • flatMapLatest:只保留最新请求(旧请求自动取消)

12.3 背压与处理策略

  • buffer():生产和消费解耦
  • conflate():只保留最新值,跳过中间值
  • collectLatest():来新值就取消上一次处理

13. Android 架构落地:ViewModel + Repository

13.1 推荐职责分层

  • ViewModel:状态与事件编排
  • Repository:数据获取与线程切换
  • DataSource/API:具体 IO 实现

13.2 示例

class ApproveViewModel(
    private val repository: ApproveRepository
): ViewModel() {

    private val _state = MutableStateFlow(ApproveUiState())
    val state: StateFlow<ApproveUiState> = _state

    private val _events = MutableSharedFlow<String>()
    val events: SharedFlow<String> = _events

    fun load(procInsId: String) {
        viewModelScope.launch {
            _state.update { it.copy(loading = true) }
            runCatching { repository.fetchDetail(procInsId) }
                .onSuccess { detail ->
                    _state.update { it.copy(loading = false, detail = detail) }
                }
                .onFailure { e ->
                    _state.update { it.copy(loading = false) }
                    _events.emit(e.message ?: "加载失败")
                }
        }
    }
}
class ApproveRepository(private val api: ApproveApi) {
    suspend fun fetchDetail(id: String): ApproveDetail = withContext(Dispatchers.IO) {
        api.getDetail(id)
    }
}

14. Channel 与 Actor:协程间通信与串行化状态

14.1 Channel:点对点/队列通信

val channel = Channel<Int>(capacity = Channel.BUFFERED)

scope.launch {
    repeat(5) { channel.send(it) }
    channel.close()
}

scope.launch {
    for (item in channel) {
        println("receive=$item")
    }
}

14.2 Actor:把共享状态写入串行化

sealed interface CounterMsg
object Inc : CounterMsg
class Get(val reply: CompletableDeferred<Int>) : CounterMsg

fun CoroutineScope.counterActor() = actor<CounterMsg> {
    var count = 0
    for (msg in channel) {
        when (msg) {
            Inc -> count++
            is Get -> msg.reply.complete(count)
        }
    }
}

适用:高并发下避免锁竞争,保证单线程顺序更新。


15. 高级主题:并发安全、性能优化、调试手段

15.1 并发安全

常见风险:多个协程同时修改同一可变对象。

解决方案:

  • 不可变数据结构
  • Mutex
  • Actor 串行化
val mutex = Mutex()
var counter = 0

suspend fun safeInc() {
    mutex.withLock {
        counter++
    }
}

15.2 性能优化建议

  • 不要把小任务切成过多协程
  • IO/CPU 任务严格分 Dispatcher
  • 减少无意义线程切换
  • Flow 高频链路使用 buffer/conflate/collectLatest

15.3 调试建议

  • JVM 参数开启调试:-Dkotlinx.coroutines.debug
  • 协程命名:CoroutineName("LoadApproveTimeline")
  • 关键节点打印 currentCoroutineContext() 信息

16. 协程测试:runTest、虚拟时间与可测性

推荐依赖:kotlinx-coroutines-test

@OptIn(ExperimentalCoroutinesApi::class)
class ApproveViewModelTest {

    @Test
    fun `load success updates state`() = runTest {
        val vm = ApproveViewModel(FakeApproveRepository(success = true))

        vm.load("proc-1")
        advanceUntilIdle()

        assert(vm.state.value.loading.not())
        assert(vm.state.value.detail != null)
    }
}

关键点:

  • runTest 替代 runBlocking
  • advanceUntilIdle() 推进虚拟时间
  • 测试中避免真实延迟和真实网络

17. 完整业务案例:审批页并发加载 + 重试 + 超时 + 事件流

场景:进入审批详情页并发请求三类数据;允许瞬时失败重试;总时长受控;失败发事件提示。

suspend fun loadApprovePage(procInsId: String): ApprovePageData = coroutineScope {
    suspend fun <T> retry(times: Int = 2, block: suspend () -> T): T {
        var last: Throwable? = null
        repeat(times + 1) {
            try {
                return block()
            } catch (e: Throwable) {
                last = e
                delay(200)
            }
        }
        throw last ?: IllegalStateException("unknown")
    }

    val detail = async {
        withTimeout(3000) { retry { approveApi.getDetail(procInsId) } }
    }
    val dict = async {
        withTimeout(3000) { retry { dictApi.getApproveDict() } }
    }
    val timeline = async {
        withTimeout(3000) { retry { approveApi.getTimeline(procInsId) } }
    }

    ApprovePageData(
        detail = detail.await(),
        dict = dict.await(),
        timeline = timeline.await()
    )
}

这个例子同时体现:

  • 结构化并发(coroutineScope
  • 并发请求(async
  • 超时(withTimeout
  • 重试(retry
  • 错误收敛(await 抛出)

18. 常见误区与实践清单(可直接贴团队规范)

18.1 常见误区

  1. 在业务层使用 GlobalScope
  2. 主线程执行 IO
  3. 没有取消策略和超时策略
  4. 把一次性事件塞进 StateFlow
  5. async 创建后忘记 await
  6. Flow 链路过长但无背压策略

18.2 实践清单

  • 生命周期归属明确:viewModelScope/lifecycleScope
  • Repository 统一 withContext(Dispatchers.IO)
  • 状态用 StateFlow,事件用 SharedFlow
  • 关键请求设置超时、重试、兜底
  • 对高频输入加 debounce + flatMapLatest
  • 测试统一 runTest

结语

协程真正的难点不在 API 数量,而在工程思维:

  1. 任务归属谁(Scope)
  2. 跑在哪(Dispatcher)
  3. 何时停(取消/超时)
  4. 失败怎么办(异常传播/Supervisor)
  5. 数据如何流动(Flow/StateFlow/SharedFlow)

如果你愿意,我下一步可以再给这份文档补两章:

  • “协程面试高频题(附标准回答)”
  • “结合你当前 screen/repository/utils 目录的项目落地模板(可直接复制)”
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐