Jetpack Compose中基于ViewModel状态统一实现页面导航的方法
我有一个包含login、otp confirmation页面的认证模块,使用AuthViewModel处理UI事件,核心代码如下:
@HiltViewModel class AuthViewModel @Inject constructor(private val repo: AuthRepository) : ViewModel() { private val _authState = MutableStateFlow(AuthState.UNAUTHENTICATED) val authState: StateFlow<AuthState> = _authState.asStateFlow() fun login(credential: Login) { viewModelScope.launch { // 调用仓库方法 _authState.value = AuthState.OTP } } fun confirm(otp: String) { viewModelScope.launch { // 调用仓库方法 _authState.value = AuthState.AUTHENTICATED } } }
当前在登录和OTP验证成功后,通过更新authState触发页面导航,但每个页面单独监听authState的方式存在问题:用户从OtpScreen返回LoginScreen时,由于ViewModel的authState仍为OTP状态,LoginScreen中的LaunchedEffect会再次触发,导致自动跳回OtpScreen。希望找到类似View体系中Activity统一监听ViewModel状态处理导航的方案,避免每个页面单独处理的弊端。
方案1:在认证导航的父级统一监听状态导航
可以在整个认证导航的父级Composable中监听authState,统一处理导航逻辑,单个页面只需负责UI交互,无需处理导航逻辑。
修改导航图代码:
const val authRoute = "auth" fun NavGraphBuilder.authNavigation(navController: NavController) { navigation(startDestination = loginRoute, route = authRoute) { // 父级Composable包裹整个认证流程,统一处理状态导航 composable(authRoute) { navBackStackEntry -> val vm: AuthViewModel = hiltViewModel(navBackStackEntry) val authState = vm.authState.collectAsStateWithLifecycle().value // 统一处理导航动作 LaunchedEffect(authState) { when(authState) { AuthState.OTP -> navController.navigate(otpRoute) AuthState.AUTHENTICATED -> navController.navigate(homeRoute) { popUpTo(authRoute) { inclusive = true } } else -> {} } } // 根据当前路由显示对应页面 val currentRoute = navController.currentBackStackEntry?.destination?.route when(currentRoute) { loginRoute -> LoginScreen(vm) otpRoute -> OtpScreen(vm) } } // 保留路由定义,无需再在内部处理ViewModel和导航 composable(loginRoute) {} composable(otpRoute) {} } }
简化后的页面Composable:
@Composable fun LoginScreen(vm: AuthViewModel) { val state = vm.loginUiState.collectAsStateWithLifecycle().value LoginUi(state, onLogin = vm::login) } @Composable fun OtpScreen(vm: AuthViewModel) { val state = vm.otpUiState.collectAsStateWithLifecycle().value OtpUi(state, onConfirm = vm::confirm) }
方案2:使用一次性事件(One-shot Event)替代持久状态
导航是一次性动作,不适合用持久化的StateFlow触发,改用SharedFlow实现一次性事件机制,确保事件只被消费一次。
修改ViewModel添加事件流:
@HiltViewModel class AuthViewModel @Inject constructor(private val repo: AuthRepository) : ViewModel() { private val _authEvents = MutableSharedFlow<AuthEvent>() val authEvents: SharedFlow<AuthEvent> = _authEvents.asSharedFlow() fun login(credential: Login) { viewModelScope.launch { // 调用仓库方法 _authEvents.emit(AuthEvent.NavigateToOtp) } } fun confirm(otp: String) { viewModelScope.launch { // 调用仓库方法 _authEvents.emit(AuthEvent.NavigateToHome) } } sealed class AuthEvent { object NavigateToOtp : AuthEvent() object NavigateToHome : AuthEvent() } }
在导航图的对应页面监听事件:
const val authRoute = "auth" fun NavGraphBuilder.authNavigation(navController: NavController) { navigation(startDestination = loginRoute, route = authRoute) { composable(loginRoute) { navBackStackEntry -> val vm: AuthViewModel = hiltViewModel(navBackStackEntry) LoginScreen(vm) // 监听一次性导航事件 LaunchedEffect(Unit) { vm.authEvents.collect { event -> when(event) { AuthViewModel.AuthEvent.NavigateToOtp -> navController.navigate(otpRoute) else -> {} } } } } composable(otpRoute) { navBackStackEntry -> val vm: AuthViewModel = hiltViewModel(navBackStackEntry) OtpScreen(vm) LaunchedEffect(Unit) { vm.authEvents.collect { event -> when(event) { AuthViewModel.AuthEvent.NavigateToHome -> navController.navigate(homeRoute) { popUpTo(authRoute) { inclusive = true } } else -> {} } } } } } }
这种方式下,事件只会被消费一次,返回页面时不会重复触发导航,因为事件已处理完毕,不会在状态中残留。
方案3:返回时重置ViewModel状态
在用户从OtpScreen返回LoginScreen时,主动重置authState为初始状态,避免LoginScreen的LaunchedEffect重复触发。
修改ViewModel添加重置方法:
@HiltViewModel class AuthViewModel @Inject constructor(private val repo: AuthRepository) : ViewModel() { private val _authState = MutableStateFlow(AuthState.UNAUTHENTICATED) val authState: StateFlow<AuthState> = _authState.asStateFlow() // ... 原有login、confirm方法 fun resetAuthState() { _authState.value = AuthState.UNAUTHENTICATED } }
在OtpScreen中处理返回事件:
@Composable fun OtpScreen(vm: AuthViewModel, onAuthenticated: () -> Unit) { val authState = vm.authState.collectAsStateWithLifecycle().value val currentOnAuthenticated = rememberUpdatedState(onAuthenticated) LaunchedEffect(authState) { if (authState == AuthState.AUTHENTICATED) { currentOnAuthenticated.value() } } // 拦截返回键,重置状态后再执行返回 val backHandler = LocalBackPressedDispatcher.current DisposableEffect(backHandler) { val callback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { vm.resetAuthState() backHandler.onBackPressed() } } backHandler.addCallback(callback) onDispose { callback.remove() } } val state = vm.otpUiState.collectAsStateWithLifecycle().value OtpUi(state, onConfirm = vm::confirm) }
用户返回时,authState被重置为UNAUTHENTICATED,LoginScreen的LaunchedEffect就不会再触发导航。
内容的提问来源于stack exchange,提问作者Samuel Robert

