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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.11 12:24:56