【业务场景架构实战】8. 订单状态流转在 UI 端的呈现设计

478 阅读9分钟

吾上可陪玉皇大帝,下可陪卑田院乞儿。眼前见天下无一不好人。——苏轼

本文集中探讨了 Android 支付系统中,订单状态流转模块的设计。笔者基于 Flow 技术栈实现整个“下单-支付”流程,对于过程中遇到的 状态重放、异步封装 等典型问题,也提供了现代流行技术架构下的参考解决方案。

0. 背景:前端视角下的购买流程

在具备支付功能的 APP 中,下单购买流程是绕不过去的话题,时至今日,从体验端到设计端,这套流程已经趋于统一的方案。站在用户视角观察,点击“购买”按钮后,APP 会拉起收银台,用户随后选择支付渠道并完成支付,最终 APP 返回订单页并展示购买后的订单。

站在前端视角下,以核心的“支付”作为支点,这套流程可以拆分为 “下单”+“支付”+“刷新” 三个环节:

  • 支付前——下单
  • 支付中——付款
  • 支付后——刷新

以典型的外卖 APP 为例,用户视角、前端视角的交互可以描述如下图。

美团下单流程图.png

【美团下单流程图】

1. 订单状态机

考虑到在整个流程中,订单状态 是贯穿始终的线索,因此我们的设计从订单状态机开始。

订单状态机.png

【订单状态机】

以在外卖 APP 中下单、支付为例,三个阶段的流程描述如下表:

阶段主要参与方流程简述
下单APP、外卖后台、支付后台APP 调用外卖后台创建订单。外卖后台与支付后台通信,获得本次交易参数(包含交易 id 等验证信息)
支付APP、支付 SDK、支付后台、外卖后台APP 获取到交易参数后,拉起支付 SDK 并传入该参数,后者完成支付后,与支付后台通信,支付后台将支付结果回调给外卖后台
刷新APP、支付 SDK、外卖后台支付 SDK 通知 APP 用户已完成支付,APP 在收到该信号后,轮询外卖后台,直至获取支付后的订单状态(或超时)

在状态机中,额外设计了“存在未支付订单”、“存在已支付订单”两个状态,防止在支付后台通知不及时的时候,用户重复支付,造成用户的损失。

订单状态的代码设计如下,采用 sealed class 约束状态。

// 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 流式编程,进行支付模块的架构设计。

支付模块架构.png

【支付模块架构】

在架构图中,状态从下向上流动,其生产者是 Repository,消费者是 Activity。本文接下来的部分,会聚焦于状态的消费和生产两部分,记录实现过程中的要点与难点。

2.1 状态的消费:Activity

// 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 中会接力调用 支付 SDKlaunchCashier() 接口,拉起收银台弹窗/页面。在这个过程中,ItemDetailActivity 商详页的生命周期变化为:

STARTED(商详页在前台) -> STOPPED(进入收银台页面) -> STARTED(完成支付/取消后返回商详页)。

在实测中,我发现这会导致 Activity 里面连续 2 次触发 updateUiuWithPaymentStatus(Created),从而重复调用 launchCashier()接口。其原因在于,paymentStatusFlow 是一个 StateFlow 热流,在每次页面进入 STARTED 状态时,都是一个新的收集者发生订阅行为,该热流会立刻发射当前值。ViewModel 的特性是可以在 Activity 销毁/重建过程中保持存活,那么它自然保留了拉起收银台前一刻的订单状态 Created,并在新的订阅者来到时,立刻发射该状态。distintUtilChanged 的去重过滤在这里是无效的,因为此刻来到的已经是一个全新的订阅者。

明朝的剑.jpeg

【明朝的剑】

2.1.2 解决方案——区分“状态”与“事件”

产生上述问题的根本原因是,在语义上把“状态”和“事件”混为一谈。

  • 状态 StateFlow:是 持续性 的,系统在某一个时刻一定处于某种状态中,订阅者发起订阅时立刻获取流最新的状态。
  • 事件 SharedFlow:是 瞬发性 的,发生过一次就消失掉,订阅者只能获取开始订阅以后新产生的事件。
状态与事件图.png

【状态与事件图】

2.2 状态的生产:Repository

Repository 是状态的生产者,它向上提供接口供 ViewModel 访问,向下调用外卖后台、支付 SDK 的具体接口。

在现代化的架构设计中,后台接口常常被封装为 Retrofit 的接口类,例如外卖下单接口,这个接口接收下单参数,返回下单结果。

// TakeoutApi.kt

/**
 * 创建外卖待支付订单
 *
 * @return 订单对象,用于拉起收银台
 */
@POST("/path/createOrder.do")
suspend fun createOrder(
    @Body params: OrderRequest
): BaseResponseEntity<TakeoutOrderResponse>

在上游的 Repository 将下单结果封装为 PaymentStatus,继续发射给 ViewModel。

// 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),支持 suspendtrySend(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) 接口为例,传统的回调式写法如下:

// 回调式写法
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))
    })
}

转成挂起函数后:

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、flatMapMergeflatMapLatest 三种。

3.3.1 flatmapConcat

flatmapConcat.png

【flatMapConcat】

守序阵营,严格按照 流的先来后到顺序,逐个处理每个流中的元素。

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.png

【flatmapMerge】

中立阵营,遵循 元素自身生成的时间 顺序发射。

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.png

【flatmapLatest】

混乱阵营,新产生的元素会冲掉上一个元素的发射。

flow {
    emit("a")
    delay(100)
    emit("b")
}.flatMapLatest { value ->
    flow {
        emit(value)
        delay(200)
        emit(value + "_last")
    }
}
// 实际发射:a, b, b_last

4. 参考资料