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

Jetpack Compose按钮点击异常:仅奇数次触发有效求排查

按钮点击仅奇数次生效问题排查与解决

问题描述

开发Jetpack Compose项目时,点击「Pray to God」按钮仅奇数次能触发逻辑(第一次、第三次有效,第二次、第四次无效)。项目基于封装的BaseViewModel和BaseComposableScreen实现,相关代码如下:

基础封装代码

@Composable
fun <State, Event> BaseComposableScreen(
    navController: NavController,
    viewModel: BaseViewModel<State, Event>,
    content: @Composable (coroutineScope: CoroutineScope) -> Unit,
) {
    val coroutineScope = rememberCoroutineScope()

    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is BasicEffect.NavigateToEffect -> {
                    coroutineScope.launch {
                        navController.navigate(effect.route)
                    }
                }
                is BasicEffect.NavigateBackToEffect -> {
                    coroutineScope.launch {
                        navController.popBackStack(effect.destination, effect.inclusive)
                    }
                }
                is BasicEffect.NavigateBackEffect -> {
                    coroutineScope.launch {
                        navController.popBackStack()
                    }
                }
            }
        }
    }

    content(coroutineScope)
}
abstract class BaseViewModel<State, Event> : ViewModel() {

    private val mutex = Mutex()
    private val exceptionHandler = CoroutineExceptionHandler(::onError)

    abstract fun provideInitialState(): State

    private val _state = MutableStateFlow(provideInitialState())
    val state: StateFlow<State> = _state.asStateFlow()

    private val _effect = Channel<BaseEffect>(Channel.BUFFERED)
    val effect: Flow<BaseEffect> = _effect.receiveAsFlow()

    open fun onEvent(event: Event) {}

    open fun onError(context: CoroutineContext, throwable: Throwable) {}

    protected fun emitState(state: State) {
        launchOnMain {
            mutex.withLock {
                _state.emit(state)
            }
        }
    }

    protected fun emitEffect(effect: BaseEffect) {
        launchOnMain {
            _effect.send(effect)
        }
    }

    protected fun <P, R, U : BaseUseCase<P, R>> executeUseCase(
        useCase: U,
        param: P,
        onComplete: ((R) -> Unit)? = null,
    ) {
        launchOnMain {
            val result = useCase.start(param)
            onComplete?.invoke(result)
        }
    }

    protected fun launchOnMain(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(exceptionHandler, block = block)
    }

    protected fun launchOnIO(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(exceptionHandler + Dispatchers.IO, block = block)
    }

    protected fun launchOnDefault(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(exceptionHandler + Dispatchers.Default, block = block)
    }

    protected fun <T> Flow<T>.launchFlow(scope: CoroutineScope = viewModelScope): Job =
        this.catch {
            exceptionHandler.handleException(currentCoroutineContext(), it)
        }.launchIn(scope)
}
abstract class BaseEffect

sealed class BasicEffect: BaseEffect() {
    data class NavigateToEffect(val route: String) : BaseEffect()
    data class NavigateBackToEffect(
        val destination: String,
        val inclusive: Boolean = false,
    ) : BaseEffect()
    object NavigateBackEffect : BaseEffect()
}

业务实现代码

class ChurchViewModel : BaseViewModel<Unit, ChurchEvent>() {
    override fun provideInitialState() = Unit

    override fun onEvent(event: ChurchEvent) {
        when (event) {
            is ChurchEvent.PrayToGod -> {
                emitEffect(ChurchEffect.GodListen)
            }
        }
    }
}

sealed class ChurchEffect : BaseEffect() {
    object GodListen : ChurchEffect()
}

sealed class ChurchEvent {
    object PrayToGod : ChurchEvent()
}
@Composable
fun ChurchScreen(navController: NavController) {
    val viewModel = viewModel<ChurchViewModel>()
    val scaffoldState = rememberScaffoldState()

    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            when (effect) {
                ChurchEffect.GodListen -> {
                    scaffoldState.snackbarHostState.showSnackbar("God listens..")
                }
            }
        }
    }

    BaseComposableScreen(navController = navController, viewModel = viewModel) {
        ChurchScreenContent(
            scaffoldState = scaffoldState,
            onPrayToGod = {
                viewModel.onEvent(ChurchEvent.PrayToGod)
            }
        )
    }
}

@Composable
private fun ChurchScreenContent(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    onPrayToGod: () -> Unit = { },
) {
    Scaffold(scaffoldState = scaffoldState) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.SpaceEvenly,
        ) {
            Text(text = "Church")
            Icon(
                painter = painterResource(id = R.drawable.ic_baseline_location_city_24),
                contentDescription = "Church image",
                modifier = Modifier.size(90.dp),
            )
            Button(onClick = onPrayToGod) {
                Text(text = "Pray to God")
            }
        }
    }
}

问题原因

核心问题是同一个Channel的Flow被两个独立的LaunchedEffect重复收集:

  • BaseComposableScreen内部启动了协程收集viewModel.effect
  • ChurchScreen中又单独启动协程收集同一个viewModel.effect

由于Channel的receiveAsFlow()是独占式消费的Flow,每个发送到Channel的元素只会被其中一个收集器接收:

  1. 第1次点击发送的ChurchEffect.GodListen被ChurchScreen的收集器拿到,触发Snackbar显示
  2. 第2次点击发送的effect被BaseComposableScreen的收集器拿到,但该收集器仅处理BasicEffect类型,ChurchEffect不匹配直接被忽略
  3. 第3次点击的effect又被ChurchScreen的收集器拿到,以此循环,导致仅奇数次点击有效

解决方案

方案1:将Channel替换为SharedFlow(推荐)

修改BaseViewModel的effect实现为SharedFlow,让多个收集器都能收到消息:

abstract class BaseViewModel<State, Event> : ViewModel() {
    // 替换原Channel实现
    private val _effect = MutableSharedFlow<BaseEffect>(replay = 0, extraBufferCapacity = Channel.BUFFERED)
    val effect: SharedFlow<BaseEffect> = _effect.asSharedFlow()

    // 修改emitEffect方法
    protected fun emitEffect(effect: BaseEffect) {
        launchOnMain {
            _effect.emit(effect)
        }
    }

    // 其他代码保持不变
}

方案2:统一在BaseComposableScreen中处理所有Effect

修改BaseComposableScreen增加自定义Effect处理参数,移除ChurchScreen中的单独收集逻辑:

@Composable
fun <State, Event> BaseComposableScreen(
    navController: NavController,
    viewModel: BaseViewModel<State, Event>,
    onCustomEffect: suspend (BaseEffect) -> Unit = {}, // 新增自定义Effect处理
    content: @Composable (coroutineScope: CoroutineScope) -> Unit,
) {
    val coroutineScope = rememberCoroutineScope()

    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            val handled = when (effect) {
                is BasicEffect.NavigateToEffect -> {
                    coroutineScope.launch { navController.navigate(effect.route) }
                    true
                }
                is BasicEffect.NavigateBackToEffect -> {
                    coroutineScope.launch { navController.popBackStack(effect.destination, effect.inclusive) }
                    true
                }
                is BasicEffect.NavigateBackEffect -> {
                    coroutineScope.launch { navController.popBackStack() }
                    true
                }
                else -> false // 未处理的Effect交给自定义逻辑
            }
            if (!handled) {
                onCustomEffect(effect)
            }
        }
    }

    content(coroutineScope)
}

修改ChurchScreen:

@Composable
fun ChurchScreen(navController: NavController) {
    val viewModel = viewModel<ChurchViewModel>()
    val scaffoldState = rememberScaffoldState()

    BaseComposableScreen(
        navController = navController,
        viewModel = viewModel,
        onCustomEffect = { effect ->
            when (effect) {
                ChurchEffect.GodListen -> {
                    scaffoldState.snackbarHostState.showSnackbar("God listens..")
                }
            }
        }
    ) {
        ChurchScreenContent(
            scaffoldState = scaffoldState,
            onPrayToGod = {
                viewModel.onEvent(ChurchEvent.PrayToGod)
            }
        )
    }
}

内容的提问来源于stack exchange,提问作者Ayxan Vəliyev

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.11 13:20:51