什么是一次性事件
在应用中,你可能会使用过 Kotlin Channels 或其他响应式流(例如SharedFlow
)向 UI 公开 ViewModel
事件,或者可能你在其他项目中看到过此模式。当生产者(ViewModel
)比消费者(Compose UI
或 View
)生命周期更长时,这些 API 不能保证这些事件的传递和处理。这可能会给开发人员带来错误和未知的问题,而且对于大多数应用来说这也是不可接受的用户体验。
ViewModel
事件是 UI 应该执行的源自 ViewModel
的操作。例如,向用户显示一个消息,或者在应用程序状态发生变化时导航到不同的页面。
我们对 ViewModel
事件的处理有两种不同的建议方式:
- 每当
ViewModel
中发起一次性事件时,ViewModel 应立即处理该事件,从而触发状态更新。ViewModel
应该只公开应用的状态。公开尚未从ViewModel
简化为状态的事件意味着ViewModel
不是从这些事件派生的状态的真实来源;单向数据流 (UDF) 描述了仅向比生产者生命周期更长的消费者发送事件的优点。 - 状态应使用可观察的数据类型进行公开。
案例分析
下面是在应用程序的典型支付流程中实现 ViewModel
的示例。在下面的代码片段中,当支付请求结果返回时,MakePaymentViewModel
直接告诉 UI 导航到支付结果页面。我们将使用此示例来探讨为什么处理此类一次性 ViewModel
事件会带来问题和更高的工程成本。
class MakePaymentViewModel(...) : ViewModel() {
val uiState: StateFlow<MakePaymentUiState> = /* ... */
// ⚠️⚠️ DO NOT DO THIS!! ⚠️⚠️
// This one-off ViewModel event hasn't been handled nor reduced to state
// Boolean represents whether or not the payment was successful
private val _navigateToPaymentResultScreen = Channel<Boolean>()
// `receiveAsFlow` makes sure only one collector will process each
// navigation event to avoid multiple back stack entries
val navigateToPaymentResultScreen = _navigateToPaymentResultScreen.receiveAsFlow()
// Protecting makePayment from concurrent callers
// If a payment is in progress, don't trigger it again
private var makePaymentJob: Job? = null
fun makePayment() {
if (makePaymentJob != null) return
makePaymentJob = viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true) } // Show loading spinner
val isPaymentSuccessful = paymentsRepository.makePayment(...)
_navigateToPaymentResultScreen.send(isPaymentSuccessful)
} catch (ioe: IOException) { ... }
finally { makePaymentJob = null }
}
}
}
然后,UI 将使用此事件并进行相应的导航:
@Composable
fun MakePaymentScreen(
onPaymentMade: (Boolean) -> Unit,
viewModel: MakePaymentViewModel = viewModel()
) {
val currentOnPaymentMade by rememberUpdatedState(onPaymentMade)
val lifecycle = LocalLifecycleOwner.current.lifecycle
// Check whenever navigateToPaymentResultScreen emits a new value
// to tell the caller composable the payment was made
LaunchedEffect(viewModel, lifecycle) {
lifecycle.repeatOnLifecycle(state = STARTED) {
viewModel.navigateToPaymentResultScreen.collect { isPaymentSuccessful ->
currentOnPaymentMade(isPaymentSuccessful)
}
}
}
// Rest of the UI for the make payment screen.
}
上面看到的navigateToPaymentResultScreen
实现有多个设计缺陷。
反模式 1:有关付款完成的状态可能会丢失
Channel
不保证事件的传递和处理。因此,事件可能会丢失,从而使 UI 处于不一致的状态。当 ViewModel
(生产者)发送事件后, UI(消费者)立即转到后台并停止Channel
的收集时,就可能会发生这种情况。对于其他不可观察的数据类型 API(例如 SharedFlow
)同样如此,即使没有消费者在监听它们,它们也可能会发出事件。
这是一种反模式,因为如果我们从事务的角度考虑, UI 层中建模的支付结果状态不是持久的或原子的。对于 repository
言,付款可能已经成功,但我们却未能正确的导航到下一个屏幕。
注意:可以通过在发送和接收事件时使用
Dispatchers.Main.immediate
来缓解这种反模式。但是,如果不是通过 lint 检查来强制执行,则此解决方案可能容易出错,因为开发人员很容易忘记它。
反模式 2:告诉 UI 采取行动
对于支持多种屏幕尺寸的应用程序,根据屏幕尺寸的不同,给定 ViewModel
事件执行的 UI 操作可能会有所不同。例如,前面的案例在手机上运行时应导航至支付结果屏幕;但如果应用程序在平板电脑上运行,则该操作可能会在同一屏幕的不同部分显示结果。
ViewModel
应该告诉 UI 什么是应用状态,并且应该由 UI 来确定如何去响应这些状态。而不应该由 ViewModel
告诉 UI 它应该采取哪些操作。
反模式 3:没有立即处理一次性事件
将事件建模为“一劳永逸”的事件(发送后就被遗忘掉)是导致该问题的原因。这样使得遵守ACID属性变得比较困难,因此无法确保尽可能高的数据可靠性和完整性。状态就是,事件发生。事件未处理的时间越长,问题就会变得越困难。对于 ViewModel 事件,应当尽快处理该事件并从事件中生成一个新的 UI 状态。
在前面的案例中,我们为事件创建了一个对象(表示为一个Boolean
)并使用一个Channel
公开它:
// 使用建模为 Boolean 值的事件创建 Channel
val _navigateToPaymentResultScreen = Channel<Boolean>()
// 发送事件
_navigateToPaymentResultScreen.send(isPaymentSuccessful)
一旦你这样做了,你就承担了确保诸如一次性交付和处理之类的责任。如果出于某种原因必须将事件建模为对象,请将其生命周期限制为尽可能短,以免它有机会丢失。
处理 ViewModel
中的一次性事件通常归结为方法调用。例如,更新 UI 状态。一旦调用该方法,你就知道它是成功完成还是抛出异常,并且您知道它只发生了一次。
如何改进
如果你遇到这种场景,请重新考虑一次性 ViewModel
事件对你的 UI 实际意味着什么。立即处理它们并将它们还原为使用可观察数据持有者公开的 UI 状态(例如StateFlow
或mutableStateOf
)。
如果你很难找到一种方法来减少一次性 ViewModel 事件的状态,请重新考虑该事件对你的 UI 的实际含义。
在上面的示例中,ViewModel
应该公开实际的应用程序数据(在本例中为支付数据),而不是告诉 UI 要采取的操作。以下是 ViewModel
事件的更好表示,该事件已处理并还原为状态,并使用可观察的数据持有者类型进行公开。
data class MakePaymentUiState(
val paymentInformation: PaymentModel,
val isLoading: Boolean = false,
// PaymentResult models the application state of this particular payment attempt,
// `null` represents the payment hasn't been made yet.
val paymentResult: PaymentResult? = null
)
class MakePaymentViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow<MakePaymentUiState>(...)
val uiState: StateFlow<MakePaymentUiState> = _uiState.asStateFlow()
// Protecting makePayment from concurrent callers
// If a payment is in progress, don't trigger it again
private var makePaymentJob: Job? = null
fun makePayment() {
if (makePaymentJob != null) return
makePaymentJob = viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true) }
val isPaymentSuccessful = paymentsRepository.makePayment(...)
// The event of what to do when the payment response comes back
// is immediately handled here. It causes a UI state update.
_uiState.update {
it.copy(
isLoading = false,
paymentResult = PaymentResult(it.paymentInfo, isPaymentSuccessful)
)
}
} catch (ioe: IOException) { ... }
finally { makePaymentJob = null }
}
}
}
在上面的代码中,通过使用新的paymentResult
数据(31
行)调用_uiState.update
(28
行)来立即处理该事件;现在该事件不可能丢失了。事件已还原为state
,MakePaymentUiState
中的paymentResult
字段反映了支付结果的应用程序数据。
这样,用户界面就会对paymentResult
变化做出反应并采取相应的行动。
@Composable
fun MakePaymentScreen(
onPaymentMade: (PaymentModel, Boolean) -> Unit,
viewModel: MakePaymentViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
uiState.paymentResult?.let {
val currentOnPaymentMade by rememberUpdatedState(onPaymentMade)
LaunchedEffect(uiState) {
// Tell the caller composable that the payment was made.
// the parent composable will act accordingly.
currentOnPaymentMade(
uiState.paymentResult.paymentModel,
uiState.paymentResult.isPaymentSuccessful
)
}
}
// Rest of the UI for the login screen.
}
注意:如果在你的用例中,Activity
没有finish()
并保留在后台堆栈中,则你的 ViewModel
需要公开一个函数来清除 UiState
中的 paymentResult
(即将字段置为null
),并在 Activity
启动其他 Activity
后调用该函数。 这方面的一个例子可以在 Consuming events can trigger state updates 中找到。
正如 UI 层的其他注意事项部分中提到的,如果你的用例需要的话,你可以使用多个流公开屏幕的 UI 状态。重要的是这些流是可观察的数据持有者类型。在上面的示例中,由于isLoading
标志和paymentResult
属性高度交织在一起,因此暴露了唯一的 UI 状态流。将它们分开可能会导致 UI 不一致,例如,如果 isLoading
是 true
并且 paymentResult
不是 null
。通过将它们放在同一个 UiState
类中,我们可以更清楚地了解构成屏幕 UI 状态的不同字段,从而减少错误。
希望这篇博文可以帮助你理解我们在本文开头提出的两条建议的原因:
- 1) 立即处理一次性 ViewModel 事件并将其简化为状态
- 2) 使用可观察的数据持有者类型公开这些状态
我们相信,这种方法可以为你提供更多的交付和处理保证,通常更容易测试,并且可以与应用程序的其他部分一致性的进行集成。
参考资料:ViewModel: One-off event antipatterns
关于使用Channel
向 UI 发送一次性的事件状态可能会丢失的问题,可以参考下面链接中 Philipp Lackner 的视频讲解,他使用了一个更加简单清晰的案例来表达了对这个问题的看法: