kotlin中关于协程的使用

发布于:2025-08-29 ⋅ 阅读:(15) ⋅ 点赞:(0)

一、什么是协程?

        协程是Kotlin提供的一种轻量级的线程管理框架,它允许我们以同步的方式编写异步代码,让代码更加简洁易读。与线程相比,协程的创建和切换开销更小,一个应用程序可以轻松创建数千个协程而不会导致性能问题。

二、为什么在Android中使用协程?

  1. ​避免回调地狱​​:以顺序的方式编写异步代码
  2. ​主线程安全​​:轻松切换线程,确保UI操作在主线程执行
  3. ​简化错误处理​​:使用try-catch处理异步操作中的异常
  4. ​生命周期感知​​:与Android组件生命周期自动绑定

三、协程的使用

1、常用Api

先列举下关于协程的常用的六种的Api以及对用的各种功能,如下表:

协程的 6 种常用 API
函数 作用 启动协程
launch { } 串行 启动无返回的协程、异常会​​立即抛出​​给父级,导致整个作用域取消。
async { }  并行

启动有返回的协程(并行)异常只在调用 .await()时​​抛出​​。

withContext(Dispatchers.x) { } 一次性切换线程并返回结果
runBlocking { } 阻塞当前线程直到协程完成(不常用)
coroutineScope { } 子作用域,失败时全部取消
supervisorScope { } 子作用域,失败时不影响兄弟协程

这6种api各自有各自的功能:

  1. ​​launch、async用于启动新协程的构建器​​ (真正意义上的“开启协程”)
  2. ​​withContext、coroutineScope、supervisorScope用于控制线程和作用的域构建器​​ (在已有协程内划分作用域)
  3. runBlocking​​一个特殊的阻塞式构建器​​ (主要用于测试,非Android日常开发)

2、使用示例

(1)、launch(无返回)
// 在 Activity / ViewModel 中
lifecycleScope.launch {
    Log.d("TAG", "launch 开始")
    delay(1000)
    Log.d("TAG", "launch 结束")   // 1 秒后打印
}

(2)、async(有返回,并行)
suspend fun main() = coroutineScope {
    val a = async { delay(800); 1 }
    val b = async { delay(600); 2 }
    val sum = a.await() + b.await()   // 两个 delay 并行跑
    println(sum)                      // 输出 3
}
suspend fun main() = coroutineScope {
    val a = async { delay(800); 1 }
    val b = async { delay(600); 2 }

    // 一行等全部,返回 List<Int>
    val list = awaitAll(a, b)   // 并行等待,顺序与入参一致
    println(list.sum())         // 输出 3
}

async开启协程的方式写了两个示例,因为这里 awaitAll() 和 await() 的使用上有点区别

方式 代码 异常传播 适用场景
逐个 await() a.await()+b.await() 第一个异常会阻断第二个 数量少
awaitAll() awaitAll(a, b) 合并异常,一次性抛出 数量多,更整洁
        3.1在开启协程时可以指定线程的作用域

        launch和 async函数本身可以接受一个 CoroutineContext类型的参数(通过参数指定上下文),你可以通过这个参数来​​指定协程的调度器、异常处理器等​​。从广义上讲,这也是在“设置”协程运行的上下文环境。

// 在 ViewModel 的 viewModelScope 这个“父作用域”中启动新协程
viewModelScope.launch { // 这个 launch 是 viewModelScope 的子协程
    // 代码
}

viewModelScope.async { // 这个 async 也是 viewModelScope 的子协程
    // 代码
}


//指定线程

// 设置调度器:在IO线程池运行
viewModelScope.launch(Dispatchers.IO) {
    // 网络请求等IO操作
}

// 设置异常处理器
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
    println("Caught $exception")
}
viewModelScope.launch(exceptionHandler) {
    // 可能会抛出异常的代码
}

// 可以组合多个上下文元素
viewModelScope.launch(Dispatchers.Default + exceptionHandler) {
    // ...
}

(3)、withcontext(一次性切线程并拿结果)

withContext 可以切到 任何 Dispatcher,常用只有这3个:

Dispatcher类型 线程 使用场景
Dispatchers.Main 主线程(UI) 更新界面
Dispatchers.IO 子线程池 网络 / 文件 / 数据库
Dispatchers.Default 子线程池 CPU 密集计算

Dispatchers.IO和 Dispatchers.Default管理的都是子线程(后台线程),但它们是为​​完全不同类型的工作任务​而设计的,因此它们的底层线程池策略有显著区别。

suspend fun main() {
    val threadName = withContext(Dispatchers.IO) {
        Thread.currentThread().name     // 在 IO 线程池里执行
    }
    println(threadName)                 // 例如:DefaultDispatcher-worker-1
}
(4)、runBlocking(阻塞主线程,仅测试用)
fun main() = runBlocking {
    println("start")
    delay(1000)
    println("end")   // 整个 main 会等 1 秒
}

(5)、coroutineScope(子作用域,任一失败全部取消)
suspend fun main() = coroutineScope {
    launch {
        delay(200)
        throw RuntimeException("boom")   // 异常
    }
    launch {
        delay(1000)
        println("never reach")           // 被取消
    }
}

(6)、supervisorScope(子作用域,失败互不影响)
suspend fun main() = supervisorScope {
    launch {
        delay(200)
        throw RuntimeException("boom")   // 兄弟不受影响
    }
    launch {
        delay(500)
        println("still alive")           // 会打印
    }
}

  • launch / async / withContext:业务代码用的最多
  • runBlocking:单元测试时使用
  • coroutineScope / supervisorScope:并发任务时控制异常

四、挂起函数

1、什么是挂起函数

说到协程,就不得不说提到挂起函数,什么是挂起函数?

  • 标记suspend 关键字。

  • 能力:只能在协程或另一个挂起函数里调用。

  • 本质:是一种可以被协程挂起(暂停执行),而不会阻塞其所在线程的函数。编译器把函数切成「状态机」,遇到 delay()、withContext() 等挂起点就挂起,线程空出来干别的,等结果回来再恢复继续执行。

2、挂起函数是如何工作的?

挂起函数的背后是 ​​状态机​​ 和 ​​Continuation​​ 概念。

​编译器魔法​​:当你编译一个 suspend函数时,编译器会做额外的转换。它会为协程体生成一个状态机(State Machine)。每个挂起点(即调用另一个 suspend函数的地方)都成为状态机的一个可能状态。

​Continuation​​:可以把它理解为一个​​回调对象​​,它封装了 “协程在挂起之后该如何恢复执行” 的信息,包括它应该从哪一行代码继续、当时的局部变量是什么等等。

当你调用 withContext(Dispatchers.IO) { ... }时,实际上发生了:

  1. 协程在执行到 withContext时,会​​挂起​​。
  2. 它将 withContext块内的代码和 Continuation(恢复信息)一起提交给协程调度器。
  3. 调度器安排一个线程(IO线程池中的线程)来执行这个块。
  4. 执行完毕后,调度器再通知协程:“你交代的任务完成了”,并把结果和 Continuation一起,安排回原来的线程(或者你指定的调度器,如 Dispatchers.Main)。
  5. 协程根据 Continuation的信息,​​恢复​​到挂起点之后的状态,继续执行。

上面这种描述太抽象,我们不如想象成一个快递员(​​一个线程​​)在送包裹(​​执行任务​​)。

​1、普通函数(阻塞)​​:快递员到了一个办公室楼下,需要等收件人下来签字。在收件人下来之前,他什么都不做,就干等着(​​阻塞​​)。这期间他没法去送别的包裹,效率很低。

2、​回调函数(非阻塞但复杂)​​:快递员把包裹交给前台,并留下一个纸条(​​回调函数​​)说:“等收件人来了,打电话叫我回来签字”。然后他就去送别的包裹了。等前台打电话来,他再回来处理。这样效率高了,但流程变得复杂,如果包裹多,需要留很多纸条,管理起来很混乱(​​回调地狱​​)。

​3、挂起函数(挂起-恢复)​​:快递员到了办公室楼下,他给收件人打了个电话说:“我到了,你下来吧”。​​在收件人下楼的这段时间里,他并没有干等着,而是被派去送隔壁楼的另一个小包裹(挂起当前任务,线程去干别的事了)​​。等收件人快到楼下了,快递员也送完隔壁的小包裹回来了,然后顺利签字完成主要任务。

在这个比喻中:

  1. ​快递员​​:就是一个线程。
  2. ​送主要包裹​​:就是执行协程体里的代码。
  3. ​打电话让收件人下楼​​:就是调用一个 suspend函数(比如 delay, withContext, 或者你的自定义挂起函数)。
  4. ​去送隔壁的小包裹​​:线程被释放,可以去执行其他任务(可能是其他协程的任务)。
  5. ​收件人下楼完成,快递员回来签字​​:挂起的条件满足,协程在(可能是原来的,也可能是另一个)线程上​​恢复​​,继续执行后面的代码。

关键点:​​ 

        1、挂起函数不会阻塞线程,而是释放线程去干别的活,等它等待的操作(如网络请求、磁盘IO、延迟)完成后,协程会在合适的时机和线程上​​恢复​​执行。

        2、挂起函数本身不指定线程​​:suspend关键字只是一个标记,告诉编译器这个函数可以在协程中使用并可能挂起。它本身并不包含任何线程信息。线程由调度器(Dispatcher)决定​​:真正决定代码在哪个线程上运行的是协程的上下文中的 CoroutineDispatcher(协程调度器)➡️(Dispatchers.Main / IO / Default)。

        3、挂起函数的作用域不一定在子线程中。它的执行线程完全取决于它在被调用时所在的协程上下文(CoroutineContext),以及它内部使用的调度器(Dispatcher)。​挂起函数的核心是“挂起”(suspend),而不是“切换线程”。线程切换只是实现挂起的一种常用手段。

3、挂起函数的使用场景

(1)情况一:在主线程启动,并在主线程调用挂起函数

viewModelScope.launch(Dispatchers.Main) { // 1. 在主线程启动协程
    // 2. 当前上下文是 Dispatchers.Main
    doSomeWork() // 3. 调用挂起函数
}

// 这个挂起函数没有使用 withContext 切换线程
// 因此它将继承调用者的上下文,即在主线程运行
suspend fun doSomeWork() {
    // 这里的代码会在 Dispatchers.Main 上执行
    // 如果在这里执行耗时操作,会阻塞主线程!
    heavyOperation() // ❌ 危险!会阻塞UI!
}

fun heavyOperation() {
    Thread.sleep(2000) // 模拟耗时阻塞操作
}

这是最常见的Android场景。协程在主线程启动,挂起函数内部没有切换上下文,那么它就会在主线程运行。在这个例子中,挂起函数 doSomeWork()的作用域是主线程。

(2)情况二:正确的“主线程安全”挂起函数

viewModelScope.launch(Dispatchers.Main) { // 1. 在主线程启动协程
    // 2. 当前上下文是 Dispatchers.Main
    val result = doSomeSafeWork() // 3. 调用挂起函数(挂起点)
    updateUI(result) // 6. 恢复后,仍在主线程,安全更新UI
}

// 这是一个主线程安全的挂起函数
suspend fun doSomeSafeWork(): String {
    // 4. 函数开始执行时,仍在主线程
    // 但 withContext 会将协程的执行挂起,并将代码块交给 IO 调度器
    return withContext(Dispatchers.IO) {
        // 5. 这个代码块现在在IO线程池中的某个线程执行
        // 模拟网络请求或数据库操作,不会阻塞主线程
        Thread.sleep(2000)
        "Result from network"
    }
    // withContext 完成后,协程会自动切回原来的上下文(Dispatchers.Main)
    // 所以返回值是在主线程被接收的
}

 一个良好的挂起函数应该内部处理线程切换,保证无论从哪个线程调用它,其耗时操作都在后台进行,并最终将结果返回给调用方线程。

doSomeSafeWork() 函数​​内部​​的 withContext 代码块的作用域是子线程(IO线程)。但从外部看,这个函数​​被调用和返回​​的上下文(viewModelScope通常是 Dispatchers.Main)是主线程。

(3)情况三:在子线程启动协程

viewModelScope.launch(Dispatchers.Default) { // 1. 在Default线程池启动
    // 2. 当前上下文是 Dispatchers.Default
    doSomeWork() // 3. 这个挂起函数将在 Default 线程执行
}

suspend fun doSomeWork() {
    // 在 Dispatchers.Default 上执行
}

如果明确指定一个后台调度器启动协程,那么挂起函数(如果不内部切换)就会在那个后台线程运行。

(4)总结

场景

挂起函数所在线程

说明

​默认情况​

​继承调用方协程的上下文​

挂起函数不自动切换线程,它在哪个线程被调用,就在哪个线程运行。

​使用 withContext

​由 withContext的参数决定​

这是​​主动控制​​挂起函数内部代码执行线程的标准方式。

​设计目标​

​实现主线程安全​

一个好的挂起函数应该内部使用 withContext确保任何耗时操作都不在主线程进行,让调用者无需关心线程细节。

挂起函数的作用域不一定在子线程中。它的线程环境是​​动态的​​和​​可预测的​​。

​​动态的​​:取决于调用它的协程上下文和它内部使用的调度器。

可预测的​​:开发者可以通过 withContext精确地控制其内部代码应该在哪个线程上执行。

​注意:​​ 

        1、永远不要假设一个挂起函数会在后台线程运行。如果要执行耗时操作,​​必须​​在挂起函数内部使用 withContext(Dispatchers.IO)或 withContext(Dispatchers.Default)来明确切换到合适的线程。这才是编写“主线程安全”挂起函数的关键。

        2、结构化并发(Structured Concurrency),这是协程设计的核心哲学,要求协程的生命周期与它的启动作用域(如 ViewModel的 viewModelScope或 Activity的 lifecycleScope)绑定。

       

       


网站公告

今日签到

点亮在社区的每一天
去签到