状态机就是:用一个变量记录当前执行到哪个状态,下次再进来时,根据这个状态跳到对应的位置继续执行。
而协程挂起/恢复的本质就是:
挂起时保存状态,恢复时根据状态继续执行。
1. 先看一个普通协程例子
suspend fun test() {
println("1")
delay(1000)
println("2")
delay(1000)
println("3")
}
从人的视角看,它是顺序执行:
打印 1
等待 1 秒
打印 2
等待 1 秒
打印 3
但是注意,delay(1000) 会挂起协程。
所以真实执行过程是:
第一次进入 test()
打印 1
遇到 delay,挂起,保存状态:我执行到 delay1 后面了
1 秒后恢复
继续打印 2
遇到 delay,挂起,保存状态:我执行到 delay2 后面了
1 秒后恢复
继续打印 3
结束
这里面就天然有几个状态:
状态 0:刚开始执行
状态 1:第一个 delay 后恢复
状态 2:第二个 delay 后恢复
状态 3:结束
2. 把它改写成状态机形式
原始代码:
suspend fun test() {
println("1")
delay(1000)
println("2")
delay(1000)
println("3")
}
可以粗略理解成被编译器改造成类似这样:
class TestStateMachine {
var state = 0
fun resume() {
when (state) {
0 -> {
println("1")
state = 1
// 启动一个 1 秒后的回调
delayLike {
resume()
}
return
}
1 -> {
println("2")
state = 2
delayLike {
resume()
}
return
}
2 -> {
println("3")
state = 3
return
}
3 -> {
println("已经结束")
}
}
}
fun delayLike(callback: () -> Unit) {
// 假设 1 秒后调用 callback
}
}
重点在这里:
var state = 0
这个 state 就是当前执行状态。
每次遇到挂起点之前,都会先改状态:
state = 1
然后 return,相当于协程挂起。
等异步操作完成后,再调用:
resume()
这时根据 state 继续执行:
when (state) {
1 -> {
println("2")
}
}
这就是状态机。
3. 状态机核心是什么?
状态机通常有三个核心要素:
1. 状态
2. 事件
3. 状态转移
对应到协程:
| 状态机概念 | 协程里的对应 |
|---|---|
| 状态 | 当前执行到哪一步 |
| 事件 | delay 到期、网络请求返回、回调触发 |
| 状态转移 | 从挂起点恢复,继续执行下一段代码 |
比如:
println("1")
delay(1000)
println("2")
可以理解为:
状态 0:准备执行 println("1")
事件:执行到 delay
状态转移:state = 1,然后挂起
状态 1:从 delay 后恢复
事件:delay 到期
状态转移:继续执行 println("2")
4. 用图表示协程状态机
对于这个函数:
suspend fun test() {
println("1")
delay(1000)
println("2")
delay(1000)
println("3")
}
状态图大概是:
[状态0:开始]
|
| 打印 1,遇到 delay
v
[状态1:等待第一次 delay 完成]
|
| delay 完成,恢复
v
[状态2:打印 2 后等待第二次 delay 完成]
|
| delay 完成,恢复
v
[状态3:打印 3,结束]
更准确一点:
state = 0
|
| println("1")
| delay 挂起前设置 state = 1
v
state = 1
|
| println("2")
| delay 挂起前设置 state = 2
v
state = 2
|
| println("3")
v
结束
5. 为什么协程需要状态机?
因为协程会在中间暂停。
普通函数调用是这样的:
fun normal() {
println("1")
println("2")
println("3")
}
它必须一次性执行完。
但是协程函数:
suspend fun test() {
println("1")
delay(1000)
println("2")
}
执行到 delay 时会退出当前执行。
问题来了:
1 秒后怎么知道要从哪里继续?
就靠状态机。
它要记住:
我上次执行到 delay 后面了
下次恢复时要执行 println("2")
所以编译器会生成类似:
when (label) {
0 -> 从头开始
1 -> 从第一个挂起点后继续
2 -> 从第二个挂起点后继续
}
这个 label 就是协程状态机里的状态编号。
6. 更接近 Kotlin 协程底层的写法
Kotlin 的 suspend 函数大概会被编译器转换成带有 Continuation 参数的函数。
你写的是:
suspend fun test() {
println("1")
delay(1000)
println("2")
}
底层概念上类似:
fun test(continuation: Continuation<Unit>): Any {
// ...
}
Continuation 可以理解成:
恢复协程需要的现场
里面会保存:
1. 当前状态 label
2. 局部变量
3. 上下文 CoroutineContext
4. resumeWith 方法
简化模拟:
class TestContinuation {
var label = 0
fun resume() {
when (label) {
0 -> {
println("1")
label = 1
delayLike {
resume()
}
return
}
1 -> {
println("2")
return
}
}
}
}
这里的:
label
就是状态机里的状态。
7. 有局部变量时,状态机还要保存变量
看这个例子:
suspend fun test() {
val name = "Tom"
delay(1000)
println(name)
}
问题是:
val name = "Tom"
是在 delay 之前创建的,但是 println(name) 在恢复之后才执行。
所以挂起时不只要保存执行位置,还要保存局部变量。
状态机大概会变成:
class TestStateMachine {
var label = 0
var name: String? = null
fun resume() {
when (label) {
0 -> {
name = "Tom"
label = 1
delayLike {
resume()
}
return
}
1 -> {
println(name)
return
}
}
}
}
也就是说:
跨挂起点还要继续用的局部变量,会被提升成状态机对象里的字段。
这就是你前面说的“保存现场”。
8. 再看一个更完整的例子
原始协程代码:
suspend fun loadUser() {
println("开始加载")
val token = getToken()
println("token = $token")
val user = getUser(token)
println("user = $user")
}
假设:
suspend fun getToken(): String
suspend fun getUser(token: String): User
这两个函数都会挂起。
那么状态大概是:
状态 0:开始执行 loadUser
状态 1:getToken 返回后
状态 2:getUser 返回后
状态 3:结束
伪代码:
class LoadUserStateMachine {
var label = 0
var token: String? = null
var user: User? = null
fun resume(result: Any? = null) {
when (label) {
0 -> {
println("开始加载")
label = 1
getTokenAsync { tokenResult ->
resume(tokenResult)
}
return
}
1 -> {
token = result as String
println("token = $token")
label = 2
getUserAsync(token!!) { userResult ->
resume(userResult)
}
return
}
2 -> {
user = result as User
println("user = $user")
label = 3
return
}
}
}
}
你可以看到:
label = 1
label = 2
就是在记录执行进度。
9. 状态机可以用在哪里?
状态机不只是协程底层用,业务开发中也很常见。
比如:
1. 订单状态
待支付 -> 已支付 -> 已发货 -> 已完成
-> 已取消
状态机写法:
enum class OrderState {
PendingPay,
Paid,
Shipped,
Finished,
Cancelled
}
状态转移:
fun pay(state: OrderState): OrderState {
return when (state) {
OrderState.PendingPay -> OrderState.Paid
else -> error("当前状态不能支付")
}
}
2. 播放器状态
Idle -> Playing -> Paused -> Playing -> Stopped
enum class PlayerState {
Idle,
Playing,
Paused,
Stopped
}
fun onPlay() {
state = when (state) {
PlayerState.Idle -> PlayerState.Playing
PlayerState.Paused -> PlayerState.Playing
else -> state
}
}
3. 登录流程
未登录 -> 登录中 -> 登录成功
-> 登录失败
sealed class LoginState {
object Idle : LoginState()
object Loading : LoginState()
data class Success(val user: User) : LoginState()
data class Error(val message: String) : LoginState()
}
这个在 Android UI 里非常常见:
when (state) {
is LoginState.Idle -> showIdle()
is LoginState.Loading -> showLoading()
is LoginState.Success -> showUser(state.user)
is LoginState.Error -> showError(state.message)
}
10. 状态机和协程的关系总结
你可以这样理解:
协程 = 语法层面的顺序写法
状态机 = 编译器底层实现方式
你写:
val token = getToken()
val user = getUser(token)
showUser(user)
看起来是同步顺序代码。
但底层类似:
状态0:调用 getToken,挂起
状态1:getToken 返回,调用 getUser,挂起
状态2:getUser 返回,调用 showUser
所以:
协程让你不用手写状态机,但编译器帮你生成了状态机。
11. 状态机的本质公式
状态机可以抽象成:
当前状态 + 输入事件 = 下一个状态 + 执行动作
比如协程:
当前状态:等待 getToken 返回
输入事件:getToken 成功返回 token
下一个状态:调用 getUser
执行动作:保存 token,发起 getUser 请求
订单:
当前状态:待支付
输入事件:用户支付成功
下一个状态:已支付
执行动作:生成支付记录
播放器:
当前状态:暂停
输入事件:点击播放
下一个状态:播放中
执行动作:继续播放音频
12. 最后一句话理解
结合协程来说,状态机就是:
把一段可能中途暂停的代码拆成多个状态,用一个变量记录当前执行到哪一步。每次恢复时,根据这个状态跳到对应代码继续执行。
协程里的:
suspend
delay
withContext
网络请求挂起
这些挂起点,都会变成状态机里的状态切换点。
所以你可以记住:
协程挂起 = 保存状态 + return
协程恢复 = 根据状态继续执行
状态机 = 管理这些状态和恢复逻辑的结构