You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose中HorizontalPager内LaunchedEffect事件收集异常

Compose项目中Channel事件分发异常问题

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才正常工作。

问题原因

  1. Channel特性:默认的Channel是RENDEZVOUS类型,每个事件只会被一个收集者接收,当存在多个活跃的收集者时,事件会被随机分配。
  2. HorizontalPager预加载:Accompanist的HorizontalPager默认会预加载相邻页面(offscreenLimit=1),因此当处于第一步时,第二步的SecondStep组件并未销毁,其LaunchedEffect仍在运行并收集同一个Channel事件。
  3. 当从第二步返回第一步后,两个页面的收集者同时活跃,发送的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.18 22:10:28