Jetpack Compose中HorizontalPager内LaunchedEffect事件收集异常
ViewModel代码
data class OneState( val name: String = "", val city: String = "" ) sealed class OneChannel { object FirstStepToSecondStep : OneChannel() object Finish : OneChannel() } @HiltViewModel class OneViewModel @Inject constructor() : ViewModel() { private val viewModelState = MutableStateFlow(OneState()) val screenState = viewModelState.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = viewModelState.value ) private val _channel = Channel<OneChannel>() val channel = _channel.receiveAsFlow() fun changeName(value: String) { viewModelState.update { it.copy(name = value) } } fun changeCity(value: String) { viewModelState.update { it.copy(city = value) } } fun firstStepToSecondStep() { Log.d("OneViewModel", "start of method first step to second step") if (viewModelState.value.name.isBlank()) { Log.d("OneViewModel", "name is empty, nothing should be done") return } Log.d( "OneViewModel", "name is not empty, first step to second step event will be send for composable" ) viewModelScope.launch { _channel.send(OneChannel.FirstStepToSecondStep) } } fun finish() { Log.d("OneViewModel", "start of method finish") if (viewModelState.value.city.isBlank()) { Log.d("OneViewModel", "city is empty, nothing should be done") return } Log.d( "OneViewModel", "city is not empty, finish event will be send for composable" ) viewModelScope.launch { _channel.send(OneChannel.Finish) } } }
该ViewModel包含MutableStateFlow管理状态、对外暴露的StateFlow供UI收集,以及用于一次性事件的Channel/Flow。changeName和changeCity用于修改状态,firstStepToSecondStep和finish负责验证逻辑并通过Channel发送事件。
可组合函数代码
FirstStep
@OptIn(ExperimentalMaterial3Api::class) @Composable fun FirstStep( viewModel: OneViewModel, nextStep: () -> Unit ) { val state by viewModel.screenState.collectAsState() LaunchedEffect(key1 = Unit) { Log.d("FirstStep (Composable)", "start of launched effect block") viewModel.channel.collect { channel -> when (channel) { OneChannel.FirstStepToSecondStep -> { Log.d("FirstStep (Composable)", "first step to second step action") nextStep() } else -> Log.d( "FirstStep (Composable)", "another action that should be ignored in this scope" ) } } } Column(modifier = Modifier.fillMaxSize()) { TextField( modifier = Modifier .fillMaxWidth() .padding(all = 16.dp), value = state.name, onValueChange = { viewModel.changeName(value = it) }, placeholder = { Text(text = "Type our name") } ) Spacer(modifier = Modifier.weight(weight = 1F)) Button( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), onClick = { viewModel.firstStepToSecondStep() } ) { Text(text = "Next Step") } } }
SecondStep
@OptIn(ExperimentalMaterial3Api::class) @Composable fun SecondStep( viewModel: OneViewModel, prevStep: () -> Unit, finish: () -> Unit ) { val state by viewModel.screenState.collectAsState() LaunchedEffect(key1 = Unit) { Log.d("SecondStep (Composable)", "start of launched effect block") viewModel.channel.collect { channel -> when (channel) { OneChannel.Finish -> { Log.d("SecondStep (Composable)", "finish action //todo") finish() } else -> Log.d( "SecondStep (Composable)", "another action that should be ignored in this scope" ) } } } Column(modifier = Modifier.fillMaxSize()) { TextField( modifier = Modifier .fillMaxWidth() .padding(all = 16.dp), value = state.city, onValueChange = { viewModel.changeCity(value = it) }, placeholder = { Text(text = "Type our city name") } ) Spacer(modifier = Modifier.weight(weight = 1F)) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(space = 16.dp) ) { Button( modifier = Modifier.weight(weight = 1F), onClick = prevStep ) { Text(text = "Previous Step") } Button( modifier = Modifier.weight(weight = 1F), onClick = { viewModel.finish() } ) { Text(text = "Finish") } } } }
OneScreen
@OptIn(ExperimentalPagerApi::class) @Composable fun OneScreen(viewModel: OneViewModel = hiltViewModel()) { val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState(initialPage = 0) val pages = listOf<@Composable () -> Unit>( { FirstStep( viewModel = viewModel, nextStep = { coroutineScope.launch { pagerState.animateScrollToPage(page = pagerState.currentPage + 1) } } ) }, { SecondStep( viewModel = viewModel, prevStep = { coroutineScope.launch { pagerState.animateScrollToPage(page = pagerState.currentPage - 1) } }, finish = {} ) } ) Column(modifier = Modifier.fillMaxSize()) { HorizontalPager( modifier = Modifier .fillMaxWidth() .weight(weight = 1F), state = pagerState, count = pages.size, userScrollEnabled = false ) { index -> pages[index]() } HorizontalPagerIndicator( modifier = Modifier .padding(vertical = 16.dp) .align(alignment = Alignment.CenterHorizontally), pagerState = pagerState, activeColor = MaterialTheme.colorScheme.primary ) } }
OneScreen使用Accompanist的HorizontalPager包含FirstStep和SecondStep两个页面,两个页面均通过LaunchedEffect收集ViewModel的Channel事件。
依赖配置
implementation 'androidx.navigation:navigation-compose:2.5.2' implementation 'com.google.dagger:hilt-android:2.43.2' kapt 'com.google.dagger:hilt-android-compiler:2.43.2' implementation 'androidx.hilt:hilt-navigation-compose:1.0.0' implementation 'com.google.accompanist:accompanist-pager:0.25.1' implementation 'com.google.accompanist:accompanist-pager-indicators:0.25.1'
问题描述
首次输入姓名点击下一步流程正常,返回第一步后再次点击下一步时,FirstStep的LaunchedEffect中collect未触发,反而SecondStep的collect收到事件但无法处理,导致页面无响应;再次点击下一步时,FirstStep的collect才正常工作。
问题原因
- Channel特性:默认的
Channel是RENDEZVOUS类型,每个事件只会被一个收集者接收,当存在多个活跃的收集者时,事件会被随机分配。 - HorizontalPager预加载:Accompanist的
HorizontalPager默认会预加载相邻页面(offscreenLimit=1),因此当处于第一步时,第二步的SecondStep组件并未销毁,其LaunchedEffect仍在运行并收集同一个Channel事件。 - 当从第二步返回第一步后,两个页面的收集者同时活跃,发送的
FirstStepToSecondStep事件被SecondStep的收集者接收,但该组件只处理Finish事件,因此事件被忽略,导致第一步无响应;第二次发送事件时,事件被FirstStep的收集者接收,流程恢复正常。
解决方案
方案1:拆分独立Channel(推荐)
给每个页面创建独立的Channel,确保事件只被目标页面接收:
修改ViewModel:
@HiltViewModel class OneViewModel @Inject constructor() : ViewModel() { // ... 原有状态代码不变 // 第一步专属Channel private val _firstStepChannel = Channel<OneChannel.FirstStepToSecondStep>() val firstStepChannel = _firstStepChannel.receiveAsFlow() // 第二步专属Channel private val _secondStepChannel = Channel<OneChannel.Finish>() val secondStepChannel = _secondStepChannel.receiveAsFlow() fun firstStepToSecondStep() { // ... 原有验证逻辑不变 viewModelScope.launch { _firstStepChannel.send(OneChannel.FirstStepToSecondStep) } } fun finish() { // ... 原有验证逻辑不变 viewModelScope.launch { _secondStepChannel.send(OneChannel.Finish) } } }
修改FirstStep的LaunchedEffect:
LaunchedEffect(key1 = Unit) { viewModel.firstStepChannel.collect { Log.d("FirstStep (Composable)", "first step to second step action") nextStep() } }
修改SecondStep的LaunchedEffect:
LaunchedEffect(key1 = Unit) { viewModel.secondStepChannel.collect { Log.d("SecondStep (Composable)", "finish action //todo") finish() } }
方案2:禁用Pager预加载
设置HorizontalPager的offscreenLimit=0,这样切换页面时非当前页面的组件会被销毁,其收集者也会取消:
HorizontalPager( modifier = Modifier .fillMaxWidth() .weight(weight = 1F), state = pagerState, count = pages.size, userScrollEnabled = false, offscreenLimit = 0 // 添加此行 ) { index -> pages[index]() }
缺点:页面切换时需要重新创建组件,可能影响流畅度。
方案3:改用SharedFlow
将Channel替换为SharedFlow,设置replay=0确保事件只被活跃的收集者接收,且每个收集者都能收到事件:
修改ViewModel:
@HiltViewModel class OneViewModel @Inject constructor() : ViewModel() { // ... 原有状态代码不变 private val _eventFlow = MutableSharedFlow<OneChannel>(replay = 0, extraBufferCapacity = 0) val eventFlow = _eventFlow.asSharedFlow() fun firstStepToSecondStep() { // ... 原有验证逻辑不变 viewModelScope.launch { _eventFlow.emit(OneChannel.FirstStepToSecondStep) } } fun finish() { // ... 原有验证逻辑不变 viewModelScope.launch { _eventFlow.emit(OneChannel.Finish) } } }
修改页面的收集逻辑,保持原有when分支即可,这样每个活跃的收集者都会收到事件,但不需要处理的事件会被忽略,不影响流程。
内容的提问来源于stack exchange,提问作者Maki

