引言
在使用了一段时间 Jetpack Compose
和 MVI
架构开发中,我们经常处理一次性事件,这在Android开发中是非常常见的问题,那到底什么是一次性事件呢?
例如:在 ViewModel
中我们执行某一段逻辑代码后,需要向 UI
发送事件并且该事件只会在 UI
上执行一次。而人们一般处理它的方法为:Channel(通道)
、SharedFlow(共享流)
,但是你有没有想过这两种方式真的能保证一次性事件能被 UI
接收而不丢失吗?
示例
下面以经典的登录场景,分析一下这两种方法的优缺点以及介绍一下第三种解决方案。
首先在 MainViewModel
中声明了 UiEvent
的密封类,用于向 UI
发送一次性事件,在调用模拟登录的方法中,修改当前的状态为登录中,然后延迟3秒后向 UI
发送一个导航到个人页的一次性事件。
class MainViewModel : ViewModel() {
//Channel
private val _channel = Channel<UiEvent>()
val channel = _channel.receiveAsFlow()
//SharedFlow
private val _sharedFlow = MutableSharedFlow<UiEvent>( )
val sharedFlow = _sharedFlow.asSharedFlow()
//状态
var state by mutableStateOf(LoginState())
private set
fun login() {
//模拟登录过程
viewModelScope.launch {
state = state.copy(isLoading = true)
delay(3000L)
//发送事件给 UI
_channel.send(UiEvent.NavigateToProfile)
//_sharedFlow.emit(UiEvent.NavigateToProfile)
state = state.copy(isLoading = false)
}
}
sealed class UiEvent {
object NavigateToProfile : UiEvent()
}
data class LoginState(
val isLoading: Boolean = false,
)
}
在 MainActivity
创建两个 Screen
,当点击登录按钮后进度指示器会根据当前的状态作出加载显示,在 LaunchedEffect
中监听生命周期的变化,并调用 repeatOnLifecycle
方法,它能安全的在生命周期中收集流,如果应用进入了后台是不会调用该方法的 ,当生命周期为 STARTED
时会重新收集流。
setContent {
OneOffEventTheme {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "login"
) {
composable("login") {
val viewModel = viewModel<MainViewModel>()
val state = viewModel.state
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.channel.collect { event ->
when(event){
is UiEvent.NavigateToProfile->{
navController.navigate("profile")
}
}
}
}
}
LoginScreen(state = state, viewModel = viewModel)
}
composable("profile") {
ProfileScreen()
}
}
}
}
//--------------------------------------------------------------------------
@Composable
fun LoginScreen(
state: LoginState,
viewModel: MainViewModel
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "当前将一次性事件作为状态")
Button(onClick = {
viewModel.login()
}) {
Text(text = "登录")
}
if (state.isLoading) {
CircularProgressIndicator()
}
}
}
@Composable
fun ProfileScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "个人页")
}
}
先来看看运行效果,跟预期的一样可以正常的导航到个人页,可能有人会说登录之后不应该能返回登录页呀!不急,这里我并没有做后退栈的处理,是为了埋下伏笔。注:这里Channel和SharedFlow的效果是一样的!
分析问题
先从下面两张图中发现问题,当点击按钮后,用户点击了 Home
键,在重新进入应用后并没有按照预期导航到个人页,此时应用进入到了后台,前面我们说到了repeatOnLifecycle
它确保应用处于后台时不会收集流,从生命周期为 STARTED
开始会重新收集流。那答案也就呼之欲出了,收集者在应用处于后台不再工作,重新返回时新的收集者没收到任何流,说明流丢失了。
仔细观看 Channel
并没有像 SharedFlow
一样丢失了流,原因在于 Channel
有缓冲区的功能,发射流后会进入到缓冲区等待收集者,所以即使当重新返回应用时,新的收集者会立马收集到流并导航到个人页。
由于 SharedFlow
它并不具备缓冲区的概念,所以当应用进入后台时,收集者不再工作,重新返回时,流已经丢失了。但是 SharedFlow
也有一个类型缓存的功能 replay
,默认为0需要手动设置缓存值。
// 设置了replay,SharedFlow会存储flow,在新的收集者收集时将收到过去3个flow值
private val _sharedFlow = MutableSharedFlow<UiEvent>(
replay = 3
)
val sharedFlow = _sharedFlow.asSharedFlow()
国外就有人提到一次性事件是一种反模式,建议用状态处理一次性事件:ViewModel: One-off event antipatterns
fun login() {
//模拟登录过程
viewModelScope.launch {
state = state.copy(isLoading = true)
delay(3000L)
//使用状态,让ui监听isLoggedIn
state = state.copy(isLoading = false,isLoggedIn = true)
}
LaunchedEffect(state.isLoggedIn){
if (state.isLoggedIn){
navController.navigate("profile")
}
}
可以看到使用状态处理也可以满足我们的需求,此时回想一下上面的埋下伏笔,你会发现我返回后退栈却不成功,一直停留在个人页,因为状态是持久性的并不存在丢失这一说,原因在于返回登录页时会触发 LaunchedEffect
,而 isLoggedIn
在登录之后始终为 true
并没有修改,所以就停留在个人页。
解决办法:状态重置,再登录过后需要把 isLoggedIn
设置为 false
。
//ViewModel
fun resetState(){
state = state.copy(isLoggedIn = false)
}
//MainActivity
LaunchedEffect(state.isLoggedIn){
if (state.isLoggedIn){
navController.navigate("profile")
viewModel.resetState()
}
}
当应用回到 STARTED
状态会时重新收集该流,那如果处于 DESTROYED
状态下,ViewModel
依然发射流到 UI
,意味着流会丢失。下面举一个例子,发射1000个流,然后再发射过程中不断的旋转屏幕让 Activity
处于 onDestory
状态下,看看流是否会丢失。
fun login() {
viewModelScope.launch {
repeat(1000){
delay(3L)
_channel.send(UiEvent.CounterEvent(it))//发射一个计数的流
}
}
}
sealed class UiEvent {
object NavigateToProfile : UiEvent()
data class CounterEvent(val count:Int) : UiEvent()
}
LaunchedEffect(lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.channel.collect { event ->
when(event){
...
is MainViewModel.UiEvent.CounterEvent->{
Log.d("Count", "COUNT:${event.count}")
}
}
}
}
}
//...
override fun onDestroy() {
super.onDestroy()
Log.d("Count", "COUNT:计数中断")
}
查看日志中,当收集器中断后 845 的流丢失了,说明在 Activity
处于销毁状态时会存在丢失流的现象,这也是国外文章讨论的问题,它并不能保证流能 UI
接收。
最后的解决方案:在主线程立即处理事件,默认在 Dispatchers.Main
它使用标准的消息队列(message queue)来调度协程任务,这意味着它会按照通常的主线程事件循环方式执行协程。如果设置 Dispatchers.Main.immediate
它会立即执行协程任务,而不管消息队列的状态,这意味着它会立即执行协程代码,而不管是否有挂起的消息队列任务,从而减少了延迟。
LaunchedEffect(lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.channel.collect { event ->
withContext(Dispatchers.Main.immediate){
//when(event)...
}
}
}
}
注意:需要小心使用
Dispatchers.Main.immediate
,因为它可能会导致协程在主线程上执行时出现不可预测的行为,特别是在执行长时间运行的任务时。通常情况下,你应该使用默认的
Dispatchers.Main
,因为它在大多数情况下能够提供良好的性能和预测性。只有在特殊情况下,你才应该考虑使用Dispatchers.Main.immediate
,以避免某些特定的问题。
总结
三种处理一次性事件的方法,各有各的优缺点,在日常开发中我用的最多的时 Channel
和 SharedFlow
,虽然 State
可以解决流丢失的问题但是会有重复的代码,需要重置状态对于我来说不太友好,其次是 SharedFlow
虽然也可以实现缓存的功能但是需要手动设置,我会选择 Channel
处理该问题。