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.effectChurchScreen中又单独启动协程收集同一个viewModel.effect
由于Channel的receiveAsFlow()是独占式消费的Flow,每个发送到Channel的元素只会被其中一个收集器接收:
- 第1次点击发送的
ChurchEffect.GodListen被ChurchScreen的收集器拿到,触发Snackbar显示 - 第2次点击发送的effect被
BaseComposableScreen的收集器拿到,但该收集器仅处理BasicEffect类型,ChurchEffect不匹配直接被忽略 - 第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
相关产品推荐
相关产品推荐

