1. 开始之前
本 Codelab 将向您介绍并发性,这是 Android 开发人员需要掌握的一项重要技能,以便提供出色的用户体验。**并发性**涉及在您的应用中同时执行多个任务。例如,您的应用可以在响应用户输入事件并相应地更新 UI 的同时,从 Web 服务器获取数据或在设备上保存用户数据。
为了在您的应用中并发地执行工作,您将使用 Kotlin **协程**。协程允许挂起代码块的执行,然后稍后恢复,以便在此期间可以执行其他工作。协程使编写**异步**代码变得更容易,这意味着一个任务无需完全完成即可开始下一个任务,从而能够并发运行多个任务。
本 Codelab 将引导您完成 Kotlin Playground 中的一些基本示例,您可以在其中亲自动手练习协程,从而更轻松地掌握异步编程。
先决条件
- 能够使用带有
main()
函数的基本 Kotlin 程序 - 了解 Kotlin 语言基础知识,包括函数和 Lambda 表达式
您将构建的内容
- 简短的 Kotlin 程序,用于学习和试验协程的基础知识
您将学到的内容
- Kotlin 协程如何简化异步编程
- 结构化并发的目的及其重要性
您需要的内容
- 使用 Kotlin Playground 需要网络连接
2. 同步代码
简单程序
在**同步**代码中,一次只能执行一个概念性任务。您可以将其视为一条顺序的线性路径。一个任务必须完全完成才能开始下一个任务。以下是一个同步代码示例。
- 打开 Kotlin Playground。
- 将代码替换为以下代码,该代码用于显示晴朗天气的预报。在
main()
函数中,我们首先打印文本:Weather forecast
。然后打印:Sunny
。
fun main() {
println("Weather forecast")
println("Sunny")
}
- 运行代码。运行上述代码的输出应为
Weather forecast Sunny
println()
是一个同步调用,因为在执行可以移动到下一行代码之前,打印文本到输出的任务已完成。因为 main()
中的每个函数调用都是同步的,所以整个 main()
函数都是同步的。函数是同步还是异步取决于其组成的部分。
同步函数仅在其任务完全完成后才返回。因此,在 main()
中的最后一个打印语句执行后,所有工作都已完成。 main()
函数返回,程序结束。
添加延迟
现在,让我们假设获取晴朗天气的预报需要向远程 Web 服务器发出网络请求。通过在打印天气预报为晴朗之前在代码中添加延迟来模拟网络请求。
- 首先,在
main()
函数之前,在代码顶部添加import kotlinx.coroutines.*
。这将导入您将从 Kotlin 协程库中使用的函数。 - 修改您的代码以添加对
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()
运行一个事件循环,该循环可以通过在准备就绪时继续每个任务中断的地方来同时处理多个任务。
- 将
main()
函数的现有内容移动到runBlocking {}
调用的主体中。runBlocking{}
的主体在一个新的协程中执行。
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Weather forecast")
delay(1000)
println("Sunny")
}
}
runBlocking()
是同步的;它只有在其 Lambda 块中的所有工作都完成后才会返回。这意味着它将等待 delay()
调用中的工作完成(直到经过一秒),然后继续执行 Sunny
打印语句。一旦 runBlocking()
函数中的所有工作都完成,该函数就会返回,从而结束程序。
- 运行程序。输出如下所示
Weather forecast Sunny
输出与之前相同。代码仍然是同步的 - 它以直线运行并且一次只执行一项操作。但是,现在的区别在于,由于延迟,它运行的时间更长。
协程中的“co-”表示协作。当代码挂起以等待某些内容时,它会协作共享底层事件循环,这允许在此期间运行其他工作。(“协程”中的“-routine”部分表示一组指令,例如函数。)在本例中,协程在到达 delay()
调用时挂起。在协程挂起的那一秒钟内可以执行其他工作(即使在此程序中,没有其他工作要执行)。一旦延迟的持续时间过去,协程就会恢复执行,并可以继续将 Sunny
打印到输出。
挂起函数
如果执行网络请求以获取天气数据的实际逻辑变得更加复杂,您可能希望将其逻辑提取到自己的函数中。让我们重构代码以查看其效果。
- 提取模拟天气数据网络请求的代码,并将其移动到名为
printForecast()
的自己的函数中。从runBlocking()
代码中调用printForecast()
。
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Weather forecast")
printForecast()
}
}
fun printForecast() {
delay(1000)
println("Sunny")
}
如果您现在运行程序,您将看到之前看到的相同编译错误。只能从协程或另一个挂起函数调用挂起函数,因此将 printForecast()
定义为 suspend
函数。
- 在
printForecast()
函数声明中的fun
关键字之前添加suspend
修饰符,以将其设为挂起函数。
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Weather forecast")
printForecast()
}
}
suspend fun printForecast() {
delay(1000)
println("Sunny")
}
请记住, delay()
是一个挂起函数,现在您也已将 printForecast()
设为挂起函数。
**挂起**函数类似于常规函数,但可以挂起并在以后恢复。为此,挂起函数只能从提供此功能的其他挂起函数中调用。
挂起函数可能包含零个或多个挂起点。**挂起点**是函数内可以挂起函数执行的位置。执行恢复后,它会从代码中上次中断的地方继续执行,并继续执行函数的其余部分。
- 练习在
printForecast()
函数的声明下方,在您的代码中添加另一个挂起函数。将此新挂起函数称为printTemperature()
。您可以假装它执行网络请求以获取天气预报的温度数据。
在函数内部,也延迟执行 1000
毫秒,然后将温度值(例如摄氏 30
度)打印到输出。您可以使用转义序列 "\u00b0"
打印度符号 °
。
suspend fun printTemperature() {
delay(1000)
println("30\u00b0C")
}
- 从
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")
}
- 运行程序。输出应为
Weather forecast Sunny 30°C
在此代码中,协程首先在 printForecast()
挂起函数中的延迟处挂起,然后在一秒延迟后恢复。 Sunny
文本打印到输出。 printForecast()
函数返回到调用方。
接下来调用 printTemperature()
函数。该协程在其到达 delay()
调用时挂起,然后一秒钟后恢复并完成将温度值打印到输出。 printTemperature()
函数已完成所有工作并返回。
在 runBlocking()
主体中,没有其他任务要执行,因此 runBlocking()
函数返回,程序结束。
如前所述, runBlocking()
是同步的,主体中的每个调用都将按顺序调用。请注意,设计良好的挂起函数仅在所有工作完成后才返回。因此,这些挂起函数一个接一个地运行。
- (可选)如果您想查看执行此程序(包括延迟)需要多长时间,则可以将您的代码包装在对
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()
)。假设如果您调用一个函数,无论它在其实现细节中可能使用了多少个协程,它都应该在返回时完全完成其工作。即使它因异常而失败,一旦抛出异常,该函数就不会再有挂起的任务。因此,一旦控制流从函数返回,无论它是否抛出异常或成功完成其工作,所有工作都已完成。
- 从前面步骤的代码开始。使用
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")
}
- 运行程序。输出如下所示
Weather forecast Sunny 30°C
输出结果相同,但您可能已经注意到程序运行速度更快了。之前,您必须等待 printForecast()
挂起函数完全完成才能继续执行 printTemperature()
函数。现在,printForecast()
和 printTemperature()
可以并发运行,因为它们位于单独的协程中。
对 launch { printForecast() }
的调用可以在 printForecast()
中的所有工作完成之前返回。这就是协程的魅力所在。您可以继续执行下一个 launch()
调用以启动下一个协程。类似地,launch { printTemperature() }
也会在所有工作完成之前返回。
- (可选)如果您想查看程序现在运行速度有多快,可以添加
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()
代码结束之前添加另一个打印语句,您认为会发生什么?该消息将在输出中的哪个位置出现?
- 修改
runBlocking()
代码,在该块结束之前添加一个额外的打印语句。
...
fun main() {
runBlocking {
println("Weather forecast")
launch {
printForecast()
}
launch {
printTemperature()
}
println("Have a good day!")
}
}
...
- 运行程序,输出结果如下所示
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
对象上访问结果。
- 首先将您的挂起函数更改为返回
String
而不是打印预报和温度数据。将函数名称从printForecast()
和printTemperature()
更新为getForecast()
和getTemperature()
。
...
suspend fun getForecast(): String {
delay(1000)
return "Sunny"
}
suspend fun getTemperature(): String {
delay(1000)
return "30\u00b0C"
}
- 修改
runBlocking()
代码,以便它对这两个协程使用async()
而不是launch()
。将每个async()
调用的返回值存储在名为forecast
和temperature
的变量中,它们是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()
}
...
}
}
...
- 稍后在协程中,在两个
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"
}
- 运行程序,输出结果将是
Weather forecast Sunny 30°C Have a good day!
不错!您创建了两个并发运行的协程来获取预报和温度数据。当它们各自完成时,它们返回一个值。然后,您将这两个返回值组合到一个打印语句中:Sunny 30°C
。
并行分解
我们可以进一步扩展这个天气示例,看看协程如何在工作的并行分解中发挥作用。并行分解涉及将问题分解成可以并行解决的较小子任务。当子任务的结果准备好后,您可以将它们组合成最终结果。
在您的代码中,将天气报告的逻辑从 runBlocking()
的主体中提取到一个名为 getWeatherReport()
的单个函数中,该函数返回 Sunny 30°C
的组合字符串。
- 在您的代码中定义一个新的挂起函数
getWeatherReport()
。 - 将该函数设置为对
coroutineScope{}
函数的调用的结果,该函数带有一个空 lambda 块,该块最终将包含获取天气报告的逻辑。
...
suspend fun getWeatherReport() = coroutineScope {
}
...
coroutineScope{}
为此天气报告任务创建一个本地作用域。在此作用域内启动的协程在此作用域内分组,这对您很快将了解的取消和异常有影响。
- 在
coroutineScope()
的主体中,使用async()
创建两个新的协程,分别用于获取预报和温度数据。通过组合这两个协程的结果来创建天气报告字符串。为此,请在由async()
调用返回的每个Deferred
对象上调用await()
。这确保了每个协程都完成其工作并返回其结果,然后我们才能从该函数返回。
...
suspend fun getWeatherReport() = coroutineScope {
val forecast = async { getForecast() }
val temperature = async { getTemperature() }
"${forecast.await()} ${temperature.await()}"
}
...
- 从
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"
}
- 运行程序,您将看到此输出
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
的初始值更改为非零数字。但是,随着代码变得越来越复杂,在某些情况下,您无法预料并防止所有异常发生。
如果您的一个协程因异常而失败会发生什么?修改天气程序的代码以找出答案。
带协程的异常
- 从上一节的天气程序开始。
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"
}
在一个挂起函数中,故意抛出一个异常以查看效果。这模拟了从服务器获取数据时发生意外错误的情况,这是有可能的。
- 在
getTemperature()
函数中,添加一行代码抛出一个异常。使用 Kotlin 中的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()
函数完成其工作之前发生。
- 运行程序查看结果。
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 {
// 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
}
}
}
...
为了更好地理解如何处理异常,请修改天气程序以捕获您之前添加的异常并将异常打印到输出。
- 在
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"
}
- 运行程序,现在错误得到了优雅地处理,程序可以成功完成执行。
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
。
请注意,此行为意味着,如果获取温度失败,则根本不会有天气报告(即使检索到有效的预报)。
根据您希望程序的行为,您可以采用另一种方法来处理天气程序中的异常。
- 移动错误处理,以便 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"
}
- 运行程序。
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
),而不是确切的温度。因此,取消当前正在获取温度数据的协程。
- 首先从下面的初始代码(没有取消)开始。
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"
}
- 经过一些延迟后,取消正在获取温度信息的协程,以便您的天气报告仅显示预报。将
coroutineScope
块的返回值更改为仅为天气预报字符串。
...
suspend fun getWeatherReport() = coroutineScope {
val forecast = async { getForecast() }
val temperature = async { getTemperature() }
delay(200)
temperature.cancel()
"${forecast.await()}"
}
...
- 运行程序。现在输出如下。天气报告仅包含天气预报
Sunny
,而不是温度,因为该协程已被取消。
Weather forecast Sunny Have a good day!
您在这里学到的是,协程可以被取消,但它不会影响同一范围内的其他协程,并且父协程不会被取消。
在本节中,您了解了协程中取消和异常的行为以及它们如何与协程层次结构相关联。让我们进一步了解协程背后的正式概念,以便您能够理解所有重要部分是如何组合在一起的。
5. 协程概念
在异步或并发地执行工作时,您需要回答一些问题,例如工作将如何执行、协程应该存在多长时间、如果它被取消或因错误而失败会发生什么等等。协程遵循**结构化并发**原则,该原则强制您在使用代码中的协程时使用机制组合来回答这些问题。
作业
当您使用 launch()
函数启动协程时,它会返回一个 Job
实例。作业持有协程的句柄或引用,因此您可以管理其生命周期。
val job = launch { ... }
作业可用于控制生命周期或协程的生存时间,例如,如果您不再需要该任务,则可以取消协程。
job.cancel()
使用作业,您可以检查它是否处于活动状态、已取消或已完成。如果协程及其启动的任何协程都已完成其所有工作,则该作业已完成。请注意,协程可能由于其他原因完成,例如被取消或因异常而失败,但在此时作业仍被视为已完成。
作业还跟踪协程之间的父子关系。
作业层次结构
当一个协程启动另一个协程时,从新协程返回的作业称为原始父作业的子作业。
val job = launch {
...
val childJob = launch { ... }
...
}
这些父子关系形成了一个作业层次结构,其中每个作业都可以启动作业,依此类推。
这种父子关系很重要,因为它将决定子级和父级以及属于同一父级的其他子级的某些行为。您在前面天气程序的示例中看到了此行为。
- 如果父作业被取消,则其子作业也会被取消。
- 当使用
job.cancel()
取消子作业时,它会终止,但不会取消其父级。 - 如果作业因异常而失败,则会使用该异常取消其父级。这称为向上传播错误(到父级、父级的父级等)。
协程作用域
协程通常会启动到 CoroutineScope
中。这确保我们没有未管理的协程并丢失,这可能会浪费资源。
launch()
和 async()
是 CoroutineScope
上的 [扩展函数](https://kotlinlang.org/docs/extensions.html) 。在作用域上调用 launch()
或 async()
以在该作用域内创建一个新的协程。
CoroutineScope
与生命周期绑定,这限制了该作用域内协程的存活时间。如果作用域被取消,则其作业会被取消,并且取消会传播到其子作业。如果作用域中的子作业因异常而失败,则其他子作业会被取消,父作业会被取消,并且异常会被重新抛给调用方。
Kotlin Playground 中的 CoroutineScope
在此代码实验室中,您使用了 runBlocking()
,它为您的程序提供了一个 CoroutineScope
。您还学习了如何使用 coroutineScope { }
在 getWeatherReport()
函数中创建一个新的作用域。
Android 应用中的 CoroutineScope
Android 在具有明确定义的生命周期的实体(例如 Activity
(lifecycleScope
)和 ViewModel
(viewModelScope
))中提供了协程作用域支持。在这些作用域内启动的协程将遵循相应实体(例如 Activity
或 ViewModel
)的生命周期。
例如,假设您在 Activity
中使用提供的名为 lifecycleScope
的协程作用域启动了一个协程。如果活动被销毁,则 lifecycleScope
将被取消,并且其所有子协程也将自动被取消。您只需要确定遵循 Activity
生命周期的协程是否符合您的预期行为。
在您将要处理的 Race Tracker Android 应用中,您将学习一种将协程的作用域限定到可组合项的生命周期的方法。
CoroutineScope 的实现细节
如果您检查 [Kotlin 协程库](https://cs.android.com/android/platform/superproject/+/master:external/kotlinx.coroutines/kotlinx-coroutines-core/common/src/CoroutineScope.kt?q=coroutinescope) 中 CoroutineScope.kt
的源代码,您会发现 CoroutineScope
被声明为一个接口,并且它包含一个 CoroutineContext
作为变量。
launch()
和 async()
函数在该作用域内创建一个新的子协程,并且子协程也从作用域继承上下文。上下文中包含什么?我们接下来讨论。
CoroutineContext
CoroutineContext
提供有关协程将在其中运行的上下文的信息。CoroutineContext
本质上是一个存储元素的映射,其中每个元素都有一个唯一的键。这些不是必需的字段,但以下是一些可能包含在上下文中的示例
- name - 协程的名称,用于唯一标识它
- job - 控制协程的生命周期
- dispatcher - 将工作分派到相应的线程
- exception handler - 处理协程中执行的代码抛出的异常
上下文中的每个元素都可以使用 +
运算符连接在一起。例如,可以如下定义一个 CoroutineContext
Job() + Dispatchers.Main + exceptionHandler
由于未提供名称,因此使用默认的协程名称。
在一个协程中,如果启动一个新的协程,子协程将从父协程继承 CoroutineContext
,但会替换刚刚创建的协程的作业。您还可以通过将参数传递给 launch()
或 async()
函数来覆盖从父上下文中继承的任何元素,以指定您希望不同的上下文部分。
scope.launch(Dispatchers.Default) {
...
}
您可以在此 [KotlinConf 会议视频演讲](https://youtu.be/w0kfnydnFWI?t=256) 中了解更多关于 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 中尝试以下示例,以更好地理解协程调度器。
- 将 Kotlin Playground 中的任何代码替换为以下代码
import kotlinx.coroutines.*
fun main() {
runBlocking {
launch {
delay(1000)
println("10 results found.")
}
println("Loading...")
}
}
- 现在,将已启动协程的内容包装在对
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
的方式。
- 运行程序。输出应为
Loading... 10 results found.
- 添加打印语句以通过调用
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...")
}
}
- 运行程序。输出应为
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)
块中的代码部分,它是在 Default Dispatcher 工作线程(不是主线程)上的协程中执行的。请注意,在 withContext()
返回后,协程返回到主线程上运行(如输出语句所示:main @coroutine#2 - end of launch function
)。此示例演示了您可以通过修改用于协程的上下文来切换调度器。
如果您有在主线程上启动的协程,并且想要将某些操作从主线程移开,则可以使用 withContext
切换用于该工作的调度器。根据操作类型,从可用调度器中适当地选择:Main
、Default
和 IO
。然后,该工作可以分配给为此目的指定的线程(或称为线程池的线程组)。协程可以挂起自身,调度器也会影响它们如何恢复。
请注意,当使用 Room 和 Retrofit 等流行库(在本单元和下一个单元中)时,如果库代码已经使用 `Dispatchers.IO` 等替代协程调度器处理了此工作,则您可能无需显式切换调度器。在这些情况下,这些库公开的 `suspend` 函数可能已经是**主线程安全的**,并且可以从在主线程上运行的协程中调用。库本身将处理将调度器切换到使用工作线程的调度器。
现在,您已经对协程的重要部分以及 `CoroutineScope`、`CoroutineContext`、`CoroutineDispatcher` 和 `Jobs` 在塑造协程的生命周期和行为方面所起的作用有了高级概述。
6. 结论
在协程这个具有挑战性的主题上,您做得很好!您已经了解到协程非常有用,因为它们的执行可以被挂起,从而释放底层线程去做其他工作,然后协程可以在稍后恢复。这允许您在代码中运行并发操作。
Kotlin 中的协程代码遵循结构化并发原则。默认情况下它是顺序执行的,因此如果您想要并发(例如,使用 `launch()` 或 `async()`),则需要明确说明。使用结构化并发,您可以将多个并发操作放入单个同步操作中,其中并发只是一个实现细节。调用代码的唯一要求是在 `suspend` 函数或协程中。除此之外,调用代码的结构不需要考虑并发细节。这使得您的异步代码更易于阅读和理解。
结构化并发跟踪应用程序中启动的每个协程,并确保它们不会丢失。协程可以具有层次结构——任务可能会启动子任务,子任务又可以启动子任务。Jobs 维护协程之间的父子关系,并允许您控制协程的生命周期。
启动、完成、取消和失败是协程执行中的四个常见操作。为了更轻松地维护并发程序,结构化并发定义了构成管理层次结构中常见操作方式基础的原则。
- **启动:**将协程启动到一个作用域中,该作用域对它的生存时间有明确的界限。
- **完成:**只有在子作业完成后,作业才算完成。
- **取消:**此操作需要向下传播。当协程被取消时,子协程也需要被取消。
- **失败:**此操作应向上传播。当协程抛出异常时,父协程将取消其所有子协程,取消自身,并将异常传播到其父协程。这将持续进行,直到错误被捕获并处理。它确保代码中的任何错误都得到正确报告,并且永远不会丢失。
通过协程的实践操作和理解协程背后的概念,您现在能够更好地编写 Android 应用程序中的并发代码。通过使用协程进行异步编程,您的代码更易于阅读和理解,在取消和异常情况下更健壮,并为最终用户提供更优化和更具响应性的体验。
总结
- 协程使您能够编写并发运行的长运行代码,而无需学习新的编程风格。协程的执行设计上是顺序的。
- 协程遵循结构化并发原则,这有助于确保工作不会丢失,并且与具有特定生存时间边界的作用域相关联。您的代码默认情况下是顺序执行的,并与底层事件循环协作,除非您明确要求并发执行(例如,使用 `launch()` 或 `async()`)。假设如果您调用一个函数,无论它在其实现细节中可能使用了多少协程,它都应该在返回之前完全完成其工作(除非它因异常而失败)。
- `suspend` 修饰符用于标记其执行可以在稍后暂停和恢复的函数。
- `suspend` 函数只能从另一个挂起函数或协程中调用。
- 您可以使用 `CoroutineScope` 上的 `launch()` 或 `async()` 扩展函数启动一个新的协程。
- Jobs 在确保结构化并发方面发挥着重要作用,它管理协程的生命周期并维护父子关系。
- `CoroutineScope` 通过其 Job 控制协程的生命周期,并强制执行取消和其他规则及其子级和子级的子级。
- `CoroutineContext` 定义协程的行为,并且可以包含对作业和协程调度器的引用。
- 协程使用 `CoroutineDispatcher` 来确定用于执行的线程。