Kotlin 园地中的协程简介

1. 准备工作

此 Codelab 将介绍并发,这是 Android 开发者为提供出色的用户体验而需要掌握的一项重要技能。并发涉及在应用中同时执行多项任务。例如,您的应用可以一边响应用户输入事件并相应更新界面,一边从网络服务器获取数据或将用户数据保存在设备上。

如需在应用中并发执行工作,需要使用 Kotlin 协程。使用协程可以挂起某个代码块的执行并于稍后恢复,以便在此期间完成其他工作。借助协程,您可以更轻松地编写异步代码,也就是说,无需等到彻底完成一个任务之后再开始下一个任务,多个任务可以并发运行。

此 Codelab 将逐步引导您完成 Kotlin 园地中的一些基本示例,让您动手体验协程,更加熟练地掌握异步编程。

前提条件

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

构建内容

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

学习内容

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

所需条件

2. 同步代码

简单的程序

同步代码中,一次只能执行一个概念性任务。您可以将其视为一条依序线性路径。必须彻底完成一项任务后才能开始下一项任务。以下是一个同步代码示例。

  1. 打开 Kotlin 园地
  2. 将代码替换为以下代码,编写一个显示晴天天气预报的程序。在 main() 函数中,先输出以下文本:Weather forecast。然后输出以下文本:Sunny
fun main() {
    println("Weather forecast")
    println("Sunny")
}
  1. 运行代码。运行上述代码的输出应如下所示:
Weather forecast
Sunny

println() 是一个同步调用,因为要完成输出文本的任务后才能执行下一行代码。由于 main() 中的每个函数调用都是同步调用,因此整个 main() 函数是同步函数。一个函数是同步函数还是异步函数取决于其组成部分。

同步函数仅在其任务彻底完成后才会返回。因此,当 main() 中的最后一个 print 语句执行完毕后,所有工作均已完成。main() 函数返回,程序结束。

添加延迟

现在,我们假设获取晴天天气预报需要向远程网络服务器发出网络请求。这样,我们就需要在代码中添加延迟来模拟网络请求,将其加到输出晴天天气预报之前。

  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

为了学习 Kotlin 园地中的协程,您可以调用协程库中提供的 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

输出内容与先前一样。代码仍是同步代码,它沿着一条直线依序运行,一次只执行一项操作。不过,现在的区别在于,运行时间因存在延迟而比之前长。

协程 (coroutine) 中的“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() 可以并发运行,因为它们位于不同的协程中。

println (Weather Forecast) 语句位于图表顶部的框中。下方有一个指向正下方的垂直箭头。该垂直箭头有一个向右延伸的分支,分支带有一个箭头,箭头指向一个包含 printForecast() 语句的框。这一原始的垂直箭头还有另一个向右延伸的分支,分支带有一个箭头,箭头指向包含 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 的变量中,这些变量是用于存储类型为 String 的结果的 Deferred 对象。(由于 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. 将该函数设置为等于使用空 lambda 代码块(最终会包含用于获取天气预报的逻辑)调用 coroutineScope{} 函数的结果。
...

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! 的最后一个 print 语句。

使用 coroutineScope() 时,即使函数内部是并发完成工作的,调用方也会将其视为同步操作,因为 coroutineScope 只有在所有工作完成后才会返回。

这里有一个关于结构化并发的要点:您可以将多个并发操作放入单个同步操作中,在这种情况下,并发是一个实现细节。对发起调用的代码只有一个要求:它必须在挂起函数或协程中。除此之外,发起调用的代码的结构不需要考虑并发细节。

4. 异常和取消

现在,我们来介绍可能出现错误或系统可能取消某些工作的一些情况。

异常简介

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

以下示例程序会因发生异常而提前终止运行。该程序旨在通过除法运算“numberOfPizzas / numberOfPeople”来计算每个人所能吃的披萨数量。假设您不小心忘了将 numberOfPeople 的值设置为实际值。

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

运行时,该程序便会因发生计算异常而崩溃,因为您无法将 0 作为除数。

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 关键字编写一个抛出表达式,后跟从 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 块。输出捕获的错误,同时输出一条内容为“weather report is not available”(天气预报不可用)的消息。
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() 函数的主体中,将 try-catch 块中的 println(getWeatherReport()) 调用括起来。您可以捕获预期的异常类型(本例中为 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() 开头时,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. 协程概念

异步或并发执行工作时,您需要回答一些问题,包括执行工作的方式、协程应存在的时长、协程取消或因错误而失败时应执行的操作等。协程遵循结构化并发原则,当您在结合使用不同机制的代码中使用协程时,这一原则会迫使您不得不回答这些问题。

作业

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

val job = launch { ... }

此作业可用于控制协程的生命周期或持续时间,例如,在您不再需要相应任务时取消协程。

job.cancel()

对于作业,您可以检查它是处于活跃状态、已取消还是已完成。如果协程及其启动的所有协程均已完成所有工作,那么该作业就已经完成。请注意,协程可能会因其他原因(例如被取消或失败并引发异常)而完成,但届时作业仍会被视为已完成。

作业还会跟踪协程之间的父子关系。

作业层次结构

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

val job = launch {
    ...            

    val childJob = launch { ... }

    ...
}

这些父级与子级关系形成了作业层次结构,其中的每个作业都可以启动其他作业,依此类推。

下图所示为作业的树状层次结构。层次结构的根部是一个父作业。该作业有 3 个子级,分别名为“子级 1 作业”“子级 2 作业”和“子级 3 作业”。然后,“子级 1 作业”本身有两个子级:“子级 1a 作业”和“子级 1b 作业”。此外,“子级 2 作业”也有一个名为“子级 2a 作业”的子级。最后,“子级 3 作业”有两个子级:“子级 3a 作业”和“子级 3b 作业”。

这种父级与子级关系很重要,因为它会规定子级和父级以及属于同一个父级的其他子级的某些行为。您在之前的天气程序示例中见过这种行为。

  • 如果某个父作业被取消,那么其子作业也会被取消。
  • 使用 job.cancel() 取消某个子作业时,该子作业会终止,但其父级不会被取消。
  • 如果某个作业失败并引发异常,其父级中具有该异常的作业也会被取消。这称为错误向上传播(到父级、父级的父级,依此类推)。

CoroutineScope

协程通常启动到 CoroutineScope 中。这可以确保没有不受管理而不知所踪的协程,因此不会浪费资源。

launch()async()CoroutineScope扩展函数。对该作用域调用 launch()async(),以在该作用域内创建新协程。

CoroutineScope 与生命周期相关联,生命周期对该作用域内的协程存留时长设置了界限。如果某个作用域被取消,那么其作业也会被取消,而且取消会传播到其子作业。如果作用域内的某个子作业失败并引发异常,那么其他子作业会被取消,父作业也会被取消,而且会向调用方重新抛出异常。

Kotlin 园地中的 CoroutineScope

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

Android 应用中的 CoroutineScope

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

举例而言,假设您在某个 Activity 中使用提供的协程作用域 lifecycleScope 启动协程。如果该 activity 被销毁,那么 lifecycleScope 会被取消,并且其所有子协程也会被自动取消。您只需要确定协程遵循该 Activity 的生命周期是不是您需要的行为。

在您将要构建的 Race Tracker Android 应用中,您将学习如何将协程的作用域限定为可组合项的生命周期。

CoroutineScope 的实现细节

如果您查看源代码以了解 CoroutineScope.kt 在 Kotlin 协程库中是如何实现的,就会看到 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 会议讲座视频,详细了解 CoroutineContext 以及如何从父级继承上下文。

前文已多次提到调度程序。它的作用是将工作分派或分配到线程。下面我们就详细介绍一下线程和调度程序。

调度程序

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

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

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

对于 Android 应用,只有当主线程执行速度非常快时,您才能对主线程调用阻塞代码。目的是让主线程保持未阻塞状态,以便主线程能够在触发新事件时立即执行工作。主线程是 activity 的界面线程,负责界面绘制和界面相关事件。当屏幕发生变化时,需要重新绘制界面。对于屏幕上的某些元素(例如动画),界面需要频繁地重新绘制,以使这些元素具有平滑过渡效果。如果主线程需要执行长时间运行的工作块,屏幕更新频率就会下降,用户就会看到生硬的过渡(称为“卡顿”),或者应用可能会挂起或响应缓慢。

因此,我们需要将任何长时间运行的工作项移出主线程,在其他线程中进行处理。应用开始时只有一个主线程,但您可以选择创建多个线程来执行其他工作。这些额外的线程可以称为工作器线程。长时间运行的任务如果长时间阻塞工作线程,是完全没有问题的,因为在此期间,主线程会保持畅通,可以积极响应用户。

Kotlin 提供了一些内置调度程序:

  • Dispatchers.Main:使用此调度程序可在 Android 主线程上运行协程。此调度程序主要用于处理界面更新和互动以及执行快速工作。
  • Dispatchers.IO:此调度程序经过了专门优化,适合在主线程之外执行磁盘或网络 I/O。例如,读取或写入文件以及执行任何网络操作。
  • Dispatchers.Default:在调用 launch()async() 时,如果其上下文中未指定调度程序,就会使用此默认调度程序。您可以使用此调度程序在主线程之外执行计算密集型工作。例如,处理位图图片文件。

不妨尝试一下 Kotlin 园地中的以下示例,更好地了解协程调度程序。

  1. 将 Kotlin 园地中您有的任何代码替换为以下代码:
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. 添加 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()),就需要明确指定并发。借助结构化并发机制,您可以将多个并发操作放入单个同步操作中,在这种情况下,并发是一个实现细节。对发起调用的代码只有一个要求:它必须在挂起函数或协程中。除此之外,发起调用的代码的结构不需要考虑并发细节。这使异步代码更易于阅读和推断。

结构化并发会跟踪应用中每个已启动的协程,确保其不会不知所踪。协程可以具有层次结构 - 任务可以启动子任务,而子任务也可以继续启动子任务。作业可在协程间保持父级与子级关系,而且您可以通过作业来控制协程的生命周期。

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

  1. 启动:将协程启动到一个限定了协程存留时长的作用域中。
  2. 完成:只有在子作业完成后,作业才会完成。
  3. 取消:此操作需要向下传播。如果取消某个协程,那么也需要取消子协程。
  4. 失败:此操作应向上传播。如果协程抛出异常,那么父级会取消其所有子级,取消本身,并将异常向上传播到其父级。此过程会一直持续到捕获失败并进行处理为止。这可以确保正确报告代码中的所有错误,绝不错过任何错误。

通过动手体验协程并了解协程背后的概念,您现在可以更好地在 Android 应用中编写并发代码了。使用协程进行异步编程,可以让代码更易于读取和推断,在取消和异常情况下更可靠,还可以为最终用户提供更出色、响应更迅速的体验。

摘要

  • 借助协程,您无需学习新的编程方式,即可编写以并发方式长时间运行的代码。协程采用依序执行的设计。
  • 协程遵循结构化并发的原则,不仅有助于确保工作不会不知所踪,还能将工作与限定了工作存留时长的作用域相关联。除非您明确要求并发执行(例如,使用 launch()async()),否则代码在默认情况下依序执行并与底层事件循环协同工作。此原则假定,如果您调用一个函数,那么无论该函数在实现细节中使用了多少协程,都应在彻底完成其工作(除非因异常而失败)后再返回。
  • suspend 修饰符用于标记函数,表示可以挂起并于稍后恢复该函数的执行。
  • suspend 函数只能从其他挂起函数或协程中调用。
  • 您可以使用 CoroutineScopelaunch()async() 扩展函数启动新协程。
  • Job 可管理协程的生命周期并维护父级与子级关系,在确保结构化并发方面发挥着重要作用。
  • CoroutineScope 通过其 Job 来控制协程的生命周期,并以递归方式对其子级和子级的子级执行取消和其他规则。
  • CoroutineContext 定义协程的行为,并可包含对作业和协程调度程序的引用。
  • 协程使用 CoroutineDispatcher 来确定用于其执行的线程。

了解更多内容