Kotlin Playground 协程简介

1. 准备工作

本 Codelab 向您介绍并发,这是 Android 开发者提供卓越用户体验必须理解的一项关键技能。并发涉及在应用中同时执行多项任务。例如,您的应用可以在从 Web 服务器获取数据或在设备上保存用户数据时,响应用户输入事件并相应地更新 UI。

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

本 Codelab 将带您通过 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() 中的最后一个 print 语句后,所有工作都完成了。main() 函数返回,程序结束。

添加延迟

现在让我们假装获取晴天的天气预报需要对远程 Web 服务器进行网络请求。通过在打印天气预报为晴天之前在代码中添加延迟来模拟网络请求。

  1. 首先,在 main() 函数之前,在代码顶部添加 import kotlinx.coroutines.*。这将导入您将使用的 Kotlin 协程库中的函数。
  2. 修改您的代码,添加对 delay(1000) 的调用,这将使 main() 函数的其余部分的执行延迟 1000 毫秒,即 1 秒。在打印 Sunny 的 print 语句之前插入此 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(挂起函数 'delay' 只能从协程或另一个挂起函数中调用)。

为了在 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 print 语句。一旦 runBlocking() 函数中的所有工作都完成,函数返回,程序结束。

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

输出与之前相同。代码仍然是同步的 - 它以直线运行,一次只做一件事。然而,现在的区别在于,由于延迟,它运行的时间更长。

协程中的“co-”表示协作。代码在暂停等待某些事情时进行协作,共享底层的事件循环,以便在此期间可以运行其他工作。(“coroutine”中的“-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 语句(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() 代码结束之前添加另一个 print 语句会发生什么?该消息会在输出中的什么位置出现?

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

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 的变量中,这些变量是类型为 StringDeferred 对象,它们持有结果。(由于 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!

很棒!您创建了两个并发运行的协程来获取预报和温度数据。当它们各自完成后,它们返回了一个值。然后您将两个返回值组合成一个 print 语句: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() 都需要完成并返回各自的结果。然后组合文本 Sunny30°C 并从作用域返回。这个天气报告 Sunny 30°C 会打印到输出,并且调用者可以继续执行最后一个 print 语句 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. 将错误处理移动到通过 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。这称为向上层传播错误(传播到父 Job、父 Job 的父 Job 等等)。

CoroutineScope

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

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

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

Kotlin Playground 中的 CoroutineScope

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

Android 应用中的 CoroutineScope

Android 在具有明确定义生命周期的实体中提供协程作用域支持,例如 Activity (lifecycleScope) 和 ViewModel (viewModelScope)。在这些作用域中启动的协程将遵循相应实体的生命周期,例如 ActivityViewModel

例如,假设您在 Activity 中使用提供的协程作用域 lifecycleScope 启动了一个协程。如果 Activity 被销毁,则 lifecycleScope 将被取消,并且其所有子协程也将自动被取消。您只需要决定跟随 Activity 生命周期是否是您想要的行为。

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

CoroutineScope 的实现细节

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

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

CoroutineContext

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

  • name - 协程的名称,用于唯一标识它
  • job - 控制协程的生命周期
  • dispatcher - 将工作分派给适当的线程
  • exception handler - 处理在协程中执行的代码抛出的异常

上下文中的每个元素都可以使用 + 运算符进行组合。例如,一个 CoroutineContext 可以定义如下

Job() + Dispatchers.Main + exceptionHandler

因为未提供名称,所以使用默认的协程名称。

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

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

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

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

Dispatcher

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

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

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

对于 Android 应用来说,只有在主线程上执行速度相当快的情况下才应该调用阻塞代码。目标是保持主线程不被阻塞,以便在触发新事件时可以立即执行工作。这个主线程是您 Activity 的 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 执行提供的代码块。新的上下文来自父 Job 的上下文(外部的 launch() 块),但它会用此处指定的调度器覆盖父上下文中使用的调度器:Dispatchers.Default。这就是我们如何能够从使用 Dispatchers.Main 执行工作切换到使用 Dispatchers.Default

  1. 运行程序。输出应为
Loading...
10 results found.
  1. 添加 print 语句以通过调用 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()),则需要明确指定。通过结构化并发,您可以将多个并发操作放入一个同步操作中,其中并发是实现的细节。对调用代码的唯一要求是它必须位于挂起函数或协程中。除此之外,调用代码的结构不需要考虑并发细节。这使得您的异步代码更容易阅读和理解。

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

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

  1. 启动:在一个具有确定生命周期边界的作用域中启动协程。
  2. 完成:只有当其子 Job 完成后,Job 才完成。
  3. 取消:此操作需要向下传播。当协程被取消时,子协程也需要被取消。
  4. 失败:此操作应向上层传播。当协程抛出异常时,父协程将取消其所有子协程,取消自身,并将异常向上层传播到其父协程。这会一直持续,直到失败被捕获和处理。它确保代码中的任何错误都得到正确报告,永不丢失。

通过亲手实践协程并理解协程背后的概念,您现在更有能力在 Android 应用中编写并发代码了。通过使用协程进行异步编程,您的代码更易于阅读和理解,在取消和异常情况下更健壮,并为最终用户提供更优化和响应更快的体验。

总结

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

了解更多