Search code examples
androidandroid-jetpack-composeandroid-viewmodel

Composable function takes a value from viewmodel infinitely


I am a beginner in Android development, and I don't know how to solve this. There's a simple viewmodel which contains 1 variable:

var testQuestionsAmount by mutableStateOf(10)
        private set

I use this variable in the main screen, and everything works fine, but I am also trying to use it in another screen as a navigation argument to get a certain number of questions from DB, and for some reason it updates infinitely, leading to infinite question generation.

//For some reason, this updates infinitely
@Composable
fun GeneratedTestScreen(questionsAmount: Int) {
    val context = LocalContext.current
    val questionRepository by lazy {
        QuestionRepository(QuestionDatabase.getInstance(context).dao)
    }
    val questionsList = questionRepository.getRandomQuestions(questionsAmount).collectAsState(initial = emptyList()).value
    val currentTestViewModel = viewModel<GeneratedTestScreenViewModel>(
        factory = object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                return GeneratedTestScreenViewModel(questionsList = questionsList) as T
            }
        }
    )
    Log.d("AMOUNT", questionsAmount.toString())
}

GeneratedTestScreen in NavGraph:

composable(route = Screen.GeneratedTest.route, arguments = listOf(
    navArgument("questionsAmount") {
        type = NavType.IntType 
    }
)) {
    val questionsAmount = it.arguments?.getInt("questionsAmount")
        GeneratedTestScreen(questionsAmount ?: 1)
}

in sealed class:

object GeneratedTest : Screen("GeneratedTest/{questionsAmount}", Icons.Rounded.List, "Test") {
        fun putQuestionsAmount(amount: Int): String {
            return "GeneratedTest/$amount"
        }
    }

Navigation from main screen to GeneratedTestScreen:

@Composable
fun TestGenerationScreen(navController: NavController) {
    val viewModel = viewModel<TestGenerationScreenViewModel>()

    ...

        Column(
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .weight(1f)
                .fillMaxWidth()
        ) {
            Button(
                shape = RoundedCornerShape(dimensionResource(id = R.dimen.padding_small)),
                colors = ButtonDefaults.buttonColors(colorResource(id = R.color.approval_green)),
                // Navigation from main to GeneratedTestScreen
                onClick = { navController.navigate(Screen.GeneratedTest.putQuestionsAmount(viewModel.testQuestionsAmount)) },
                modifier = Modifier.align(Alignment.CenterHorizontally)
            ) {
                Text(text = stringResource(id = R.string.test_gen_screen_start_test_button_text))
            }
        }
    }
}

I've tried to google it, and the only thing I found was that viewModel stays in memory for some time after screen is removed from backstack, but in my case both main screen and GeneratedTestScreen remain in backstack. This is totally irrelevant to this problem. I also checked my room query twice, and it's correct.

So, how can I fix it? And what's the reason for this kind of behavior?

P.S. Sorry for my bad English


Solution

  • You can't do questionRepository calls as part of composition, so what this line is doing:

    val questionsList = questionRepository.getRandomQuestions(questionsAmount).collectAsState(initial = emptyList()).value

    Is actually doing kicking off the collectAsState, which then waits for the query to return so your first composition causes you to use the default value of emptyList(). Then, the query actually completes and returns the actual result - this causes a recomposition. However, because you haven't used remember to save your state, that whole getRandomQuestions call starts from scratch. Thus, instead of actually delivering your results, you get a brand new call back and you're back to the initial state...waiting for your new query to deliver results, leading to an infinite loop.

    Instead, you should be doing all of this in your ViewModel. You really have two things that your ViewModel needs - a Context to retrieve your QuestionDatabase and your questionsAmount argument you've passed through the NavController.

    For both of those, you actually don't need any custom ViewModelProvider.Factory at all as both of those are instantly available via the default parameters you can pass to your ViewModel. The first via extending AndroidViewModel and the second by passing in a SavedStateHandle, which is automatically filled with all of your arguments without you needing to pass anything manually or call it.arguments?.getInt("questionsAmount") as part of your composition.

    Thus, your ViewModel should look like:

    class GeneratedTestScreenViewModel(
      application: Application,
      savedStateHandle: SavedStateHandle
    ) : AndroidViewModel(application) {
    
      // First get the database from the Application Context
      private val questionDatabase = QuestionDatabase.getInstance(application)
    
      // And the questionsAmount variable from the SavedStateHandle
      private val questionsAmount: Int = savedStateHandle["questionsAmount"]
    
      // Now create your repository
      private val questionRepository = QuestionRepository(questionDatabase.dao)
    
      // And expose your questionsList from the ViewModel
      // making sure it uses stateIn() to only collect from the database
      // while the screen is visible
      val questionsList = questionRepository
        .getRandomQuestions(questionsAmount)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
    }
    

    With this setup, your screen becomes:

    // Note that you no longer need to pass in the questionsAmount
    @Composable
    fun GeneratedTestScreen() {
        val currentTestViewModel = viewModel<GeneratedTestScreenViewModel>()
    
        // We use collectAsStateWithLifecycle() from lifecycle-runtime-compose
        // to pause collection when the screen isn't visible
        val questionsList = viewModel.questionsList.collectAsStateWithLifecycle()
    
        // Now you can use your list for your Compose UI
    }