吾上可陪玉皇大帝,下可陪卑田院乞儿。眼前见天下无一不好人。------苏轼
本文集中探讨了 Android 支付系统中,订单状态流转模块的设计 。笔者基于Flow
技术栈实现整个"下单-支付"流程,对于过程中遇到的 状态重放、异步封装 等典型问题,也提供了现代流行技术架构下的参考解决方案。
0. 背景:前端视角下的购买流程
在具备支付功能的 APP 中,下单购买流程是绕不过去的话题,时至今日,从体验端到设计端,这套流程已经趋于统一的方案。站在用户视角观察,点击"购买"按钮后,APP 会拉起收银台,用户随后选择支付渠道并完成支付,最终 APP 返回订单页并展示购买后的订单。
站在前端视角下,以核心的"支付"作为支点,这套流程可以拆分为 "下单"+"支付"+"刷新" 三个环节:
- 支付前------下单
- 支付中------付款
- 支付后------刷新
以典型的外卖 APP 为例,用户视角、前端视角的交互可以描述如下图。
【美团下单流程图】
1. 订单状态机
考虑到在整个流程中,订单状态
是贯穿始终的线索,因此我们的设计从订单状态机开始。
【订单状态机】
以在外卖 APP 中下单、支付为例,三个阶段的流程描述如下表:
阶段 | 主要参与方 | 流程简述 |
---|---|---|
下单 | APP、外卖后台、支付后台 | APP 调用外卖后台创建订单。外卖后台与支付后台通信,获得本次交易参数(包含交易 id 等验证信息) |
支付 | APP、支付 SDK、支付后台、外卖后台 | APP 获取到交易参数后,拉起支付 SDK 并传入该参数,后者完成支付后,与支付后台通信,支付后台将支付结果回调给外卖后台 |
刷新 | APP、支付 SDK、外卖后台 | 支付 SDK 通知 APP 用户已完成支付,APP 在收到该信号后,轮询外卖后台,直至获取支付后的订单状态(或超时) |
在状态机中,额外设计了"存在未支付订单"、"存在已支付订单"两个状态,防止在支付后台通知不及时的时候,用户重复支付,造成用户的损失。
订单状态的代码设计如下,采用 sealed class
约束状态。
kotlin
// PaymentStatus.kt
sealed class PaymentStatus {
/**
* 初始态
*/
data object Idle : PaymentStatus()
/**
* 正在创建订单
*/
data object CreatingOrder: PaymentStatus()
/**
* 已创建订单
*/
data class Created(val orderInfo: OrderInfo) : PaymentStatus()
/**
* 该资源存在未支付订单
*/
data object HasUnpaidOrder : PaymentStatus()
/**
* 该资源存在已支付订单
*/
data object HasPaidOrder : PaymentStatus()
/**
* 用户正在支付中
*/
data object Paying : PaymentStatus()
/**
* 已支付,等待落单
*/
data object Confirming : PaymentStatus()
/**
* 已支付且落单成功
*/
data class Paid(val dialInfo: DialInfo) : PaymentStatus()
/**
* 支付失败
*/
data class Failed(val reason: String?) : PaymentStatus()
/**
* 支付取消(只存在于创建订单并拉起收银台之后)
*/
data object Cancelled : PaymentStatus()
}
2. 订单状态的产生与消费
由于订单状态是不断流转的,用户操作 APP 的过程,就是订单状态流动的过程 。因此,很适合基于 Flow
流式编程,进行支付模块的架构设计。
【支付模块架构】
在架构图中,状态从下向上流动,其生产者是 Repository
,消费者是 Activity
。本文接下来的部分,会聚焦于状态的消费和生产两部分,记录实现过程中的要点与难点。
2.1 状态的消费:Activity
kotlin
// ItemDetailActivity.kt
// 在 onCreate 中注册监听
fun initObservers() {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.paymentStatusFlow
.distintUntilChanged { old, new ->
old::class == new::class // 1、判断 class 相等去重
}
.collect {
updateUiWithPaymentStatus(it)
}
}
}
}
fun updateUiWithPaymentStatus(paymentStatus: PaymentStatus) {
when (paymentStatus) {
Idle -> {
// 不处理
}
Creating -> {
// 展示 Loading
}
Created -> {
viewModel.launchCashier(this@ItemDetailActivity, it.orderInfo) // 2、由于拉起收银台需要 Activity 对象,因此将自身作为参数1传入
}
...
}
}
上面的代码基本实现了"监听状态-更新 UI"的业务逻辑,并且还在监听时通过 distintUtilChanged
进行去重,防止上游产生 2 次相同数据时,UI 频繁刷新,乍一看似乎无懈可击,然而实际上并非如此。
2.1.1 repeatOnLifecycle 的坑------重放状态
注释 2
的地方,当监听到订单已创建时,会调用 launchCashier()
进行付款,ViewModel
中会接力调用 支付 SDK 的 launchCashier()
接口,拉起收银台弹窗/页面。在这个过程中,ItemDetailActivity
商详页的生命周期变化为:
STARTED
(商详页在前台) -> STOPPED
(进入收银台页面) -> STARTED
(完成支付/取消后返回商详页)。
在实测中,我发现这会导致 Activity 里面连续 2 次触发 updateUiuWithPaymentStatus(Created)
,从而重复调用 launchCashier()
接口。其原因在于,paymentStatusFlow
是一个 StateFlow
热流,在每次页面进入 STARTED
状态时,都是一个新的收集者发生订阅行为,该热流会立刻发射当前值。ViewModel 的特性是可以在 Activity 销毁/重建过程中保持存活,那么它自然保留了拉起收银台前一刻的订单状态 Created
,并在新的订阅者来到时,立刻发射该状态。distintUtilChanged
的去重过滤在这里是无效的,因为此刻来到的已经是一个全新的订阅者。
【明朝的剑】
2.1.2 解决方案------区分"状态"与"事件"
产生上述问题的根本原因是,在语义上把"状态"和"事件"混为一谈。
- 状态
StateFlow
:是 持续性 的,系统在某一个时刻一定处于某种状态中,订阅者发起订阅时立刻获取流最新的状态。 - 事件
SharedFlow
:是 瞬发性 的,发生过一次就消失掉,订阅者只能获取开始订阅以后新产生的事件。

【状态与事件图】
2.2 状态的生产:Repository
Repository 是状态的生产者,它向上提供接口供 ViewModel 访问,向下调用外卖后台、支付 SDK 的具体接口。
在现代化的架构设计中,后台接口常常被封装为 Retrofit 的接口类,例如外卖下单接口,这个接口接收下单参数,返回下单结果。
kotlin
// TakeoutApi.kt
/**
* 创建外卖待支付订单
*
* @return 订单对象,用于拉起收银台
*/
@POST("/path/createOrder.do")
suspend fun createOrder(
@Body params: OrderRequest
): BaseResponseEntity<TakeoutOrderResponse>
在上游的 Repository 将下单结果封装为 PaymentStatus
,继续发射给 ViewModel。
kotlin
// TakeoutRepository.kt
/**
* 创建订单
*/
fun createTakeoutOrder(orderReq: OrderRequest): Flow<PaymentStatus> = flow {
// 调用 API 创建订单
val createOrderResp = takeoutApi.createOrder(orderReq)
val createOrderStatus = createOrderRespToPaymentStatus(createOrderResp)
emit(createOrderStatus) // 创单状态:成功、失败
...
}
/**
* 执行支付
* 注意这里使用的是 callbackFlow 运算符
*/
fun payTakeoutOrder(orderInfo: OrderInfo): Flow<PaymentStatus> = callbackFlow {
// 调用支付 SDK 异步接口进行支付
paySDK.launchCashier(orderInfo) { payResult ->
trySend(payResultToPaymentStatus(payResult))
}
awaitClose{
Log.d(TAG, "paySDK.launchCashier flow closed")
}
}
3. 知识扩展
3.1 flow、callbackFlow
这两者都是用于生成 Flow 的运算符,一言以蔽之:
flow {}
用于 线性挂起任务callbackFlow {}
用于 事件回调式接口
两者完整的对比如下表所示。
对比项 | flow {} |
callbackFlow {} |
---|---|---|
核心用途 | 把 同步或挂起逻辑 转换成 Flow | 把 回调式或异步事件源 转换成 Flow |
发射方式 | 直接 emit(value) ,支持 suspend |
用 trySend(value) 发送,不是挂起函数 |
背后机制 | 顺序执行的协程块 | 基于 Channel 实现,支持并发回调发送 |
适用场景 | 适合一次性计算、顺序执行逻辑(如数据库查询、网络请求) | 适合持续事件流(如监听 SDK 回调、传感器数据、WebSocket) |
并发安全性 | 不支持并发发射,多个协程同时 emit 会异常 |
多个线程 / 回调线程同时 trySend 是安全的 |
取消与清理 | 结束 Flow 自动退出协程 | 必须在 awaitClose {} 中清理资源(如注销监听、关闭连接) |
典型写法 | flow { emit(api.getData()) } |
callbackFlow { listener = { trySend(it) }; awaitClose { unregister(listener) } } |
生命周期 | Flow 结束 = 协程结束 | Flow 结束前 Channel 一直活着,直到 close() 或 awaitClose() |
背压处理 | 内部默认顺序执行,无缓存 | 可通过 Channel 缓冲策略控制(如 buffer() ) |
性能开销 | 轻量 | 稍高(因为用到 Channel) |
3.2 suspendCoroutine
在封装传统的回调式 callback
选择上,除了使用 callbackFlow
将其变为 Flow
,还有一种方式,就是通过 suspendCoroutine
将回调转换为 挂起函数,用同步的方式来访问异步接口。
以 login(userName, password, LoginListener)
接口为例,传统的回调式写法如下:
kotlin
// 回调式写法
fun login(username: String, password: String, callback: (Result<User>) -> Unit) {
sdk.login(username, password, object : LoginListener {
override fun onSuccess(user: User) = callback(Result.success(user))
override fun onError(e: Throwable) = callback(Result.failure(e))
})
}
转成挂起函数后:
kotlin
suspend fun loginSuspend(username: String, password: String): User =
suspendCoroutine { cont ->
sdk.login(username, password, object : LoginListener {
override fun onSuccess(user: User) = cont.resume(user)
override fun onError(e: Throwable) = cont.resumeWithException(e)
})
}
将异常情况通过 Exception
抛出,从而保持接口语义的一致性(只返回 User
对象)。
3.2.1 suspendCoroutine 和 callbackFlow 的应用场景选择
这两者都是用来封装传统的回调,提供更加便利、适配现代架构的接口形式。在应用场景的选择上,存在语义的区别,需要根据实际的业务流程进行选择。
suspendCoroutine
:一个回调只返回一次(oneshot),只产生一次结果。callbackFlow
:会一直回调,源源不断地发射事件,例如监听网络连接状态、用户登录态、GPS 传感器数据等。
3.3 flatMap、flatMapLatest、flatMapConcat、flatMapMerge
ViewModel 层进行状态流转时,调用 Repository 接口获取 Flow 对象,在 collect {}
中设置 ViewState,供 Activity/Fragment 订阅,从而更新 UI。
当业务流程复杂程度升高时,ViewModel 在同一个函数中会访问多个流式接口,并且将获取到的状态进行拼装,对此,Kotlin 提供了多个用于简化操作的运算符。这些运算符同属于 flatMap {}
一族,直译过来是"摊平转换",即将输入的状态进行转换后,作为新的流发射出去。
以拼接两个流为例,常见的操作符有 flatmapConcat、flatMapMerge
、flatMapLatest
三种。
3.3.1 flatmapConcat

【flatMapConcat】
守序阵营,严格按照 流的先来后到顺序,逐个处理每个流中的元素。
kotlin
flow {
emit("a")
delay(100)
emit("b")
}.flatMapConcat { value ->
flow {
emit(value)
delay(200)
emit(value + "_last")
}
}
// 实际发射:a, a_last, b, b_last
3.3.2 flatmapMerge

【flatmapMerge】
中立阵营,遵循 元素自身生成的时间 顺序发射。
kotlin
flow {
emit("a")
delay(100)
emit("b")
}.flatMapConcat { value ->
flow {
emit(value)
delay(200)
emit(value + "_last")
}
}
// 实际发射:a, b, a_last, b_last
3.3.3 flatMapLatest

【flatmapLatest】
混乱阵营,新产生的元素会冲掉上一个元素的发射。
kotlin
flow {
emit("a")
delay(100)
emit("b")
}.flatMapLatest { value ->
flow {
emit(value)
delay(200)
emit(value + "_last")
}
}
// 实际发射:a, b, b_last