Advanced ViewModel injection with DaggerHilt in Jetpack Compose
During the development proccess, I faced an issue where I had to manage multiple identical views, each of them with its own lifecycle. Imagine a HorizontalPager with a list of cards. Each of this card should perform a network request and handle data accordingly. There are multiple ways to achieve this:
- Create a MasterViewModel which will hold Map<String, MutableStateFlow<ViewState>>. It will allows us to work with a single instance of the ViewModel. But it will force us to handle lists/maps and nullable cases. On the bright side you don’t need to deal with AssistedInject.
- Instantiate a ViewModel per page. Simple, neat, clean. This approach can be done in multiple ways as well. One of them would require to deal with AssistedInject. But, at the same time, we would simplify VM logic quite a bit.
In this article I will focus on the second point: scoping the ViewModel to a specific view. There are two possible ways to achieve this.
Let’s take a look at our simple starting example:
@HiltViewModel
class HubCardViewModel @Inject constructor(
private val getHubDetails: GetHubDetails,
private val mapper: HubDomainToViewDataMapper,
) : ViewModel() {
private val _state: MutableStateFlow<HubCardViewState> =
MutableStateFlow(HubCardViewState.Loading)
internal val state: StateFlow<HubCardViewState> = _state.asStateFlow()
fun loadData(hubId: Int) {
viewModelScope.launch {
_state.value = HubCardViewState.Loading
val res = getHubDetails.invoke(hubId)
_state.value = res.fold(
onSuccess = { HubCardViewState.Success(mapper(it)) },
onError = {
it.printStackTrace()
HubCardViewState.Error(it.message ?: "no idea")
}
)
}
}
}
This is a simple ViewModel which has 2 injectable parameters: a use case (GetHubDetails) and a mapper (HubDomainToViewDataMapper).
The main logic here is done by loadData method which requires hubId as a parameter.
What we want to do is to make HubCardViewModel be created every time when the hubId is different.
Let’s take a look at our Composable function.
Note: it’s broken
@Composable
fun HubCard(
modifier: Modifier = Modifier,
hubId: Int,
hubCardViewModel: HubCardViewModel = hiltViewModel(),
onChargeClick: () -> Unit = {},
onNavigationClick: () -> Unit = {},
onBookmarkClick: () -> Unit = {}
) {
val state by hubCardViewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(hubId) {
hubCardViewModel.loadData(hubId)
}
HubCardContent(
modifier = modifier,
hub = state,
onNavigationClick = onNavigationClick,
onChargeClick = onChargeClick,
onBookmarkClick = onBookmarkClick
)
}
A new instance of HubCardViewModel will be injected using Hilt. Then we load data in LaunchedEffect to prevent redundant requests in case of recompositions.
But this code will not work properly. It will load the last visible item in the HorizontalPager because we have a single instance of HubCardViewModel
Solution 1
We need to provide a key to hiltViewModel(). Assisted injection was introduced in version 1.2.0, if you don’t have a key parameter for hiltViewModel function, then update your library to the latest version.
navigation-hilt-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "navigationHiltCompose" }
The final result will look like this:
@Composable
fun HubCard(
modifier: Modifier = Modifier,
hubId: Int,
hubCardViewModel: HubCardViewModel = hiltViewModel(key = "$hubId"),
onChargeClick: () -> Unit = {},
onNavigationClick: () -> Unit = {},
onBookmarkClick: () -> Unit = {}
) {
val state by hubCardViewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(hubId) {
hubCardViewModel.loadData(hubId)
}
HubCardContent(
modifier = modifier,
hub = state,
onNavigationClick = onNavigationClick,
onChargeClick = onChargeClick,
onBookmarkClick = onBookmarkClick
)
}
Solution 2
Since you’re already here, I assume you want to hear about AssistedInject.
This is what Dagger documentations says:
Assisted injection is a dependency injection (DI) pattern that is used to construct an object where some parameters may be provided by the DI framework and others must be passed in at creation time (a.k.a “assisted”) by the user.
So it means that we can pass parameters to our ViewModel’s constructor.
First thing we need to do is to change our ViewModel.
@HiltViewModel(assistedFactory = HubCardViewModel.Factory::class)
class HubCardViewModel @AssistedInject constructor(
private val getHubDetails: GetHubDetails,
private val mapper: HubDomainToViewDataMapper,
@Assisted private val hubId: Int
) : ViewModel() {
private val _state: MutableStateFlow<HubCardViewState> =
MutableStateFlow(HubCardViewState.Loading)
internal val state: StateFlow<HubCardViewState> = _state.asStateFlow()
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
_state.value = HubCardViewState.Loading
val res = getHubDetails.invoke(hubId)
_state.value = res.fold(
onSuccess = { HubCardViewState.Success(mapper(it)) },
onError = {
it.printStackTrace()
HubCardViewState.Error(it.message ?: "no idea")
}
)
}
}
@AssistedFactory
interface Factory {
fun create(hubId: Int): HubCardViewModel
}
}
Let’s analyse the code above in detail.
@HiltViewModel(assistedFactory = HubCardViewModel.Factory::class)
HiltViewModel annotation should be defined with the reference to the corresponding assisted factory.
HubID becomes part of the constructor and is marked as @Assisted. In case you have multiple variables of the same type, you would need to provide an identifier (e.g. @Assisted(“hubId”), @Assisted(“userId”)). It will help Dagger to distinguish which value to provide.
The next thing to do is the Factory itself. It’s a simple interface which defines which values have to be provided in order to populate our ViewModel.
@AssistedFactory
interface Factory {
fun create(hubId: Int): HubCardViewModel
}
In my case, I’ve placed the ViewModel factory in the class itself, but it can be placed elsewhere.
The last thing we need to do is to instantiate the ViewModel in our composable.
val hubCardViewModel: HubCardViewModel =
hiltViewModel<HubCardViewModel, HubCardViewModel.Factory>(
key = "$hubId",
creationCallback = { it.create(hubId = hubId) }
)
Full code:
@Composable
fun HubCard(
modifier: Modifier = Modifier,
hubId: Int,
onChargeClick: () -> Unit = {},
onNavigationClick: () -> Unit = {},
onBookmarkClick: () -> Unit = {}
) {
val hubCardViewModel: HubCardViewModel =
hiltViewModel<HubCardViewModel, HubCardViewModel.Factory>(
key = "$hubId",
creationCallback = { it.create(hubId = hubId) }
)
val state by hubCardViewModel.state.collectAsStateWithLifecycle()
HubCardContent(
modifier = modifier,
hub = state,
onNavigationClick = onNavigationClick,
onChargeClick = onChargeClick,
onBookmarkClick = onBookmarkClick
)
}
My personal preference is to have the ViewModel as a parameter for the Composable.
@Composable
fun HubCard(
modifier: Modifier = Modifier,
hubId: Int,
hubCardViewModel: HubCardViewModel = hiltViewModel<HubCardViewModel, HubCardViewModel.Factory>(
key = "$hubId",
creationCallback = { factory -> factory.create(hubId = hubId) }
),
onChargeClick: () -> Unit = {},
onNavigationClick: () -> Unit = {},
onBookmarkClick: () -> Unit = {}
) {
val state by hubCardViewModel.state.collectAsStateWithLifecycle()
HubCardContent(
modifier = modifier,
hub = state,
onNavigationClick = onNavigationClick,
onChargeClick = onChargeClick,
onBookmarkClick = onBookmarkClick
)
}
In this solution, since the hubId is passed in the ViewModel constructor, we don’t need to load data in LaunchedEffect anymore. This logic has now been moved to the init block of the ViewModel.
And there you have it! I hope that by the end of this article you understand Dagger Hilt a bit better and maybe even learned something new. If you have any suggestions or questions feel free to share them in the comments below.
Cheers!
Links