0

I am just starting with Compose. For the first look, for me, it all appears like a copy of SwiftUI, which I love. But when I started to really use it, I quickly ran into many issues. Apparently, I need to find the proper way how to use it to benefit from it...

Here is one of my issues.

package org.test.android.kotlin.compose.ui

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import org.test.android.kotlin.compose.ui.theme.MbiKtTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MbiKtTheme {
                val navController = rememberNavController()
                // <Edit #1>
                // Navigator.route.collectAsState("").value.takeIf { it.isNotEmpty() }?.also { navController.navigate(it) }
                // Navigator.route.observe(this, { route -> navController.navigate(route) })
                // </Edit #1>
                // <Edit #2>
                Navigator.route.collectAsState("").value.takeIf { it.isNotEmpty() }?.also { 
                    navController.popBackStack()
                    navController.navigate(it)
                }
                // </Edit #2>
                Surface(color = MaterialTheme.colors.background) {
                    NavHost(
                        navController = navController,
                        startDestination = "setup"
                    ) {
                        composable(route = "setup") {
                            SetupScreen()
                        }
                        composable(route = "progress") {
                            ProgressScreen()
                        }
                    }
                }
            }
        }
    }
}

// This is unnecessary here in this simple code fragment, but a MUST for large modular projects
object Navigator {
    // <Edit #1>
    val route = MutableSharedFlow<String>(0, 1, BufferOverflow.DROP_OLDEST)
    //val route: MutableLiveData<String> = MutableLiveData()
    // </Edit #1>
}

class SetupViewModel : ViewModel() {
    init {
        Log.d(toString(), "Create")
    }

    override fun onCleared() {
        Log.d(toString(), "Destroy")
    }

    override fun toString(): String {
        return "SetupViewModel"
    }
}

@Composable
fun SetupScreen(model: SetupViewModel = viewModel()) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(all = Dp(8f))
    ) {
        Text(text = "Setup")
        Spacer(modifier = Modifier.weight(1f))
        Button(onClick = { Navigator.route.tryEmit("progress") }, modifier = Modifier.fillMaxWidth()) { Text(text = "Register") }
    }
}

class ProgressViewModel : ViewModel() {
    init {
        Log.d(toString(), "Created")
    }

    override fun onCleared() {
        Log.d(toString(), "Cleared")
    }

    override fun toString(): String {
        return "ProgressViewModel"
    }
}

@Composable
fun ProgressScreen(model: ProgressViewModel = viewModel()) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(all = Dp(8f))
    ) {
        Text(text = "Progress")
        Spacer(modifier = Modifier.weight(1f))
        Button(onClick = { Navigator.route.tryEmit("setup") }, modifier = Modifier.fillMaxWidth()) { Text(text = "Abort") }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MbiKtTheme {
        SetupScreen()
    }
}

My reality is, of course, much more complex, I did my best to simplify it all as much as possible, but this already demonstrates the problem I have:

  • Navigate between the two screens (composables) and rotate the screen
  • And observe the Created/Destroyed messages from both view models in the LogCat
  • In the first place: Destroyed is never called when navigating from one screen to another (clearly because the activity stays alive), which is totally not acceptable in large projects
  • Subsequently, as soon as you navigate at least once to the other screen (just tap the button), the view models start to be recreated with every screen rotation, which is also totally not acceptable

I know that compose is not mature yet (I have seen some components still in "alpha" release). So it may be a bug in compose itself.

Or it may be just my missunderstanding of how to use Compose in large scale and modular projects...

Any ideas?

(Just for completeness, I double checked I am using the latest currently available versions of everything.)

Edit #1 (2021/09/05)

Thanks to the article which deals with one of my issues (link in comment below), I fixed one of the problems: The view models are NOT being recreated when rorating screen any more (still no clue, why).

So the remaining issue is that the view models do not follow the expected lifecycle.

Edit #2 (2021/09/13)

Thanks to the answer below (unfortunately I did not find any way how to make it accepted answer - SF UI is still a bit unclear to me), I was able to really make the view models lifecycle to work as expected.

I just disabled the back-stack, which is anyway unwanted (creates a lot of mess between the UI and the underlying model) feature in my kind of app...

  • In the mean time, I found this interesting article which seems to be dealing with one of my architectural problems in Compose (apparently, and luckily, I am not the only one): https://medium.com/google-developer-experts/modular-navigation-with-jetpack-compose-fda9f6b2bef7 – Jiří Křivánek Sep 05 '21 at 15:20

1 Answers1

1

The complete composition tree is rebuilt each time the screen is rotated, starting with setContent.

In your source code, you subscribed to Navigator.route.observe at every recomposition. And the "fix" is to convert LiveData or Float to composite state. You did this with Flow + collectAsState, with LiveData a similar method is called observeAsState. Learn more about state in compose.

So, every time you rotated the device, navigate was called.

navigate does not change the current screen with the new destination. Instead, it pushes the new view onto the stack. So every time you navigate - you push a new screen onto the navigation stack and create a model for it. And when you rotate the device without collectAsState, you push another screen onto the stack. Check out more about compose navigation in documentation.

You can change this behavior with NavOptionsBuilder, for example like this:

navController.navigate(route) {
    if (route == "setup") {
        popUpTo("setup")
    }
}

The view models will be released when the corresponding views leave the navigation stack. If you click the Back button on the navigation bar, you will see that it has been released.

p.s. I personally find Compose much more flexible and convenient compared to SwiftUI, even though the first stable version was released only a month ago. You just need to know it better.

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • Or I can just call navController.popBackStack() before navController.navigate(it), which seems to get me rid of the messy backstack Android feature.... THANK YOU! – Jiří Křivánek Sep 13 '21 at 09:47