icerockdev / moko-mvvm

Model-View-ViewModel architecture components for mobile (android & ios) Kotlin Multiplatform development
https://moko.icerock.dev/
Apache License 2.0
994 stars 95 forks source link

viewModelScope.launch() block not running in Compose Multiplatform #243

Closed cryptrr closed 1 year ago

cryptrr commented 1 year ago
class DemoViewModel : ViewModel(), KoinComponent {

    val main: MainViewModel by inject()

    private val _stateFlow = MutableStateFlow<Result<SimpleResponseDTO>>(value = Result.None)

    val stateFlow = _stateFlow.asSharedFlow()

    fun doSomething(email: String){

        Logger.d { "is getting logged" }

        viewModelScope.launch{

            **//Block inside launch() not running at all**

            _stateFlow.value = Result.Loading

            Logger.d { "is not getting logged" }
            _stateFlow.value = Result.None
        }
    }

}

Tried changing Dispatchers but none works.

Moko dependencies used in Common:

implementation("dev.icerock.moko:mvvm-compose:0.16.1")
implementation("dev.icerock.moko:mvvm-flow-compose:0.16.1")
Alex009 commented 1 year ago

here sample with compose multiplatform & moko-mvvm - https://github.com/icerockdev/moko-compose-multiplatform-template/tree/main try to reproduce issue in this repo.

maybe your viewmodel already call onCleared and viewModelScope already disposed

cryptrr commented 1 year ago

Thanks, let me look into it.

cryptrr commented 1 year ago

This is what I get when I log viewModelScope

CoroutineScope(coroutineContext=[JobImpl{Cancelled}@5f81b7, Dispatchers.Main])

Yeah viewModelScope.isActive is false

onCleared() is getting called instantly on navigation to the screen

Alex009 commented 1 year ago

set breakpoint to onCleared and check what code call it in stacktrace

cryptrr commented 1 year ago

I investigated it and got another very weird behaviour.

The viewModel is being cleared when using conditional rendering even when viewModel is initialized in the parent composable.

This is from another screen's viewModel that is for some reason not automatically getting cleared on page load

 @Composable
 fun LoginScreen(){
        val viewModel = getViewModel(key = Unit, factory = viewModelFactory{ LoginScreenViewModel() })

        val loginState by viewModel.stateFlow.collectAsState(initial = Result.None)

        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
             when (loginState) {
                        is Result.Loading -> {
                            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                                CircularProgressIndicator(color = Color.Blue)
                            }
                        }

                        is Result.None -> {
                            LoginContent()
                        }

                        else -> {   }
               }
        }
 }

@Composable
fun LoginContent(){
           //Is also using the same viewModel initialized in the parent.
            val viewModel = getViewModel(key = Unit, factory = viewModelFactory{ LoginScreenViewModel() })

             Button(onClick = {viewModel.login(viewModel.emailFieldText)}){
                   //Button Content
             }
}

The function viewModel.login() changes the loginState to Result.Loading and hence the parent composable's when shows the CircularProgressIndicator

Now the viewModel is getting cleared even though the viewModel is initialized in the parent composable ie LoginScreen()

If you are not using conditional rendering in LoginScreen() using when(), the viewModel is not cleared.

Alex009 commented 1 year ago

you should pass viewmodel as argument to inner composable. because getViewModel works on remember logic. when you use getViewModel in some different Composable but with same key - you can got unexpected results like this.

cryptrr commented 1 year ago

That fixed it but the original problem remains.

Here's the composable code for that screen

@Composable
    fun ForgotPasswordContent(){
        val viewModel = getViewModel(key = Unit, factory = viewModelFactory{ ResetPasswordViewModel() })

        val resetPasswordRequestState by viewModel.stateFlow.collectAsState(initial = Result.None)

        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            FullWidthButton(
                "Submit",
                UIBlue,
                isLoading = resetPasswordRequestState is Result.Loading,
                onClick = {
                    viewModel.sendResetPasswordRequest(viewModel.emailFieldText)
                }) {}
        }
    }

Here's the full ViewModel

class ResetPasswordViewModel : ViewModel(), KoinComponent {

    val main: MainViewModel by inject()

    var emailFieldText by mutableStateOf("")

    private val _stateFlow = MutableStateFlow<Result<SimpleResponseDTO>>(value = Result.None)

    val stateFlow = _stateFlow.asSharedFlow()

    override fun onCleared() {
        //OnCleared is called instantly on screen render
        Logger.d{"Clearing viewmodel"} 
        super.onCleared()
    }

    fun sendResetPasswordRequest(email: String){
        Logger.d { "is getting logged" }

        Logger.d { viewModelScope.isActive.toString() }
        Logger.d { viewModelScope.coroutineContext.toString() }

        viewModelScope.launch{
            //Not running at all
            _stateFlow.value = Result.Loading

            delay(2000L)
            Logger.d { "is not getting logged" }
            _stateFlow.value = Result.None
        }
    }
}

This is the full stack trace of onCleared() Screenshot from 2023-06-07 23-12-49

cryptrr commented 1 year ago

Okay it's fine. I thought key was used for recomposition of ViewModel ala LaunchedEffect and put the same key everywhere.