【业务场景架构实战】5. 使用 Flow 模式传递状态过程中的思考点

天下难事,必作于易;天下大事,必作于细。

我在使用 Kotlin Flow 实现业务功能模块的过程中,遇到一些细节上的问题,虽然微小,但仍值得思考、学习和记录。请将本文结合前一篇文章《【业务场景架构实战】4. 支付状态分层流转的设计和实现》共同阅读。

1. 如何将 支付 SDK 的回调式接口封装为 Flow

这是一个典型场景,把 传统回调 -> Flow 的编程风格进行转换,其核心思想是用 callbackFlow {},将异步回调改写为同步的格式。

异步 API

kotlin 复制代码
fun pay(orderId: String, callback: (result: PaymentResult) -> Unit)

改写后的 Flow,便于进行状态监听

kotlin 复制代码
class AlipaySdkWrapper : PaymentSdkWrapper {
    override fun pay(orderId: String): Flow<PaymentResult> = callbackFlow {
        // 假设这是支付宝SDK提供的函数
        AlipaySdk.pay(orderId) { result ->
            trySend(result) // 将回调结果发送到Flow
            close()         // 一次性回调,结果出来就关闭流
        }

        // 当Flow被取消/关闭时,执行清理逻辑
        awaitClose {
            // 比如取消支付请求(如果SDK支持)
            // AlipaySdk.cancel(orderId)
        }
    }
}

关于 callbackFlow,还有一些细节需要留意:

  • 内部使用 trySend() 向下游发射数据。
  • 默认是 冷流 ,只有新产生订阅者时,才会触发 AlipaySdk.pay(),这也符合支付接口原本的设计意图。
  • 如果支付只有一次最终结果(成功/失败),则可使用 close() 对流执行关闭操作。如果支付接口仍然存在多次回调(例如 处理中->成功/失败),则不可进行 close()
  • awaitClose() 用于在流关闭后(例如 VM 销毁、Flow 被取消等),释放 SDK 的 listener(例如 unregisterListener),防止资源泄漏。

在 ViewModel 里调用支付接口

经过改写后,在 VM 层就可以通过统一的方式调用支付,这样做的好处是,外部代码观察到的是同一的 Flow 接口,而底层具体实现可以无痛替换(例如改为微信支付、银联支付、苹果支付等)。

kotlin 复制代码
viewModelScope.launch {
    paymentSdk.pay("123456")
        .collect { result ->
            when (result) {
                is PaymentResult.Success -> println("支付成功")
                is PaymentResult.Failed -> println("支付失败")
            }
        }
}

2. 使用 ActivityComponent 引起的生命周期倒置问题

在唤起聚合支付 SDK 所提供的收银台过程中,其支付接口 pay(activity, orderInfo) 需要传入一个 Activity 类型的参数,这样设计的原因,是由于收银台会以类似 Dialog Fragment 的方式呈现在当前页面上,涉及到页面视图的引用,因此必须使用到 Activity 对象。

但是,这对我们的分层设计产生了一个不大不小的困扰,最初对于分层的设计如下:

  1. DataSource
  2. Repository
  3. ViewModel
  4. UI

其中 DataSource 位于最底层,提供支付 SDK、后端接口等的抽象。在上述分层设计里,除了最上层 UI,其他各层是不会接触到页面层级对象的,正是由于这种设计,才能够保持 ViewModel 独立于页面生命周期的特性,以便在页面销毁后重建时恢复状态。

然而,如果通过 HiltActivity 提供给 PaymentSdk,虽然可以简化支付接口的层层调用,使其只需要传递订单信息,但却打破了这种解耦关系。

错误写法,将 PaymentSdk 生命周期声明为页面级

kotlin 复制代码
@Module
@InstallIn(ActivityComponent::class)
abstract class PaymentModule {

    @Binds
    abstract fun bindPaymentSdkWrapper(
        impl: AlipaySdkWrapper
    ): PaymentSdkWrapper
}

上面这种写法会导致 RepositoryDataSource 生命周期倒置的问题。Repository 是全局单例,而它持有了 DataSource,因此后者必须也是全局单例,从而将其声明为 ActivityComponent 是行不通的,除非也将 Repository 改写为同样的 ActivityComponent。但这样的话,会导致 Repository 跟随页面销毁重建,并没有必要这么做,在实现时还是要尽可能保持整洁和精简。

因此,最终采用的方案是,保持 AlipaySdk.pay() 接口的 Activity 参数,从 Activity-VieModel-Repository 中层层传递过来。

CourseDetailActivity.kt

kotlin 复制代码
btnPay.setOnClickListener {
    viewModel.pay(this)
}

3. ViewModel 对外暴露状态:成员变量 or 接口返回值

这也是一个值得探究的问题。

我们知道在传统的 LiveData 模式中,ViewModel 内部持有 LiveData 成员变量,Activity 通过订阅该对象,实现对数据流的监听。

Flow 问世以来,它逐渐替代掉了老旧的 LiveData,提供 StateFlowSharedFlow 供页面监听,它的写法是与 LiveData 相似的。

但有一点区别尤其明显------ Flow 可以用作函数返回值,并且这也是一种非常典型的 Flow 用法。

1. UI State 成员变量模式

特点是 状态驱动 ,ViewModel 内部维护页面状态变量。注意到这里更底层的 Repository 实际上是返回的 Flow 对象,在 ViewModel 收集它时,用 collect 对内部状态进行更新。

ViewModel 层更新状态

kotlin 复制代码
@HiltViewModel
class PaymentViewModel @Inject constructor(
    private val repository: PaymentRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(PaymentUiState(idle = true))
    val uiState: StateFlow<PaymentUiState> = _uiState

    fun pay(activity: Activity, orderInfo: OrderInfo) {
        viewModelScope.launch {
            repository.startPay(activity, orderInfo) // ===> 下游 Repository 接口返回 Flow<PaymentResult> 对象
                .map { result ->
                    when (result) {
                        is PaymentResult.Success -> PaymentUiState(success = true, message = "支付成功")
                        is PaymentResult.Failure -> PaymentUiState(success = false, message = result.reason)
                        is PaymentResult.Cancelled -> PaymentUiState(success = false, message = "用户取消")
                    }
                }
                .collect { state ->
                    _uiState.value = state
                }
        }
    }
}

UI 层收集状态

kotlin 复制代码
lifecycleScope.launchWhenStarted {
    viewModel.uiState.collect { uiState ->
        when {
            uiState.idle -> showIdleUI()
            uiState.success -> showSuccessUI()
            else -> showErrorUI(uiState.message)
        }
    }
}

2. ViewModel 函数返回值式

特点是 流程驱动Activity 调用 ViewModel 函数 -> 函数返回状态 -> Activity 收集函数返回的状态。由于 ViewModel 内部不对状态进行维护,每一次调用都生成独立的流,能够用于 多订单并行支付 的场景。

Activity 调用 vm.pay() 获得 StateFlow,进行收集并展示

kotlin 复制代码
@AndroidEntryPoint
class PaymentActivity : AppCompatActivity() {

    private val viewModel: PaymentViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val orderInfo = OrderInfo(id = "12345")

        lifecycleScope.launchWhenStarted {
            viewModel.pay(this@PaymentActivity, orderInfo).collect { uiState ->
                when {
                    uiState.idle -> showIdleUI()
                    uiState.success -> showSuccessUI()
                    else -> showErrorUI(uiState.message)
                }
            }
        }
    }
}

3. 我的选择

最终在两者之间,我选择了前者,即 UI State 成员变量模式。对于 Activity 来说,维护在 ViewModel 中的状态更适合描述和记录页面信息,在发生问题时,通过打印当前状态,也更容易进行定位排查。

4. 选择 Fake 实现 所处的层次

使用 Hilt 的一个重要优势,就是可以在开发阶段,在真实业务逻辑实现与 Fake 实现之间灵活切换。

  1. DataSource
  2. Repository
  3. ViewModel
  4. UI

回到我们的分层设计,在这个分层中,ViewModelUI 作为终端呈现,没有为其设计虚假实现得必要。因此,我们对 Fake 对象的选择,要么是 FakeDataSource,要么是 FakeRepository

在进行决策时,牢记这个原则:

越是底层的 Fake,其代码覆盖程度越广。

FakeRepository 与 FakeDataSourcee

对两种 Fake 方案的自测覆盖范围,对比阐述如下:

Fake 所在层次 Repository 层 DataSource 层
能测到 UI
能测到 ViewModel
能测到 Reopsitory
能测到 DataSource
开发复杂程度

可见两者最主要的区别是,使用 FakeDataSource 可以用来验证 Repository内部逻辑是否正确。后续在接入新的支付渠道时,测试逻辑能够保持一致性,测试范围的覆盖也更加到位。

最终我选择的是 FakeDataSource 来实现。

相关推荐
2501_9160137414 小时前
iOS 推送开发完整指南,APNs 配置、证书申请、远程推送实现与上架调试经验分享
android·ios·小程序·https·uni-app·iphone·webview
李艺为16 小时前
非预置应用使用platform签名并且添加了android.uid.system无法adb安装解决方法
android·adb
李宥小哥18 小时前
C#基础11-常用类
android·java·c#
文火冰糖的硅基工坊21 小时前
《投资-111》价值投资者的认知升级与交易规则重构 - 价值投资的思维模式:穿越表象,回归本质
重构·架构·投资·投机
Jerry1 天前
Compose 中的绘制功能简介
android
我科绝伦(Huanhuan Zhou)1 天前
【脚本升级】银河麒麟V10一键安装MySQL9.3.0
android·adb
消失的旧时光-19431 天前
Android回退按钮处理方法总结
android·开发语言·kotlin
叫我龙翔1 天前
【MySQL】从零开始了解数据库开发 --- 数据表的约束
android·c++·mysql·数据库开发
2501_916013741 天前
iOS 上架 App 全流程实战,应用打包、ipa 上传、App Store 审核与工具组合最佳实践
android·ios·小程序·https·uni-app·iphone·webview