Kotlin 协程终极复习笔记 (旗舰版 v2.0)

65 阅读6分钟

原文章 :juejin.cn/post/731436…

第一部分:核心概念与世界观

  • 1. 协程是什么?(一句话概括)

    • 协程是 Kotlin 提供的编写异步代码的全新范式,它能让我们像写同步代码一样去处理异步逻辑,核心是**“非阻塞的挂起”**。
  • 2. 它解决了什么痛点?

    • 回调地狱 (Callback Hell):彻底消灭了嵌套的回调函数,代码逻辑从上到下线性执行,可读性极高。
    • 线程管理复杂:开发者无需手动创建和管理线程池,通过 Dispatcher 声明意图即可。
    • 资源泄漏:通过结构化并发,将协程生命周期与业务组件(如 ViewModel)绑定,避免了因忘记取消任务导致的内存和资源泄漏。

第二部分:关键源码解读:suspend 函数的状态机

suspend 函数的魔法完全由编译器实现。编译器会将一个 suspend 函数转换成一个实现了 Continuation 接口的状态机 (State Machine)

1. helloWorld 状态机流程图 (修正版)

这张图清晰地展示了 helloWorld 函数内部状态的流转过程。

graph TD
    A["<b>开始: label = 0</b><br/>(首次调用 invokeSuspend)"];
    B["<b>状态: label = 1</b><br/>(等待 hello() 结果)"];
    C["<b>状态: label = 2</b><br/>(等待 world() 结果)"];
    D{"<b>结束</b><br/>返回最终字符串"};

    A -- "1. 创建StringBuilder, 设置label=1<br/>2. 调用 hello()" --> B;
    B -- "挂起<br/>(hello() 返回 COROUTINE_SUSPENDED)" --> B;
    B -- "恢复<br/>(resumeWith('Hello, ') 被调用)" --> C;
    C -- "1. 追加'Hello, ', 设置label=2<br/>2. 调用 world()" --> C;
    C -- "挂起<br/>(world() 返回 COROUTINE_SUSPENDED)" --> C;
    C -- "恢复<br/>(resumeWith('World!!') 被调用)" --> D;

    style B fill:#f9f,stroke:#333,stroke-width:2px;
    style C fill:#f9f,stroke:#333,stroke-width:2px;
2. 协程的三幕剧:挂起、执行与恢复
  • 第一幕:挂起 (The Suspension)

    1. 保存状态:将当前函数的局部变量(如StringBuilder)保存到 Continuation 对象的成员变量中。
    2. 更新标签:修改 Continuationlabel 字段,指向下一次恢复时应执行的代码块(case 分支)。
    3. 启动异步任务:将 Continuation 自身作为回调,交给底层的异步API。
    4. 发出挂起信号:立即返回一个特殊的单例对象 COROUTINE_SUSPENDED。协程框架看到这个信号,就会暂停协程,释放线程。
  • 第二幕:方法体的执行 (The Execution)

    • suspend 函数体被编译器转换成了一个巨大的 switch 语句,包裹在 invokeSuspend 方法里。
    • label 变量就是程序计数器,决定了 switch 进入哪个 case
    • 局部变量通过 Continuation 对象的成员变量 (param1, param2 等) 来实现跨越挂起的传递。
  • 第三幕:恢复 (The Resumption)

    1. 触发器:底层的异步任务在它自己的线程池中完成,然后触发了回调
    2. 调用 resume:回调函数的核心逻辑就是调用我们当初传进去的 Continuation 对象的 resumeWith(result) 方法。
    3. 调度与执行resumeWith 的调用会被 Dispatcher 拦截,它会将协程的后续执行(即再次调用 invokeSuspend)作为一个任务,派发到指定的线程。
    4. 状态机跳转:因为 label 的值已在挂起前被更新,switch 语句会直接进入下一个 case,代码无缝衔接。

第三部分:全局流程:组件交互时序图

这张图展示了协程世界和真实世界(后台线程池)之间的交互。

sequenceDiagram
    participant Caller as 调用方 (Main Dispatcher)
    participant LaunchCont as LaunchContinuation
    participant HelloCont as HelloWorldContinuation
    participant DelayExecutor as 后台线程池

    Caller->>LaunchCont: 1. launch! 协程启动, 调用 resumeWith
    activate LaunchCont
    LaunchCont->>HelloCont: 2. 调用 helloWorld(this)
    activate HelloCont
    note right of HelloCont: [状态机] label=0, 开始执行
    
    HelloCont->>DelayExecutor: 3. 调用delaSuspend, 提交异步任务(带Continuation回调)
    HelloCont-->>LaunchCont: 4. 返回 COROUTINE_SUSPENDED (挂起信号)
    deactivate HelloCont
    LaunchCont-->>Caller: 5. 协程挂起, Main Dispatcher被释放
    deactivate LaunchCont
    
    note over DelayExecutor: 异步任务执行中...
    
    DelayExecutor-->>HelloCont: 6. [500ms后] 回调触发, 调用 resumeWith("Hello, ")
    activate HelloCont
    note right of HelloCont: [状态机] label=1, 从上次挂起点恢复
    
    HelloCont->>DelayExecutor: 7. 再次调用delaSuspend, 提交异步任务
    HelloCont-->>LaunchCont: 8. 再次返回 COROUTINE_SUSPENDED
    deactivate HelloCont
    
    note over DelayExecutor: 异步任务执行中...

    DelayExecutor-->>HelloCont: 9. [500ms后] 回调触发, 调用 resumeWith("World!!")
    activate HelloCont
    note right of HelloCont: [状态机] label=2, 从上次挂起点恢复, 拼接字符串
    
    HelloCont-->>LaunchCont: 10. helloWorld()执行完毕, 返回最终结果
    deactivate HelloCont
    activate LaunchCont
    
    LaunchCont->>Caller: 11. 打印结果
    LaunchCont-->>Caller: 12. launch代码块执行完毕, 协程结束
    deactivate LaunchCont

第四部分:全面面试问答

【基础篇】

1. Q: launchasync 有什么区别? A:

  • launch (启动并忘记):用于执行一个异步任务,不关心返回结果。返回一个 Job,主要用于取消。异常会立即传播。
  • async (启动并获取):用于执行一个异步任务,并期望获取其结果。返回一个 Deferred<T>,必须通过调用 .await() 来挂起等待结果。异常在调用 .await() 时才会抛出。

2. Q: Dispatchers.IO, Default, Main, Unconfined 有什么区别? A:

  • Main: 通常是 UI 线程(在 Android 中)。用于更新 UI 和执行快速、非阻塞的操作。
  • IO: 为高阻塞的 I/O 操作(网络请求、文件读写、数据库操作)优化的后台线程池。线程数量较多。
  • Default: 为 CPU 密集型操作(大量计算、复杂数据处理、JSON 解析)优化的后台线程池。线程数量通常等于 CPU 核心数。
  • Unconfined: 不指定任何线程。它会在调用者的线程上开始执行,但在第一个挂起点之后,会恢复到恢复它的那个线程上(比如 OkHttp 的回调线程),线程上下文会丢失。通常不推荐在业务中使用

【原理篇】

3. Q: 请详细描述一下协程的挂起和恢复流程。 A: 好的。协程的挂起恢复机制,其核心是编译器代码生成回调

  1. 编译期:编译器会将 suspend 函数转换为一个实现了 Continuation 接口的状态机。函数体内的代码被拆分到 invokeSuspend 方法的 switch-case 中,通过一个 label 变量来控制流程。
  2. 挂起时:当执行到一个挂起点时,它会先更新 label 到下一个状态,然后保存当前局部变量到 Continuation 的成员中。最后,它会启动一个异步任务,并立即返回一个 COROUTINE_SUSPENDED 信号。调度器看到这个信号,就知道需要挂起。
  3. 恢复时:当底层的异步任务完成时,会调用 ContinuationresumeWith 方法。这个调用会被调度器接收,然后将 invokeSuspend 方法作为一个任务重新投递到指定的线程,并根据 label 跳转到下一段代码继续执行。

4. Q: withContext 的作用是什么?如果我在协程里调用一个阻塞方法会发生什么? A:

  • 作用withContext 的核心作用是安全地切换协程的执行线程上下文
  • 后果:如果直接在协程里调用阻塞方法,会导致调用协程的那个线程被完全阻塞,协程“非阻塞”的优势就荡然无存,若在主线程则会导致ANR。
  • 正确做法:必须使用 withContext(Dispatchers.IO) 将阻塞代码包裹起来。withContext 会挂起当前协程,将阻塞任务扔到后台线程执行,完成后再切回原线程恢复协程。

【进阶篇】

5. Q: CoroutineScope 的作用是什么?JobSupervisorJob 有什么区别? A:

  • CoroutineScope: 核心作用是实现结构化并发 (Structured Concurrency)。它能将协程的生命周期与一个业务组件绑定,实现自动的、级联的取消,从而防止内存泄漏。
  • Job vs SupervisorJob:
    • Job: 遵循父子协程的连带取消原则。任何一个子协程失败(抛出异常),都会导致其父协程和所有其他兄弟协程被立即取消。
    • SupervisorJob: 破坏了这种连带关系。一个子协程的失败不会影响其父协程或其他兄弟协程。常用于需要独立管理子任务生命周期的场景(例如,UI上有多个独立的操作,一个失败不应影响其他)。viewModelScope 内部使用的就是 SupervisorJob

6. Q: 如何处理协程中的异常? A: 有三种主要方式:

  1. try-catch: 和普通代码一样,用 try-catch 包裹 suspend 函数调用,这是最直接、最推荐的方式。
  2. CoroutineExceptionHandler: 这是一个可以添加到 CoroutineContext 中的“全局”处理器。当一个顶层协程(由 launch 而不是 async 启动)发生未捕获的异常时,它会被调用。它无法阻止协程的取消流程。
  3. supervisorScope: 创建一个使用 SupervisorJob 的子作用域。在这个作用域内,一个子协程的失败不会影响其他子协程。异常需要通过 try-catch 或在每个子协程的 Job 上设置 CoroutineExceptionHandler 来单独处理。