安卓开发必读 | Kotlin Vocabulary—揭秘协程中的suspend 修饰符
wptr33 2024-12-03 18:45 12 浏览
Kotlin 协程把 suspend 修饰符引入到了我们 Android 开发者的日常开发中。您是否好奇它的底层工作原理呢?编译器是如何转换我们的代码,使其能够挂起和恢复协程操作的呢?
了解这些将会帮您更好地理解挂起函数 (suspend function) 为什么只会在所有工作完成后才会返回,以及如何在不阻塞线程的情况下挂起代码。
本文概要: Kotlin 编译器将会为每个挂起函数创建一个状态机,这个状态机将为我们管理协程的操作!
如果您是 Android 平台上协程的初学者,请查阅下面这些协程 codelab:
在 Android 应用中使用协程
https://codelabs.developers.google.com/codelabs/kotlin-coroutines/#0
协程的进阶使用: Kotlin Flow 和 Live Data
https://codelabs.developers.google.com/codelabs/advanced-kotlin-coroutines/#0
协程 101
协程简化了 Android 平台的异步操作。正如官方文档《利用 Kotlin 协程提升应用性能》所介绍的,我们可以使用协程管理那些以往可能阻塞主线程或者让应用卡死的异步任务。
《利用 Kotlin 协程提升应用性能》
https://developer.android.google.cn/kotlin/coroutines
协程也可以帮我们用命令式代码替换那些基于回调的 API。例如,下面这段使用了回调的异步代码:
// 简化的只考虑了基础功能的代码
fun loginUser(userId: String, password: String, userResult: Callback<User>) {
// 异步回调
userRemoteDataSource.logUserIn { user ->
// 成功的网络请求
userLocalDataSource.logUserIn(user) { userDb ->
// 保存结果到数据库
userResult.success(userDb)
}
}
}
上面的回调可以通过使用协程转换为顺序调用:
suspend fun loginUser(userId: String, password: String): User {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
return userDb
}
在后面这段代码中,我们为函数添加了 suspend 修饰符,它可以告诉编译器,该函数需要在协程中执行。作为开发者,您可以把挂起函数看作是普通函数,只不过它可能会在某些时刻挂起和恢复而已。
不同于回调,协程提供了一种简单的方式来实现线程间的切换以及对异常的处理。但是,在我们把一个函数写成挂起函数时,编译器在内部究竟做了什么事呢?
Suspend 的工作原理
回到 loginUser 挂起函数,注意它调用的另一个函数也是挂起函数:
suspend fun loginUser(userId: String, password: String): User {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
return userDb
}
// UserRemoteDataSource.kt
suspend fun logUserIn(userId: String, password: String): User
// UserLocalDataSource.kt
suspend fun logUserIn(userId: String): UserDb
简而言之,Kotlin 编译器会把挂起函数使用有限状态机 (稍后讲到) 转换为一种优化版回调。也就是说,编译器会帮您实现这些回调!
有限状态机
https://en.wikipedia.org/wiki/Finite-state_machine
Continuation 接口
挂起函数通过 Continuation 对象在方法间互相通信。Continuation 其实只是一个具有泛型参数和一些额外信息的回调接口,稍后我们会看到,它会实例化挂起函数所生成的状态机。
Continuation
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/-continuation/index.html
Continuation
https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/coroutines/Continuation.kt
我们先来看看它的声明:
interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(value: Result<T>)
}
context 是 Continuation 将会使用的 CoroutineContext;
resumeWith 会恢复协程的执行,同时传入一个 Result 参数,Result 中会包含导致挂起的计算结果或者是一个异常。
Result
https://github.com/Kotlin/kotlinx.coroutines/blob/master/stdlib-stubs/src/Result.kt
注意: 从 Kotlin 1.3 开始,您也可以使用 resumeWith 对应的扩展函数: resume (value: T) 和 resumeWithException (exception: Throwable)。
resume
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/resume.html
resumeWithException
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/resume-with-exception.html
编译器将会在函数签名中使用额外的 completion 参数 (Continuation 类型) 来代替 suspend 修饰符。而该参数将会被用于向调用该挂起函数的协程返回结果:
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
completion.resume(userDb)
}
为了简化起见,我们的例子将会返回一个 Unit 而不是 User。User 对象将会在被加入的 Continuation 参数中 "返回"。
其实,挂起函数在字节码中返回的是 Any。因为它是由 T | COROUTINE_SUSPENDED 构成的组合类型。这种实现可以使函数在可能的情况下同步返回。
注意: 如果您使用 suspend 修饰符标记了一个函数,而该函数又没有调用其它挂起函数,那么编译器会添加一个额外的 Continuation 参数但是不会用它做任何事,函数体的字节码则会看起来和一般的函数一样。
您也会在其他地方看到 Continuation 接口:
当使用 suspendCoroutine 或 suspendCancellableCoroutine (首选使用) 来将基于回调的 API 转化为协程时,会直接与一个 Continuation 对象进行交互。它会用于恢复那些执行了参数代码块后挂起的协程;
您可以在一个挂起函数上使用 startCoroutine 扩展函数,它会接收一个 Continuation 对象作为参数,并会在新的协程结束时调用它,无论其运行结果是成功还是异常。
suspendCoroutine
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/suspend-coroutine.html
suspendCancellableCoroutine
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/suspend-cancellable-coroutine.html
startCoroutine
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines/start-coroutine.html
使用不同的 Dispatcher
您可以在不同的 Dispatcher 间切换,从而做到在不同的线程中执行计算。那么 Kotlin 是如何知道从哪里开始恢复挂起的计算的呢?
Continuation 有一个子类叫 DispatchedContinuation,它的 resume 函数会执行一次调度调用,并会调度至 CoroutineContext 包含的 Dispatcher 中。除了那些将 isDispatchNeeded 方法 (会在调度前调用) 重写为始终返回 false 的 Dispatcher.Unconfined,其他所有的 Dispatcher 都会调用 dispatch 方法。
DispatchedContinuation
https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/common/src/internal/DispatchedContinuation.kt
生成状态机
特殊说明: 本文接下来所展示的,并不是与编译器生成的字节码完全相同的代码,而是足够精确的,能够确保您理解其内部发生了什么的 Kotlin 代码。这些声明由版本为 1.3.3 的协程库生成,可能会在其未来的版本中作出修改。
Kotlin 编译器会确定函数何时可以在内部挂起,每个挂起点都会被声明为有限状态机的一个状态,每个状态又会被编译器用标签表示:
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
// Label 0 -> 第一次执行
val user = userRemoteDataSource.logUserIn(userId, password)
// Label 1 -> 从 userRemoteDataSource 恢复
val userDb = userLocalDataSource.logUserIn(user)
// Label 2 -> 从 userLocalDataSource 恢复
completion.resume(userDb)
}
为了更好地声明状态机,编译器会使用 when 语句来实现不同的状态:
fun loginUser(userId: String, password: String, completion: Continuation<Any?>) {
when(label) {
// Label 0 -> 第一次执行
userRemoteDataSource.logUserIn(userId, password)
}
// Label 1 -> 从 userRemoteDataSource 恢复
userLocalDataSource.logUserIn(user)
}
// Label 2 -> 从 userLocalDataSource 恢复
completion.resume(userDb)
}
else -> throw IllegalStateException(...)
}
}
这时候的代码还不完整,因为各个状态之间无法共享信息。编译器会使用同一个 Continuation 对象在方法中共享信息,这也是为什么 Continuation 的泛型参数是 Any,而不是原函数的返回类型 (即 User)。
接下来,编译器会创建一个私有类,它会:
保存必要的数据;
递归调用 loginUser 函数来恢复执行。
您可以查看下面提供的编译器生成类的近似版本。
特别说明: 注释不是由编译器生成的,而是由作者添加的。添加它们是为了解释这些代码的作用,也能让后面的代码更加容易理解。
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
class LoginUserStateMachine(
// completion 参数是调用了 loginUser 的函数的回调
completion: Continuation<Any?>
): CoroutineImpl(completion) {
// suspend 的本地变量
var user: User? = null
var userDb: UserDb? = null
// 所有 CoroutineImpls 都包含的通用对象
var result: Any? = null
var label: Int = 0
// 这个方法再一次调用了 loginUser 来切换
// 状态机 (标签会已经处于下一个状态)
// result 将会是前一个状态的计算结果
override fun invokeSuspend(result: Any?) {
this.result = result
loginUser(null, null, this)
}
}
...
}
由于 invokeSuspend 函数将会再次调用 loginUser 函数,并且只会传入 Continuation 对象,所以 loginUser 函数签名中的其他参数变成了可空类型。此时,编译器只需要添加如何在状态之间切换的信息。
首先需要知道的是:
函数是第一次被调用;
函数已经从前一个状态中恢复。
做到这些需要检查 Contunuation 对象传递的是否是 LoginUserStateMachine 类型:
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
...
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
...
}
如果是第一次调用,它将创建一个新的 LoginUserStateMachine 实例,并将 completion 实例作为参数接收,以便它记得如何恢复调用当前函数的函数。如果不是第一次调用,它将继续执行状态机 (挂起函数)。
现在,我们来看看编译器生成的用于在状态间切换并分享信息的代码:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
...
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
when(continuation.label) {
0 -> {
// 错误检查
throwOnFailure(continuation.result)
// 下次 continuation 被调用时, 它应当直接去到状态 1
continuation.label = 1
// Continuation 对象被传入 logUserIn 函数,从而可以在结束时恢复
// 当前状态机的执行
userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
}
1 -> {
// 检查错误
throwOnFailure(continuation.result)
// 获得前一个状态的结果
continuation.user = continuation.result as User
// 下次这 continuation 被调用时, 它应当直接去到状态 2
continuation.label = 2
// Continuation 对象被传入 logUserIn 函数,从而可以在结束时恢复
// 当前状态机的执行
userLocalDataSource.logUserIn(continuation.user, continuation)
}
... // 故意遗漏了最后一个状态
}
}
花一些时间浏览上面的代码,看看您是否能注意到与之前代码之间的差异。下面我们来看看编译器生成了什么:
when 语句的参数是 LoginUserStateMachine 实例内的 label;
每一次处理新的状态时,为了防止函数被挂起时运行失败,都会进行一次检查;
在调用下一个挂起函数 (即 logUserIn) 前,LoginUserStateMachine 的 label 都会更新到下一个状态;
在当前的状态机中调用另一个挂起函数时,continuation 的实例 (LoginUserStateMachine 类型) 会被作为参数传递过去。而即将被调用的挂起函数也同样被编译器转换成一个相似的状态机,并且接收一个 continuation 对象作为参数。当被调用的挂起函数的状态机运行结束时,它将恢复当前状态机的执行。
最后一个状态与其他几个不同,因为它必须恢复调用它的方法的执行。如您将在下面代码中所见,它将调用 LoginUserStateMachine 中存储的 cont 变量的 resume 函数:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
...
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
when(continuation.label) {
...
2 -> {
// 错误检查
throwOnFailure(continuation.result)
// 获取前一个状态的结果
continuation.userDb = continuation.result as UserDb
// 恢复调用了当前函数的函数的执行
continuation.cont.resume(continuation.userDb)
}
else -> throw IllegalStateException(...)
}
}
如您所见,Kotlin 编译器帮我们做了很多工作!例如示例中的挂起函数:
suspend fun loginUser(userId: String, password: String): User {
val user = userRemoteDataSource.logUserIn(userId, password)
val userDb = userLocalDataSource.logUserIn(user)
return userDb
}
编译器为我们生成了下面这些代码:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun loginUser(userId: String?, password: String?, completion: Continuation<Any?>) {
class LoginUserStateMachine(
// completion 参数是调用了 loginUser 的函数的回调
completion: Continuation<Any?>
): CoroutineImpl(completion) {
// 要在整个挂起函数中存储的对象
var user: User? = null
var userDb: UserDb? = null
// 所有 CoroutineImpls 都包含的通用对象
var result: Any? = null
var label: Int = 0
// 这个函数再一次调用了 loginUser 来切换
// 状态机 (标签会已经处于下一个状态)
// result 将会是前一个状态的计算结果
override fun invokeSuspend(result: Any?) {
this.result = result
loginUser(null, null, this)
}
}
val continuation = completion as? LoginUserStateMachine ?: LoginUserStateMachine(completion)
when(continuation.label) {
0 -> {
// 错误检查
throwOnFailure(continuation.result)
// 下次 continuation 被调用时, 它应当直接去到状态 1
continuation.label = 1
// Continuation 对象被传入 logUserIn 函数,从而可以在结束时恢复
// 当前状态机的执行
userRemoteDataSource.logUserIn(userId!!, password!!, continuation)
}
1 -> {
// 检查错误
throwOnFailure(continuation.result)
// 获得前一个状态的结果
continuation.user = continuation.result as User
// 下次这 continuation 被调用时, 它应当直接去到状态 2
continuation.label = 2
// Continuation 对象被传入 logUserIn 方法,从而可以在结束时恢复
// 当前状态机的执行
userLocalDataSource.logUserIn(continuation.user, continuation)
}
2 -> {
// 错误检查
throwOnFailure(continuation.result)
// 获取前一个状态的结果
continuation.userDb = continuation.result as UserDb
// 恢复调用了当前函数的执行
continuation.cont.resume(continuation.userDb)
}
else -> throw IllegalStateException(...)
}
}
Kotlin 编译器将每个挂起函数转换为一个状态机,在每次函数需要挂起时使用回调并进行优化。
了解了编译器在底层所做的工作后,您可以更好地理解为什么挂起函数会在完成所有它启动的工作后才返回结果。同时,您也能知道 suspend 是如何做到不阻塞线程的: 当方法被恢复时,需要被执行的信息全部被存在了 Continuation 对象之中!
转载自:CSDN博主「谷歌开发者_」的原创文章
- 上一篇:使用 Kotlin 密封类
- 下一篇:Kotlin 最常用函数(备忘查询)
相关推荐
- 为什么劝你不要买Switch OLED?(现在买switch oled划算吗)
-
那么在聊我们自己的观点前,还是先来看看这款任天堂SwitchOLED款式究竟带来了哪些变化吧。SwitchOLED已经把它最大的特点之一写到了名字里——OLED。这款产品改动最大的地方,就是在维...
- 新游戏、怀旧机!没有Switch新机的任天堂还能俘虏玩家们的心吗?丨C位
-
6月16日凌晨,日本电子游戏巨头任天堂在E3游戏展会上举行了发布会。翘首以盼的SwitchPro并未亮相,截止发稿,任天堂股价跌2.44%。但玩家们最为期待的《塞尔达:荒野之息2》将于2022年推出...
- 腾讯引进Nintendo Switch (OLED版)开启预售 多重满足您的娱乐需求
-
1月4日,腾讯引进NintendoSwitch(OLED版)开启预售,并将于1月11日正式发售。NintendoSwitch(OLED版)是NintendoSwitch的全新款式,配置色彩艳丽的...
- 股东大会上任天堂考虑移植更多WiiU和GC游戏到Switch
-
任天堂股东大会上有任天堂的忠实小股东玩家提议想在Switch玩到WiiU以及GC的经典游戏,对于这个问题,官方给了正面的回应。官方表示目前皮克敏等部分游戏已经可以在switch上玩了,今后会认真考虑玩...
- 《野狗子》M站均分低至60分!IGN仅给5分、VGC感觉像是PS3游戏
-
外山圭一郎工作室首部作品《野狗子》全球媒体口碑已解禁,总体评价一般,目前M站均分已降低至60分,21条评价,好评5条,中评14条,差评2条。IGN为其打出5分评价。《野狗子》的特色在于一些有趣的附身交...
- 《EA SPORTS FC 25》加入任天堂游戏试玩会,Switch版可免费体验
-
近日,EASPORTS宣布其最新力作《EASPORTSFC25》的Switch版本已正式加入任天堂游戏试玩会。即日起至3月2日,所有NintendoSwitchOnline订阅用户均可限时...
- Double Kill!马里奥乐园+Switch能解任天堂焦虑吗?丨C位
-
近日,“超级任天堂世界”主题乐园在大阪环球影城开园,“超级马里奥之父”宫本茂现身揭幕仪式。任天堂近来喜事连连,2020年财年前九个月实现营收838亿元,贡献最大的Switch销量远超索尼PS5与微...
- 迷你主机、Switch好搭子:16寸4K超亮QLED CFORCE便携屏值得入手吗
-
一、迷你主机、Switch好搭子...
- 怎样查看Switch的“主机序列号信息”
-
小时候看到游戏机就走不动,现在玩游戏越来越方便了,比如Switch.今天讲讲怎样查看Switch的“主机序列号信息”。第一步:点击【设置】...
- Switch版《EA SPORTS FC 25》容量相比前作缩水
-
备受期待的Switch版足球游戏《EASPORTSFC25》即将发布,根据任天堂官方网站的游戏信息显示,与前作《EASPORTSFC24》相比,游戏容量有所下降。在任天堂官方网站可以查到,...
- switch双系统升级注意点(switch双系统升级注意点是什么)
-
升级系统有风险,建议只有实在想玩的游戏玩不了的时候再升级。1、针对双系统进行,单系统不适用。2、国行系统同pj系统的升级是分开的,互不干涉。国行系统进入后直接升级即可。3、升级之前,建议先做好各类备份...
- 《鬼泣4:特别版》游戏评测:全家老小齐上阵
-
游戏名称:鬼泣4:特别版英文名称:DevilMayCry?4SpecialEdition游戏类型:动作游戏ACT制作公司:CAPCOMCo.,Ltd.发行公司:CAPCOMCo.,L...
- 最好不要在超过35°C的环境温度里玩你的掌机
-
随着进入“初伏”,天气越发酷热,今年全球高温尤为严重。Valve对此发布了一个安全警告,告诉玩家SteamDeck可以在哪些温度下安全运行。Valve指出,SteamDeck的最佳工作环...
- 任天堂回应股东玩家 考虑Switch回溯游玩WiiU以及GC游戏
-
今日有任天堂的忠实小股东玩家提议想在Switch玩到WiiU以及GC的经典游戏,官方回应称正在考虑中,敬请期待。·当然,Switch回溯游玩WiiU以及GC游戏的方式有任天堂的在线服务直接游玩官方优...
- Switch 2神秘C键用途曝光!或能连接初代NS手柄
-
直到近日,游戏圈内的风云人物、知名爆料人extas1s站了出来,带来一则令人颇感意外的消息。extas1s爆料称,玩家有望借助这个神秘的C按钮,实现初代Switch与Switch2...
- 一周热门
-
-
C# 13 和 .NET 9 全知道 :13 使用 ASP.NET Core 构建网站 (1)
-
因果推断Matching方式实现代码 因果推断模型
-
git pull命令使用实例 git pull--rebase
-
git pull 和git fetch 命令分别有什么作用?二者有什么区别?
-
面试官:git pull是哪两个指令的组合?
-
git 执行pull错误如何撤销 git pull fail
-
git fetch 和git pull 的异同 git中fetch和pull的区别
-
git pull 之后本地代码被覆盖 解决方案
-
还可以这样玩?Git基本原理及各种骚操作,涨知识了
-
git命令之pull git.pull
-
- 最近发表
-
- 为什么劝你不要买Switch OLED?(现在买switch oled划算吗)
- 新游戏、怀旧机!没有Switch新机的任天堂还能俘虏玩家们的心吗?丨C位
- 腾讯引进Nintendo Switch (OLED版)开启预售 多重满足您的娱乐需求
- 股东大会上任天堂考虑移植更多WiiU和GC游戏到Switch
- 《野狗子》M站均分低至60分!IGN仅给5分、VGC感觉像是PS3游戏
- 《EA SPORTS FC 25》加入任天堂游戏试玩会,Switch版可免费体验
- Double Kill!马里奥乐园+Switch能解任天堂焦虑吗?丨C位
- 迷你主机、Switch好搭子:16寸4K超亮QLED CFORCE便携屏值得入手吗
- 怎样查看Switch的“主机序列号信息”
- Switch版《EA SPORTS FC 25》容量相比前作缩水
- 标签列表
-
- git pull (33)
- git fetch (35)
- mysql insert (35)
- mysql distinct (37)
- concat_ws (36)
- java continue (36)
- jenkins官网 (37)
- mysql 子查询 (37)
- python元组 (33)
- mysql max (33)
- mybatis 分页 (35)
- vba split (37)
- redis watch (34)
- python list sort (37)
- nvarchar2 (34)
- mysql not null (36)
- hmset (35)
- python telnet (35)
- python readlines() 方法 (36)
- munmap (35)
- docker network create (35)
- redis 集合 (37)
- python sftp (37)
- setpriority (34)
- c语言 switch (34)