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

吾上可陪玉皇大帝,下可陪卑田院乞儿。眼前见天下无一不好人。------苏轼
本文集中探讨了 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 中会接力调用 支付 SDKlaunchCashier() 接口,拉起收银台弹窗/页面。在这个过程中,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、flatMapMergeflatMapLatest 三种。

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

4. 参考资料

相关推荐
JanelSirry3 小时前
微服务是不是一定要容器化(如 Docker)?我该怎么选
docker·微服务·架构
没有bug.的程序员3 小时前
电商系统分布式架构实战:从单体到微服务的演进之路
java·分布式·微服务·云原生·架构·监控体系·指标采集
重生之我要当java大帝3 小时前
java微服务-尚医通-数据字典-5
vue.js·微服务·云原生·架构
Query*3 小时前
Java 设计模式——代理模式:从静态代理到 Spring AOP 最优实现
java·设计模式·代理模式
小趴菜82273 小时前
Android中加载unity aar包实现方案
android·unity·游戏引擎
qq_252924193 小时前
PHP 8.0+ 现代Web开发实战指南 引
android·前端·php
Jeled3 小时前
Android 本地存储方案深度解析:SharedPreferences、DataStore、MMKV 全面对比
android·前端·缓存·kotlin·android studio·android jetpack
居7然4 小时前
DeepSeek-7B-chat 4bits量化 QLora 微调
人工智能·分布式·架构·大模型·transformer
自由的疯4 小时前
Java 怎么学习Kubernetes
java·后端·架构