Kotlin Playground 中的协程简介

1. 开始之前

本代码实验室将向您介绍并发,这是 Android 开发人员必须掌握的一项关键技能,才能提供出色的用户体验。并发是指在您的应用中同时执行多个任务。例如,您的应用可以在响应用户输入事件并相应地更新 UI 的同时,从 Web 服务器获取数据或将用户数据保存到设备上。

要在您的应用中并发执行任务,您将使用 Kotlin 协程。协程允许暂停代码块的执行,然后稍后再恢复,以便在此期间可以执行其他工作。协程使编写异步代码更容易,这意味着一个任务无需完全完成即可开始下一个任务,从而使多个任务能够并发运行。

本代码实验室将引导您完成 Kotlin Playground 中的一些基本示例,您可以在其中亲身体验协程,从而更轻松地掌握异步编程。

先决条件

  • 能够使用具有 main() 函数的基本 Kotlin 程序
  • 了解 Kotlin 语言的基础知识,包括函数和 lambda 表达式

您将构建的内容

  • 简短的 Kotlin 程序,用于学习和试验协程的基础知识

您将学习的内容

  • Kotlin 协程如何简化异步编程
  • 结构化并发的目的及其重要性

您需要的内容

2. 同步代码

简单程序

同步代码中,一次只进行一个概念性任务。您可以将其视为一条顺序的线性路径。一个任务必须完全完成才能开始下一个任务。下面是一个同步代码示例。

  1. 打开 Kotlin Playground
  2. 用以下代码替换代码,该代码显示晴朗天气的天气预报。在 main() 函数中,我们首先打印文本:Weather forecast。然后我们打印:Sunny
fun main() {
    println("Weather forecast")
    println("Sunny")
}
  1. 运行代码。运行上述代码的输出应为
Weather forecast
Sunny

println() 是一个同步调用,因为将文本打印到输出的任务在执行可以移动到下一行代码之前已完成。因为 main() 中的每个函数调用都是同步的,所以整个 main() 函数都是同步的。函数是同步还是异步取决于其组成的部分。

同步函数只有在其任务完全完成后才会返回。因此,在 main() 中的最后一个打印语句执行后,所有工作都已完成。main() 函数返回,程序结束。

添加延迟

现在,让我们假设获取晴朗天气的天气预报需要向远程 Web 服务器发出网络请求。在打印天气预报是晴朗之前,通过在代码中添加延迟来模拟网络请求。

  1. 首先,在 main() 函数之前,在代码顶部添加 import kotlinx.coroutines.*。这将导入您将从 Kotlin 协程库中使用的函数。
  2. 修改您的代码以添加对 delay(1000) 的调用,这会将 main() 函数其余部分的执行延迟 1000 毫秒,即 1 秒。在 Sunny 的打印语句之前插入此 delay() 调用。
import kotlinx.coroutines.*

fun main() {
    println("Weather forecast")
    delay(1000)
    println("Sunny")
}

delay() 实际上是 Kotlin 协程库提供的特殊的挂起函数main() 函数的执行将在此处暂停,然后在延迟的指定持续时间结束后恢复(在此例中为一秒钟)。

如果您尝试在此处运行程序,则会发生编译错误:Suspend function 'delay' should be called only from a coroutine or another suspend function

为了在 Kotlin Playground 中学习协程,您可以使用对协程库中 runBlocking() 函数的调用来包装现有代码。runBlocking() 运行事件循环,该循环可以通过在准备好时继续每个任务的停止位置来一次处理多个任务。

  1. main() 函数的现有内容移动到 runBlocking {} 调用的主体中。runBlocking{} 的主体在一个新的协程中执行。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        delay(1000)
        println("Sunny")
    }
}

runBlocking() 是同步的;它只有在其 lambda 块中的所有工作完成后才会返回。这意味着它将等待 delay() 调用中的工作完成(直到经过一秒钟),然后继续执行 Sunny 打印语句。一旦 runBlocking() 函数中的所有工作都完成,该函数就会返回,从而结束程序。

  1. 运行程序。以下是输出
Weather forecast
Sunny

输出与之前相同。代码仍然是同步的 - 它按顺序运行,并且一次只执行一件事情。但是,现在的区别是由于延迟,它运行的时间更长。

协程中的“co-”表示协作。当代码暂停等待某些内容时,代码会协作以共享底层事件循环,这允许在此期间运行其他工作。(协程中的“-routine”部分表示像函数一样的一组指令。)在本例中,协程在到达 delay() 调用时会暂停。在此协程暂停的一秒钟内可以执行其他工作(尽管在此程序中没有其他工作要做)。一旦延迟的持续时间过去,协程就会恢复执行,并可以继续将 Sunny 打印到输出。

挂起函数

如果执行网络请求以获取天气数据的实际逻辑变得更复杂,您可能希望将其逻辑提取到其自身的函数中。让我们重构代码以查看其效果。

  1. 提取模拟天气数据网络请求的代码,并将其移动到名为 printForecast() 的自身函数中。从 runBlocking() 代码调用 printForecast()
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

fun printForecast() {
    delay(1000)
    println("Sunny")
}

如果您现在运行程序,您将看到之前看到的相同的编译错误。只能从协程或其他挂起函数调用挂起函数,因此将 printForecast() 定义为 suspend 函数。

  1. printForecast() 函数声明中的 fun 关键字之前添加 suspend 修饰符,以使其成为挂起函数。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

请记住,delay() 是一个挂起函数,现在您也已将 printForecast() 制成挂起函数。

挂起函数类似于常规函数,但它可以暂停并在以后恢复。为此,挂起函数只能从提供此功能的其他挂起函数调用。

挂起函数可能包含零个或多个挂起点。挂起点是函数中可以暂停函数执行的地方。执行恢复后,它会从上次停止代码的地方继续执行,并继续执行函数的其余部分。

  1. 练习在 printForecast() 函数声明下方向您的代码添加另一个挂起函数。将此新挂起函数称为 printTemperature()。您可以假装这会执行网络请求以获取天气预报的温度数据。

在函数中,也延迟 1000 毫秒的执行,然后将温度值(例如 30 摄氏度)打印到输出。您可以使用转义序列 "\u00b0" 打印度符号 °

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
}
  1. main() 函数中的 runBlocking() 代码调用新的 printTemperature() 函数。以下是完整代码
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
        printTemperature()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. 运行程序。输出应为
Weather forecast
Sunny
30°C

在此代码中,协程首先在 printForecast() 挂起函数中使用延迟暂停,然后在该一秒延迟后恢复。Sunny 文本将打印到输出。printForecast() 函数返回调用方。

接下来调用printTemperature()函数。该协程在到达delay()调用时挂起,然后一秒钟后恢复并完成将温度值打印到输出的操作。printTemperature()函数已完成所有工作并返回。

runBlocking()主体中,没有其他任务要执行,因此runBlocking()函数返回,程序结束。

如前所述,runBlocking()是同步的,主体中的每个调用都将按顺序调用。请注意,设计良好的挂起函数只有在完成所有工作后才会返回一次。因此,这些挂起函数一个接一个地运行。

  1. (可选) 如果你想查看执行此程序(包括延迟)需要多长时间,你可以将代码包装在measureTimeMillis()的调用中,它将返回以毫秒为单位的代码块运行时间。添加导入语句(import kotlin.system.*)以访问此函数。打印执行时间并除以1000.0以将毫秒转换为秒。
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            printForecast()
            printTemperature()
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}
suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 

输出

Weather forecast
Sunny
30°C
Execution time: 2.128 seconds

输出显示执行大约需要 2.1 秒。(你的精确执行时间可能略有不同。)这看起来很合理,因为每个挂起函数都有一个一秒钟的延迟。

到目前为止,你已经看到协程中的代码默认情况下是顺序调用的。如果你想让事情同时运行,则必须明确说明,你将在下一节中学习如何做到这一点。你将利用协作事件循环同时执行多个任务,这将加快程序的执行时间。

3. 异步代码

launch()

使用协程库中的launch()函数启动一个新的协程。要同时执行任务,请向代码中添加多个launch()函数,以便多个协程可以同时运行。

Kotlin 中的协程遵循一个名为结构化并发的关键概念,其中你的代码默认情况下是顺序的,并与底层事件循环协作,除非你明确要求并发执行(例如,使用launch())。假设如果你调用一个函数,那么无论它在其实现细节中使用了多少协程,它都应该在其返回时完全完成其工作。即使它抛出异常,一旦抛出异常,函数就没有更多挂起的任务了。因此,一旦控制流从函数返回,所有工作就完成了,无论它是抛出异常还是成功完成其工作。

  1. 从之前的步骤开始你的代码。使用launch()函数将对printForecast()printTemperature()的每个调用分别移到它们自己的协程中。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. 运行程序。以下是输出
Weather forecast
Sunny
30°C

输出相同,但你可能已经注意到程序运行速度更快了。以前,你必须等待printForecast()挂起函数完全完成才能继续执行printTemperature()函数。现在printForecast()printTemperature()可以并发运行,因为它们位于单独的协程中。

The println (Weather Forecast) statement is in a box at the top of the diagram. Below it, there is a vertical arrow pointing straight down. Off that vertical arrow, there is a branch going to the right with an arrow pointing to a box that contains the statement printForecast(). Off that original vertical arrow, there is also another branch going to the right with an arrow pointing to a box that contains the statement printTemperature().

launch { printForecast() }的调用可以在printForecast()中的所有工作完成之前返回。这就是协程的妙处。你可以继续执行下一个launch()调用以启动下一个协程。同样,launch { printTemperature() }也在所有工作完成之前返回。

  1. (可选) 如果你想查看程序现在快了多少,你可以添加measureTimeMillis()代码来检查执行时间。
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            launch {
                printForecast()
            }
            launch {
                printTemperature()
            }
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}

...

输出

Weather forecast
Sunny
30°C
Execution time: 1.122 seconds

你可以看到执行时间从大约 2.1 秒减少到大约 1.1 秒,因此一旦你添加并发操作,程序执行速度就会更快!在继续下一步之前,你可以删除此时间测量代码。

如果你在第二个launch()调用之后,在runBlocking()代码结束之前添加另一个打印语句,你认为会发生什么?该消息将出现在输出中的哪个位置?

  1. 修改runBlocking()代码,在该代码块结束之前添加一个额外的打印语句。
...

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
        println("Have a good day!")
    }
}

...
  1. 运行程序,输出如下
Weather forecast
Have a good day!
Sunny
30°C

从这个输出中,你可以观察到,在为printForecast()printTemperature()启动两个新的协程之后,你可以继续执行下一条指令,该指令打印Have a good day!。这演示了launch()的“启动并忘记”特性。你使用launch()启动一个新的协程,而不必担心它的工作何时完成。

稍后,协程将完成它们的工作,并打印其余的输出语句。一旦runBlocking()调用主体中的所有工作(包括所有协程)都已完成,则runBlocking()返回,程序结束。

现在你已经将同步代码更改为异步代码。当异步函数返回时,任务可能尚未完成。这就是你在launch()的情况下看到的。函数返回了,但它的工作尚未完成。通过使用launch(),可以在你的代码中同时运行多个任务,这在开发 Android 应用时是一种强大的功能。

async()

在现实世界中,你不知道预测和温度的网络请求需要多长时间。如果你想在两个任务都完成后显示统一的天气报告,那么当前使用launch()的方法是不够的。这就是async()的用武之地。

如果你关心协程何时完成并需要从中获得返回值,请使用协程库中的async()函数。

async()函数返回一个Deferred类型的对象,它就像一个承诺,结果将在准备好时放入其中。你可以使用await()Deferred对象上访问结果。

  1. 首先将你的挂起函数更改为返回一个String,而不是打印预测和温度数据。将函数名称从printForecast()printTemperature()更新为getForecast()getTemperature()
...

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 修改你的runBlocking()代码,以便它对两个协程使用async()而不是launch()。将每个async()调用的返回值存储在名为forecasttemperature的变量中,它们是Deferred对象,保存类型为String的结果。(由于 Kotlin 中的类型推断,指定类型是可选的,但它包含在下面,以便你可以更清楚地看到async()调用返回的内容。)
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        ...
    }
}

...
  1. 稍后在协程中,在两次async()调用之后,你可以通过在Deferred对象上调用await()来访问这些协程的结果。在这种情况下,你可以使用forecast.await()temperature.await()打印每个协程的值。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        println("${forecast.await()} ${temperature.await()}")
        println("Have a good day!")
    }
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 运行程序,输出将是
Weather forecast
Sunny 30°C
Have a good day!

不错!你创建了两个并发运行的协程来获取预测和温度数据。当它们各自完成后,它们返回一个值。然后你将这两个返回值组合到一个打印语句中:Sunny 30°C

并行分解

我们可以进一步研究这个天气示例,看看协程如何在工作的并行分解中发挥作用。并行分解包括将问题分解成可以并行解决的较小子任务。当子任务的结果准备好后,你可以将它们组合成最终结果。

在你的代码中,将天气报告的逻辑从runBlocking()的主体中提取到单个getWeatherReport()函数中,该函数返回Sunny 30°C的组合字符串。

  1. 在你的代码中定义一个新的挂起函数getWeatherReport()
  2. 将该函数设置为对coroutineScope{}函数的调用的结果,该函数带有空 lambda 块,最终将包含获取天气报告的逻辑。
...

suspend fun getWeatherReport() = coroutineScope {
    
}

...

coroutineScope{}为此天气报告任务创建一个局部作用域。在此作用域内启动的协程在此作用域内分组在一起,这对稍后你将学习的取消和异常具有影响。

  1. coroutineScope()的主体中,使用async()创建两个新的协程分别获取预测和温度数据。通过组合来自这两个协程的结果来创建天气报告字符串。通过在async()调用返回的每个Deferred对象上调用await()来实现此目的。这确保了每个协程都完成其工作并返回其结果,然后我们才能从此函数返回。
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

...
  1. runBlocking()调用这个新的getWeatherReport()函数。这是完整的代码
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 运行程序,你将看到此输出
Weather forecast
Sunny 30°C
Have a good day!

输出相同,但这里有一些值得注意的要点。如前所述,coroutineScope()只有在其所有工作(包括它启动的任何协程)完成后才会返回。在这种情况下,两个协程getForecast()getTemperature()都需要完成并返回它们各自的结果。然后组合Sunny文本和30°C并从作用域返回。这个Sunny 30°C的天气报告被打印到输出中,调用者可以继续执行最后一条打印语句Have a good day!

使用coroutineScope(),即使函数内部并发执行工作,对于调用者来说,它看起来像是一个同步操作,因为只有在所有工作完成之后coroutineScope才会返回。

结构化并发在这里的关键在于,您可以将多个并发操作放入单个同步操作中,其中并发只是一个实现细节。调用代码的唯一要求是在挂起函数或协程中。除此之外,调用代码的结构不需要考虑并发细节。

4. 异常和取消

现在让我们讨论一些可能发生错误或某些工作可能被取消的情况。

异常介绍

一个异常是在执行代码期间发生的意外事件。您应该实现适当的方法来处理这些异常,以防止您的应用程序崩溃并对用户体验产生负面影响。

这是一个程序过早终止并出现异常的示例。该程序旨在通过将numberOfPizzas / numberOfPeople相除来计算每个人可以吃多少比萨饼。假设您不小心忘记为numberOfPeople设置实际值。

fun main() {
    val numberOfPeople = 0
    val numberOfPizzas = 20
    println("Slices per person: ${numberOfPizzas / numberOfPeople}")
}

运行程序时,它会由于算术异常而崩溃,因为您不能将数字除以零。

Exception in thread "main" java.lang.ArithmeticException: / by zero
 at FileKt.main (File.kt:4) 
 at FileKt.main (File.kt:-1) 
 at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (:-2)

这个问题有一个简单的解决方法,您可以将numberOfPeople的初始值更改为非零数字。但是,随着代码变得越来越复杂,在某些情况下,您无法预测和防止所有异常的发生。

当您的一个协程发生异常时会发生什么?修改天气程序的代码来找出答案。

协程中的异常

  1. 从上一节的天气程序开始。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}

在一个挂起函数中,故意抛出一个异常以查看其效果。这模拟了从服务器获取数据时发生意外错误的情况,这是很可能的。

  1. getTemperature()函数中,添加一行抛出异常的代码。使用Kotlin中的throw关键字编写一个throw表达式,后跟一个从Throwable扩展的异常的新实例。

例如,您可以抛出一个AssertionError并传入一个更详细地描述错误的消息字符串:throw AssertionError("Temperature is invalid")。抛出此异常会停止getTemperature()函数的进一步执行。

...

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}

您还可以将getTemperature()方法的延迟更改为500毫秒,以便您知道异常会在另一个getForecast()函数完成其工作之前发生。

  1. 运行程序以查看结果。
Weather forecast
Exception in thread "main" java.lang.AssertionError: Temperature is invalid
 at FileKt.getTemperature (File.kt:24) 
 at FileKt$getTemperature$1.invokeSuspend (File.kt:-1) 
 at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33) 

要理解此行为,您需要知道协程之间存在父子关系。您可以从另一个协程(父协程)启动一个协程(子协程)。当您从这些协程启动更多协程时,您可以构建整个协程层次结构。

执行getTemperature()的协程和执行getForecast()的协程是同一父协程的子协程。您在协程中看到的异常行为是由于结构化并发造成的。当其中一个子协程发生异常时,它会向上传播。父协程被取消,这反过来会取消任何其他子协程(例如,在这种情况下运行getForecast()的协程)。最后,错误向上传播,程序因AssertionError而崩溃。

try-catch异常

如果您知道代码的某些部分可能会抛出异常,那么您可以使用try-catch块包围该代码。您可以捕获异常并以更优雅的方式在应用程序中处理它,例如向用户显示有用的错误消息。这是一个代码片段,说明它可能是什么样子。

try {
    // Some code that may throw an exception
} catch (e: IllegalArgumentException) {
    // Handle exception
}

此方法也适用于使用协程的异步代码。您仍然可以使用try-catch表达式来捕获和处理协程中的异常。原因是,使用结构化并发,顺序代码仍然是同步代码,因此try-catch块仍然会以相同的方式工作。

...

fun main() {
    runBlocking {
        ...
        try {
            ...
            throw IllegalArgumentException("No city selected")
            ...
        } catch (e: IllegalArgumentException) {
            println("Caught exception $e")
            // Handle error
        }
    }
}

...

为了更轻松地处理异常,请修改天气程序以捕获您之前添加的异常并将异常打印到输出。

  1. runBlocking()函数中,在调用getWeatherReport()的代码周围添加一个try-catch块。打印出捕获的错误,并打印出天气报告不可用的消息。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        try {
            println(getWeatherReport())
        } catch (e: AssertionError) {
            println("Caught exception in runBlocking(): $e")
            println("Report unavailable at this time")
        }
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. 运行程序,现在错误得到了优雅地处理,程序可以成功完成执行。
Weather forecast
Caught exception in runBlocking(): java.lang.AssertionError: Temperature is invalid
Report unavailable at this time
Have a good day!

从输出中,您可以观察到getTemperature()抛出了异常。在runBlocking()函数的主体中,您将println(getWeatherReport())调用放在try-catch块中。您捕获了预期的异常类型(在本例中为AssertionError)。然后,您将异常打印到输出,显示为"Caught exception",后跟错误消息字符串。为了处理错误,您可以使用另一个println()语句让用户知道天气报告不可用:Report unavailable at this time

请注意,此行为意味着如果获取温度失败,则根本不会有天气报告(即使检索到了有效的预报)。

根据您希望程序的行为,您可以使用另一种方法来处理天气程序中的异常。

  1. 移动错误处理,以便try-catch行为实际上发生在由async()启动的协程中以获取温度。这样,即使温度失败,天气报告仍然可以打印预报。这是代码。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. 运行程序。
Weather forecast
Caught exception java.lang.AssertionError: Temperature is invalid
Sunny { No temperature found }
Have a good day!

从输出中,您可以看到调用getTemperature()发生异常,但是async()内的代码能够捕获该异常并通过让协程仍然返回一个显示温度未找到的String来优雅地处理它。天气报告仍然可以打印,成功预报为Sunny。天气报告中缺少温度,但取而代之的是一条解释温度未找到的消息。这比程序因错误而崩溃更好的用户体验。

理解这种错误处理方法的一个有帮助的方法是,当使用它启动协程时,async()是生产者。await()是消费者,因为它正在等待使用协程的结果。生产者完成工作并产生结果。消费者使用结果。如果生产者发生异常,那么如果没有处理,消费者将获得该异常,并且协程将失败。但是,如果生产者能够捕获和处理异常,那么消费者将看不到该异常,并且将看到有效的结果。

这是getWeatherReport()代码,以供参考。

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

在这种情况下,生产者(async())能够捕获和处理异常,并且仍然返回"{ No temperature found }"String结果。消费者(await())接收此String结果,甚至不需要知道发生了异常。这是优雅地处理您预计代码中可能发生的异常的另一个选项。

现在您已经了解到,异常会向上传播到协程树中,除非它们被处理。当异常一直传播到层次结构的根部时,也需要注意,这可能会导致整个应用程序崩溃。在协程中的异常博文中和协程异常处理文章中了解有关异常处理的更多详细信息。

取消

与异常类似的一个主题是协程的取消。这种情况通常是由用户驱动的,当事件导致应用程序取消它先前启动的工作时。

例如,假设用户在应用程序中选择了一个首选项,他们不再希望在应用程序中看到温度值。他们只想了解天气预报(例如Sunny),而不是确切的温度。因此,取消当前正在获取温度数据的协程。

  1. 首先从下面的初始代码(无取消)开始。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 经过一些延迟后,取消正在获取温度信息的协程,以便您的天气报告只显示预报。将coroutineScope块的返回值更改为仅为天气预报字符串。
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    
    delay(200)
    temperature.cancel()

    "${forecast.await()}"
}

...
  1. 运行程序。现在的输出如下。天气报告仅包含天气预报Sunny,而不是温度,因为该协程已被取消。
Weather forecast
Sunny
Have a good day!

您在这里学到的是,可以取消协程,但这不会影响同一作用域中的其他协程,并且不会取消父协程。

在本节中,您了解了协程中取消和异常的行为以及它与协程层次结构的关系。让我们进一步了解协程背后的正式概念,以便您可以理解所有重要部分是如何组合在一起的。

5. 协程概念

当异步或并发执行工作时,您需要回答一些问题,例如工作将如何执行,协程应该存在多长时间,如果它被取消或发生错误会发生什么等等。协程遵循结构化并发原则,它强制您在使用代码中的协程时,通过多种机制组合来回答这些问题。

Job

当您使用launch()函数启动协程时,它会返回一个Job实例。Job 持有协程的句柄或引用,因此您可以管理其生命周期。

val job = launch { ... }

可以使用 Job 来控制生命周期,或协程的持续时间,例如,如果您不再需要该任务,则可以取消协程。

job.cancel()

使用 Job,您可以检查它是否处于活动状态、已取消或已完成。如果协程及其启动的任何协程都已完成所有工作,则该 Job 已完成。请注意,协程可能由于其他原因(例如被取消或发生异常)而完成,但在此时 Job 仍被视为已完成。

Job 还跟踪协程之间的父子关系。

Job 层次结构

当一个协程启动另一个协程时,从新协程返回的 Job 被称为原始父 Job 的子 Job。

val job = launch {
    ...            

    val childJob = launch { ... }

    ...
}

这些父子关系形成一个 Job 层次结构,其中每个 Job 都可以启动 Job,依此类推。

This diagram shows a tree hierarchy of jobs. At the root of the hierarchy is a parent job. It has 3 children called: Child 1 Job, Child 2 Job, and Child 3 Job. Then Child 1 Job has two children itself: Child 1a Job and Child 1b Job. Also, Child 2 Job has a single child called Child 2a Job. Lastly, Child 3 Job has two children: Child 3a Job and Child 3b Job.

这种父子关系很重要,因为它将决定子 Job、父 Job 和属于同一父 Job 的其他子 Job 的某些行为。您在前面有关天气程序的示例中看到了这种行为。

  • 如果父 Job 被取消,则其子 Job 也被取消。
  • 当使用job.cancel()取消子 Job 时,它会终止,但不会取消其父 Job。
  • 如果 Job 因异常而失败,它会使用该异常取消其父 Job。这被称为向上传播错误(到父级、父级的父级,依此类推)。

CoroutineScope

协程通常会启动到CoroutineScope中。这确保我们没有未管理的协程丢失,这可能会浪费资源。

launch()async()CoroutineScope上的扩展函数。在作用域上调用launch()async()以在该作用域内创建一个新的协程。

一个CoroutineScope与一个生命周期绑定,该生命周期限定了该作用域内的协程的生存时间。如果作用域被取消,则其 Job 被取消,并且该取消会传播到其子 Job。如果作用域中的子 Job 因异常而失败,则其他子 Job 将被取消,父 Job 将被取消,并且异常将重新抛给调用者。

Kotlin Playground 中的 CoroutineScope

在这个代码实验室中,您使用了runBlocking(),它为您的程序提供了一个CoroutineScope。您还学习了如何使用coroutineScope { }getWeatherReport()函数中创建一个新的作用域。

Android 应用中的 CoroutineScope

Android 在具有明确生命周期的实体(例如ActivitylifecycleScope)和ViewModelviewModelScope))中提供协程作用域支持。在这些作用域内启动的协程将遵守相应实体(例如ActivityViewModel)的生命周期。

例如,假设您使用提供的名为lifecycleScope的协程作用域在Activity中启动一个协程。如果活动被销毁,则lifecycleScope将被取消,其所有子协程也将自动被取消。您只需要决定遵循Activity生命周期的协程是否符合您的预期行为。

在您将要开发的 Race Tracker Android 应用中,您将学习一种将协程的作用域限定为可组合组件生命周期的方法。

CoroutineScope 的实现细节

如果您查看CoroutineScope.kt在 Kotlin 协程库中的实现的源代码,您可以看到CoroutineScope被声明为接口,它包含一个CoroutineContext作为变量。

launch()async()函数在该作用域内创建一个新的子协程,并且子协程也从作用域继承上下文。上下文中包含什么内容?我们接下来讨论。

CoroutineContext

CoroutineContext提供有关协程将在其中运行的上下文的信息。CoroutineContext本质上是一个存储元素的映射,其中每个元素都有一个唯一的键。这些不是必需的字段,但以下是一些可能包含在上下文中的示例

  • name - 协程的名称,用于唯一标识它
  • job - 控制协程的生命周期
  • dispatcher - 将工作调度到合适的线程
  • 异常处理程序 - 处理协程中执行的代码抛出的异常

可以使用+运算符将上下文中的每个元素附加在一起。例如,可以如下定义一个CoroutineContext

Job() + Dispatchers.Main + exceptionHandler

由于未提供名称,因此使用默认的协程名称。

在一个协程内,如果您启动一个新的协程,子协程将从父协程继承CoroutineContext,但会专门替换刚创建的协程的 Job。您还可以通过向launch()async()函数传递参数来覆盖从父上下文继承的任何元素,以指定您希望不同的上下文部分。

scope.launch(Dispatchers.Default) {
    ...
}

您可以在此KotlinConf 大会视频演讲中了解更多关于CoroutineContext以及上下文如何从父级继承的信息。

您已经多次提到调度程序。它的作用是将工作调度或分配给线程。让我们更详细地讨论线程和调度程序。

Dispatcher

协程使用调度程序来确定用于执行的线程。可以启动一个线程,执行一些工作(执行一些代码),然后在没有更多工作需要完成时终止。

当用户启动您的应用时,Android 系统会为您的应用创建一个新的进程和一个单线程执行,这称为主线程。主线程处理您的应用的许多重要操作,包括 Android 系统事件、在屏幕上绘制 UI、处理用户输入事件等等。因此,您为应用编写的绝大多数代码都可能在主线程上运行。

在代码的线程行为方面,需要理解两个术语:阻塞非阻塞。常规函数会阻塞调用线程,直到其工作完成。这意味着它在工作完成之前不会释放调用线程,因此在此期间无法执行其他工作。相反,非阻塞代码会在满足特定条件之前释放调用线程,因此您在此期间可以执行其他工作。您可以使用异步函数执行非阻塞工作,因为它在工作完成之前就会返回。

对于 Android 应用,只有当阻塞代码执行速度很快时,才应在主线程上调用它。目标是保持主线程不被阻塞,以便在触发新事件时可以立即执行工作。此主线程是活动的UI 线程,负责 UI 绘制和 UI 相关事件。当屏幕上发生变化时,需要重新绘制 UI。对于屏幕上的动画之类的东西,需要频繁重新绘制 UI,以便看起来像平滑过渡。如果主线程需要执行长时间运行的阻塞工作,则屏幕不会频繁更新,用户将看到突然的过渡(称为“卡顿”),或者应用可能会挂起或响应缓慢。

因此,我们需要将任何长时间运行的工作项从主线程移出,并在不同的线程中处理它。您的应用程序从单个主线程启动,但您可以选择创建多个线程来执行额外的工作。这些额外的线程可以称为工作线程。长时间运行的任务阻塞工作线程很长时间是完全可以的,因为与此同时,主线程没有被阻塞,并且可以主动响应用户。

Kotlin 提供了一些内置的分派器

  • Dispatchers.Main: 使用此分派器在 Android 主线程上运行协程。此分派器主要用于处理 UI 更新和交互,以及执行快速工作。
  • Dispatchers.IO: 此分派器经过优化,可在主线程之外执行磁盘或网络 I/O。例如,从文件读取或写入文件,以及执行任何网络操作。
  • Dispatchers.Default: 这是在调用launch()async()时使用的默认分派器,前提是在其上下文中未指定分派器。您可以使用此分派器在主线程之外执行计算密集型工作。例如,处理位图图像文件。

在 Kotlin Playground 中尝试以下示例,以更好地理解协程分派器。

  1. 将 Kotlin Playground 中的任何代码替换为以下代码
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        launch {
            delay(1000)
            println("10 results found.")
        }
        println("Loading...")
    }
}
  1. 现在,使用对withContext()的调用来包装启动的协程的内容,以更改协程在其内执行的CoroutineContext,并专门覆盖分派器。切换到使用Dispatchers.Default(而不是当前用于程序中其余协程代码的Dispatchers.Main)。
...

fun main() {
    runBlocking {
        launch {
            withContext(Dispatchers.Default) {
                delay(1000)
                println("10 results found.")
            }
        }
        println("Loading...")
    }
}

可以切换分派器,因为withContext() 本身就是一个挂起函数。它使用新的CoroutineContext执行提供的代码块。新上下文来自父作业(外部launch()块)的上下文,但它将父上下文中使用的分派器与这里指定的Dispatchers.Default覆盖。这就是我们能够从使用Dispatchers.Main执行工作转变为使用Dispatchers.Default的方式。

  1. 运行程序。输出应为
Loading...
10 results found.
  1. 通过调用Thread.currentThread().name添加打印语句以查看您所在的线程。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("${Thread.currentThread().name} - runBlocking function")
                launch {
            println("${Thread.currentThread().name} - launch function")
            withContext(Dispatchers.Default) {
                println("${Thread.currentThread().name} - withContext function")
                delay(1000)
                println("10 results found.")
            }
            println("${Thread.currentThread().name} - end of launch function")
        }
        println("Loading...")
    }
}
  1. 运行程序。输出应为
main @coroutine#1 - runBlocking function
Loading...
main @coroutine#2 - launch function
DefaultDispatcher-worker-1 @coroutine#2 - withContext function
10 results found.
main @coroutine#2 - end of launch function

从该输出中,您可以观察到大部分代码是在主线程上的协程中执行的。但是,对于withContext(Dispatchers.Default)块中的代码部分,它是在默认分派器工作线程(不是主线程)上的协程中执行的。请注意,在withContext()返回之后,协程返回到在主线程上运行(如输出语句所示:main @coroutine#2 - end of launch function)。此示例演示您可以通过修改用于协程的上下文来切换分派器。

如果您有在主线程上启动的协程,并且想要将某些操作移出主线程,那么您可以使用withContext来切换用于该工作的分派器。根据操作类型,从可用的分派器中适当选择:MainDefaultIO。然后,该工作可以分配给为此目的指定的线程(或称为线程池的线程组)。协程可以挂起自身,分派器也会影响它们的恢复方式。

请注意,当使用 Room 和 Retrofit 等流行库(在本单元和下一个单元中)时,如果库代码已经使用替代协程分派器(如Dispatchers.IO)来处理此工作,则您可能不必显式切换分派器。在这些情况下,这些库公开的suspend函数可能已经是**主线程安全的**,并且可以从在主线程上运行的协程中调用。库本身将处理切换到使用工作线程的分派器。

现在,您已经对协程的重要部分以及CoroutineScopeCoroutineContextCoroutineDispatcherJobs在塑造协程的生命周期和行为方面所起的作用有了高级概述。

6. 结论

关于协程这个具有挑战性的主题,您做得很好!您已经了解到协程非常有用,因为它们的执行可以被挂起,从而释放底层线程以执行其他工作,然后协程可以在以后恢复。这允许您在代码中运行并发操作。

Kotlin 中的协程代码遵循结构化并发原则。默认情况下它是顺序的,因此如果您需要并发(例如使用launch()async()),则需要明确说明。使用结构化并发,您可以将多个并发操作放入单个同步操作中,其中并发是一个实现细节。调用代码的唯一要求是在挂起函数或协程中。除此之外,调用代码的结构不需要考虑并发细节。这使得您的异步代码更易于阅读和理解。

结构化并发跟踪应用程序中启动的每个协程,并确保它们不会丢失。协程可以具有层次结构——任务可能会启动子任务,子任务又可以启动子任务。作业维护协程之间的父子关系,并允许您控制协程的生命周期。

启动、完成、取消和失败是协程执行中的四个常见操作。为了更轻松地维护并发程序,结构化并发定义了构成管理层次结构中常见操作基础的原则。

  1. 启动:将协程启动到具有其生存时间的定义边界的范围内。
  2. 完成:只有在子作业完成之后,作业才算完成。
  3. 取消:此操作需要向下传播。当协程被取消时,子协程也需要被取消。
  4. 失败:此操作应向上传播。当协程抛出异常时,父协程将取消其所有子协程,取消自身,并将异常传播给其父协程。这种情况会一直持续到捕获并处理该错误为止。它确保代码中的任何错误都会被正确报告,并且永远不会丢失。

通过协程的实践操作以及对协程背后概念的理解,您现在更有能力在 Android 应用程序中编写并发代码。通过将协程用于异步编程,您的代码更易于阅读和理解,在取消和异常情况下更健壮,并为最终用户提供更优化和更响应迅速的体验。

总结

  • 协程使您可以编写并发运行的长运行代码,而无需学习新的编程风格。协程的执行设计上是顺序的。
  • 协程遵循结构化并发原则,这有助于确保工作不会丢失,并且与具有其生存时间定义边界的范围相关联。您的代码默认情况下是顺序的,并与底层事件循环协作,除非您明确要求并发执行(例如使用launch()async())。假设是,如果您调用一个函数,无论它在实现细节中可能使用了多少协程,它都应该在返回时完全完成其工作(除非它因异常而失败)。
  • suspend修饰符用于标记其执行可以在稍后暂停和恢复的函数。
  • 只能从另一个挂起函数或从协程中调用suspend函数。
  • 您可以使用CoroutineScope上的launch()async()扩展函数启动新的协程。
  • 作业在确保结构化并发方面发挥着重要作用,它通过管理协程的生命周期并维护父子关系来实现。
  • CoroutineScope通过其作业来控制协程的生命周期,并强制其子项及其子项的取消和其他规则。
  • CoroutineContext定义了协程的行为,并且可以包含对作业和协程分派器的引用。
  • 协程使用CoroutineDispatcher来确定用于其执行的线程。

了解更多