深入探讨 Jetpack Compose 中的一次性事件

引言

在使用了一段时间 Jetpack ComposeMVI 架构开发中,我们经常处理一次性事件,这在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,以避免某些特定的问题。


总结

三种处理一次性事件的方法,各有各的优缺点,在日常开发中我用的最多的时 ChannelSharedFlow,虽然 State 可以解决流丢失的问题但是会有重复的代码,需要重置状态对于我来说不太友好,其次是 SharedFlow 虽然也可以实现缓存的功能但是需要手动设置,我会选择 Channel 处理该问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值