Dagger: Dagger/Hilt ViewModel Injection (with compose and navigation-compose)

Created on 1 Nov 2020  路  6Comments  路  Source: google/dagger

Hello,

I am currently trying to build an app with only Compose (meaning no Fragments and navigation-compose, along with architecture components such as Hilt and ViewModel).

I tried using the viewModel function with the defaultViewModelProviderFactory of the Activity.

    java.lang.IllegalArgumentException: SavedStateProvider with the given key is already registered
        at androidx.savedstate.SavedStateRegistry.registerSavedStateProvider(SavedStateRegistry.java:111)
        at androidx.lifecycle.SavedStateHandleController.attachToLifecycle(SavedStateHandleController.java:50)
        at androidx.lifecycle.SavedStateHandleController.create(SavedStateHandleController.java:70)
        at androidx.lifecycle.AbstractSavedStateViewModelFactory.create(AbstractSavedStateViewModelFactory.java:67)
        at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.java:185)
        at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.java:150)
        at androidx.compose.ui.viewinterop.ViewModelKt.get(ViewModel.kt:75)
        at androidx.compose.ui.viewinterop.ViewModelKt.viewModel(ViewModel.kt:60)

I had to move this code inside a NavHost Composable. I reported this on the KotlinLang Slack and was told this issue relates to #2152 . It uses the incorrect Scope for a Navigation Composable.

In the case of the related issue, the scope is too small and in my case, it is the exact opposite Problem.

In that issue I linked, the fragment is within the Navigation graph, so the issue is that the saved state is too small of a scope (the navigation graph encompasses multiple fragments).
In your Compose case, the entire navigation graph in within the single Activity/Fragment, so there the scope is too large and you end up saving state multiple times with the same key.

Although the issue should be fixed by a more correct approach to scoping, it is still worth to file a bug for the inverse problem with saved state.

P2 hilt bug

Most helpful comment

Here is a complete workaround using reflection

@Composable
inline fun <reified VM : ViewModel> navViewModel(
    key: String? = null,
    factory: ViewModelProvider.Factory? = AmbientViewModelProviderFactory.current,
): VM {
    val navController = AmbientNavController.current
    val backStackEntry = navController.currentBackStackEntryAsState().value
    return if (backStackEntry != null) {
        // Hack for navigation viewModel
        val application = AmbientApplication.current
        val viewModelFactories = AmbientViewModelFactoriesMap.current
        val delegate = SavedStateViewModelFactory(application, backStackEntry, null)
        val hiltViewModelFactory = HiltViewModelFactory::class.java.declaredConstructors.first()
            .newInstance(backStackEntry, null, delegate, viewModelFactories) as HiltViewModelFactory
        viewModel(key, hiltViewModelFactory)
    } else {
        viewModel(key, factory)
    }
}

@Composable
fun ProvideNavigationViewModelFactoryMap(factory: HiltViewModelFactory, content: @Composable () -> Unit) {
    // Hack for navigation viewModel
    val factories =
        HiltViewModelFactory::class.java.getDeclaredField("mViewModelFactories").also { it.isAccessible = true }
            .get(factory).let {
                it as Map<String, ViewModelAssistedFactory<out ViewModel>>
            }
    Providers(
        AmbientViewModelFactoriesMap provides factories
    ) {
        content.invoke()
    }
}

usage:

val AmbientApplication = staticAmbientOf<Application>()

@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            Providers(
                AmbientApplication provides application
            ) {
                ProvideNavigationViewModelFactoryMap(factory = defaultViewModelProviderFactory as HiltViewModelFactory) {
                    NavHost(navController = navController, startDestination = "home") {
                        composable("home") {
                            val viewModel = navViewModel<HomeViewModel>()
                            Button(onClick = {
                                navController.navigate("home2")
                            }) {
                                Text(text = "Click me!")
                            }
                        }
                        composable("home2") {
                            val viewModel = navViewModel<HomeViewModel>()
                            Text(text = "home2")
                        }
                    }
                }
            }
        }
    }
}

class HomeViewModel @ViewModelInject constructor(
    val sharedPreferences: SharedPreferences
) : ViewModel()

All 6 comments

Run into the same issue, a quick workaround is to pass a UUID to viewModel() as key, but this will create a new view model every time.

What version of the AndroidX ViewModel extension are you using? The issue that caused the java.lang.IllegalArgumentException: SavedStateProvider with the given key is already registered was fixed in alpha02 of the extension: https://developer.android.com/jetpack/androidx/releases/hilt#1.0.0-alpha02

I'm using 1.0.0-alpha02.
Note that this happens when using navigation-compose with jetpack compose only, which means that there's only a single activity without any fragment.
A quick example like this:

@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            NavHost(navController = navController, startDestination = "home") {
                composable("home") {
                    val viewModel = viewModel<HomeViewModel>(factory = defaultViewModelProviderFactory)
                    Button(onClick = {
                        navController.navigate("home2")
                    }) {
                        Text(text = "Click me!")
                    }
                }
                composable("home2") {
                    val viewModel = viewModel<HomeViewModel>(factory = defaultViewModelProviderFactory)
                    Text(text = "home2")
                }
            }
        }
    }
}

class HomeViewModel @ViewModelInject constructor(
    val sharedPreferences: SharedPreferences
) : ViewModel()

When you click the button and navigate to "home2", the app will crash

I am facing the same issue but for me the app crashes immediatelly when navigating to the second screen.
it would be great if we can have a temporarly workaround and maybe a fixed version soon.
thx for the great work

Thanks for the sample code! It looks like you are hitting the same issue I've described here: https://github.com/google/dagger/issues/2152#issuecomment-722706928

In essence using the activity or fragment as the SavedStateRegistryOwner when the ViewModelStoreOwners is not the same will cause your ViewModel to be different between the your two navigation destinations but because the SavedStateRegistryOwner has a higher scope it complains when trying to provide the same SavedStateHandle that was already consumed. We need to make HiltViewModelFactory use the SavedStateRegistryOwner provided by the Navigation library and specifically the NavBackStackEntry.

Sadly there is no workaround for now since HiltViewModelFactory's constructor is package-protected so you can't build it yourself with the right SavedStateRegistryOwner. We'll try to get this fixed soon!

Here is a complete workaround using reflection

@Composable
inline fun <reified VM : ViewModel> navViewModel(
    key: String? = null,
    factory: ViewModelProvider.Factory? = AmbientViewModelProviderFactory.current,
): VM {
    val navController = AmbientNavController.current
    val backStackEntry = navController.currentBackStackEntryAsState().value
    return if (backStackEntry != null) {
        // Hack for navigation viewModel
        val application = AmbientApplication.current
        val viewModelFactories = AmbientViewModelFactoriesMap.current
        val delegate = SavedStateViewModelFactory(application, backStackEntry, null)
        val hiltViewModelFactory = HiltViewModelFactory::class.java.declaredConstructors.first()
            .newInstance(backStackEntry, null, delegate, viewModelFactories) as HiltViewModelFactory
        viewModel(key, hiltViewModelFactory)
    } else {
        viewModel(key, factory)
    }
}

@Composable
fun ProvideNavigationViewModelFactoryMap(factory: HiltViewModelFactory, content: @Composable () -> Unit) {
    // Hack for navigation viewModel
    val factories =
        HiltViewModelFactory::class.java.getDeclaredField("mViewModelFactories").also { it.isAccessible = true }
            .get(factory).let {
                it as Map<String, ViewModelAssistedFactory<out ViewModel>>
            }
    Providers(
        AmbientViewModelFactoriesMap provides factories
    ) {
        content.invoke()
    }
}

usage:

val AmbientApplication = staticAmbientOf<Application>()

@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            Providers(
                AmbientApplication provides application
            ) {
                ProvideNavigationViewModelFactoryMap(factory = defaultViewModelProviderFactory as HiltViewModelFactory) {
                    NavHost(navController = navController, startDestination = "home") {
                        composable("home") {
                            val viewModel = navViewModel<HomeViewModel>()
                            Button(onClick = {
                                navController.navigate("home2")
                            }) {
                                Text(text = "Click me!")
                            }
                        }
                        composable("home2") {
                            val viewModel = navViewModel<HomeViewModel>()
                            Text(text = "home2")
                        }
                    }
                }
            }
        }
    }
}

class HomeViewModel @ViewModelInject constructor(
    val sharedPreferences: SharedPreferences
) : ViewModel()
Was this page helpful?
0 / 5 - 0 ratings