Jetpack Compose Navigation 无限加载屏幕

MeL*_*ine 4 android infinite-loop android-jetpack-compose jetpack-compose-navigation compose-recomposition

我正在尝试Navigation使用单个活动和多个Composable屏幕来实现。

这是我的NavHost

@Composable
@ExperimentalFoundationApi
fun MyNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
    startDestination: String = HOME.route,
    viewModelProvider: ViewModelProvider,
    speech: SpeechHelper
) = NavHost(
    modifier = modifier,
    navController = navController,
    startDestination = startDestination
) {
    composable(route = HOME.route) {
        with(viewModelProvider[HomeViewModel::class.java]) {
            HomeScreen(
                speech = speech,
                viewModel = this,
                modifier = Modifier.onKeyEvent { handleKeyEvent(it, this) }
            ) {
                navController.navigateTo(it)
            }
        }
    }

    composable(route = Destination.VOLUME_SETTINGS.route) {
        VolumeSettingsScreen(
            viewModelProvider[VolumeSettingsViewModel::class.java]
        ) { navController.navigateUp() }
    }
}

fun NavHostController.navigateTo(
    navigateRoute: String,
    willGoBackTo: String = HOME.route
): Unit = navigate(navigateRoute) {
    popUpTo(willGoBackTo) { inclusive = true }
}
Run Code Online (Sandbox Code Playgroud)

我的屏幕看起来像这样:

@Composable
fun HomeScreen(
    speech: SpeechHelper,
    viewModel: HomeViewModel,
    modifier: Modifier,
    onNavigationRequested: (String) -> Unit
) {

    MyBlindAssistantTheme {
        val requester = remember { FocusRequester() }
        val uiState by viewModel.uiState.collectAsStateWithLifecycle(
            initialValue = UiState.Speak(
                R.string.welcome_
                    .withStrResPlaceholder(R.string.text_home_screen)
                    .toSpeechUiModel()
            )
        )

        uiState?.let {
            when (it) {
                is UiState.Speak -> speech.speak(it.speechUiModel)
                is UiState.SpeakRes -> speech.speak(it.speechResUiModel.speechUiModel())
                is UiState.Navigate -> onNavigationRequested(it.route) 
            }
        }

        Column(
            modifier
                .focusRequester(requester)
                .focusable(true)
                .fillMaxSize()
        ) {
            val rowModifier = Modifier.weight(1f)

            Row(rowModifier) {...}
                   
        }

        LaunchedEffect(Unit) {
            requester.requestFocus()
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

这是视图模型:

class HomeViewModel : ViewModel() {
    private val mutableUiState: MutableStateFlow<UiState?> = MutableStateFlow(null)
    val uiState = mutableUiState.asStateFlow()

    
    fun onNavigateButtonClicked(){
        mutableUiState.tryEmit(Destination.VOLUME_SETTINGS.route.toNavigationState())
    }
}
Run Code Online (Sandbox Code Playgroud)

单击按钮时,ViewModel将调用并发出 NavigateUiState...但在加载下一个屏幕后,它会继续发出,这会导致无限的屏幕重新加载。应该怎样做才能避免这种情况?

z.g*_*g.y 7

我用两个屏幕重新实现了您发布的代码,HomeScreenSettingScreen删除了该类的某些部分UiState及其用法。

问题在于您的HomeScreen可组合项,而不是发射StateFlow

你有这个mutableState

val uiState by viewModel.uiState.collectAsStateWithLifecycle(
      initialValue = UiState.Speak
)
Run Code Online (Sandbox Code Playgroud)

那是在执行回调的块observed之一中。whennavigation

uiState?.let {
         when (it) {
             is UiState.Navigate ->  {
                  onNavigationRequested(it.route)
             }
             UiState.Speak -> {
                 Log.d("UiState", "Speaking....")
            }
}
Run Code Online (Sandbox Code Playgroud)

当你的ViewModel函数被调用时

 fun onNavigateButtonClicked(){
        mutableUiState.tryEmit(UiState.Navigate(Destination.SETTINGS_SCREEN.route))
 }
Run Code Online (Sandbox Code Playgroud)

它将更新uiState,将其值设置为Navigate,观察到HomeScreen,满足when块,然后触发回调以导航到下一个屏幕。

现在根据官方文档

您应该仅将 navigator() 作为回调的一部分调用,而不是作为可组合项本身的一部分,以避免在每次重组时调用 navigator()。

但就你而言, 是navigation由观察到的触发的mutableState,并且mutableState是你的一部分HomeScreen

似乎当navController执行导航并且NavHost成为Composable

@Composable
public fun NavHost(
    navController: NavHostController,
    startDestination: String,
    modifier: Modifier = Modifier,
    route: String? = null,
    builder: NavGraphBuilder.() -> Unit
) { ... }
Run Code Online (Sandbox Code Playgroud)

它将执行 a re-composition,因此,它将再次调用HomeScreen(HomeScreen 不是re-composed,其状态保持不变),并且由于该HomeScreen's UiState值仍设置为Navigate,它满足when块,再次触发回调进行导航,并且NavHost re-composes无限循环然后创建

我所做的(而且非常丑陋boolean)是在 viewModel 中创建了一个标志,用它有条件地包装回调,

uiState?.let {
            when (it) {
                is UiState.Navigate  ->  {
                    if (!viewModel.navigated) {
                        onNavigationRequested(it.route)
                        viewModel.navigated = true
                    } else {
                        // dirty empty else 
                    }
                }
                UiState.Speak -> {
                    Log.d("UiState", "Speaking....")
                }
            }
        }
Run Code Online (Sandbox Code Playgroud)

并将其设置为true之后,防止循环。

我很难猜测你的 compose 实现结构,但我通常不会混合一次性事件操作和 UiState,而是有一个单独的UiEvent密封类,它将对“一次性”事件进行分组,如下所示:

  • 小吃店
  • 吐司
  • 导航

并将它们作为SharedFlow排放物排放,因为这些事件不需要任何初始状态或初始值。

继续,我创建了这个类

sealed class UiEvent {
    data class Navigate(val route: String) : UiEvent()
}
Run Code Online (Sandbox Code Playgroud)

将其用作类型ViewModelNavigate在本例中),

 private val _event : MutableSharedFlow<UiEvent> = MutableSharedFlow()
 val event = _event.asSharedFlow()

 fun onNavigateButtonClicked(){
        viewModelScope.launch {
            _event.emit(UiEvent.Navigate(Destination.SETTINGS_SCREEN.route))
        }
    }
Run Code Online (Sandbox Code Playgroud)

HomeScreen并通过这种方式观察它LaunchedEffect,触发其中的导航,而无需将回调绑定到任何观察到的状态。

LaunchedEffect(Unit) {
        viewModel.event.collectLatest {
            when (it) {
                is UiEvent.Navigate -> {
                    onNavigationRequested(it.route)
                }
            }
        }
    }
Run Code Online (Sandbox Code Playgroud)

这种方法不会引入无限的导航循环,并且不再需要脏布尔检查。

另请看看这篇帖子,与您的情况类似