I'm currently working on an application using Compose and Hilt, and occasionally (less than 10 times out of 1000 according to my automated tests), the order of calls to my ViewModel differs, leading to NullPointerExceptions because I'm manipulating attributes that Hilt apparently hasn't injected yet.
My ViewModels inherit from a base ViewModel:
abstract class ArrowWordsViewModel(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO.also { println("CPT ERROR Parent constructor") }
) :
ViewModel() {
init {
println("CPT ERROR INIT")
computeViewModelInternally(true)
}
open suspend fun computeViewModel() {}
private fun computeViewModelInternally() {
Timber.d("CPT ERROR - computeViewModelInternal")
viewModelScope.launch(dispatcher + CoroutineExceptionHandler { _, throwable ->
viewModelScope.launch(Dispatchers.Main) {
println("CPT ERROR THROWABLE- ${throwable.javaClass.name}")
}
}) {
println("CPT ERROR computeViewModel parent")
computeViewModel()
}
}
}
And I have a ViewModel that inherits from it. For example:
@HiltViewModel
class AdsViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val preferences: ArrowWordsPreferences,
private val fake: Int
) : ArrowWordsViewModel() {
val isOnBoardingContextTest= savedStateHandle.get<Boolean>(ScreenArgs.ADS_IS_ONBOARDING_EXTRA).also { println("CPT ERROR ATTRIBUTE") }
val isOnBoardingContext = MutableStateFlow(false)
val showAlertDialog = MutableStateFlow(false)
override suspend fun computeViewModel() {
println("CPT ERROR computeViewModel")
super.computeViewModel()
isOnBoardingContext.value = savedStateHandle.get<Boolean>(ScreenArgs.ADS_IS_ONBOARDING_EXTRA) ?: false
}
}
The injected elements are defined in a Hilt Module:
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
@Singleton
fun providePreferences(@ApplicationContext context: Context): ArrowWordsPreferences =
ArrowWordsPreferences(context)
@Provides
fun provideFake() : Int =
1.also { println("CPT ERROR - PROVIDE FAKE") }
}
My ViewModel is then used in a Composable:
@Composable
fun AdsScreen(
modifier: Modifier = Modifier,
viewModel: AdsViewModel = hiltViewModel(),
onBackClicked: () -> Unit,
onOnBoardingFinished: () -> Unit
) {
//...
}
In 99% of cases, everything works fine. But sometimes, I get a NullPointerException
on the line isOnBoardingContext.value = savedStateHandle.get<Boolean>(ScreenArgs.ADS_IS_ONBOARDING_EXTRA) ?: false
called in the computeViewModel
method of the AdsViewModel
class.
As you can see, I've added logs and fake injections to try to understand the flow of my code.
In 99% of cases, when everything goes well, the logs produce the following output:
CPT ERROR - PROVIDE FAKE
CPT ERROR Parent constructor
CPT ERROR INIT
CPT ERROR ATTRIBUTE
CPT ERROR computeViewModel parent
CPT ERROR computeViewModel
But when I get a NullPointerException
, the following logs are displayed:
CPT ERROR - PROVIDE FAKE
CPT ERROR Parent constructor
CPT ERROR INIT
CPT ERROR computeViewModel parent
CPT ERROR computeViewModel
CPT ERROR ATTRIBUTE
CPT ERROR THROWABLE- java.lang.NullPointerException
We can see that the difference is at the level of the CPT ERROR ATTRIBUTE
log which is not in the same place.
According to the Kotlin documentation:
During the initialization of an instance, the initializer blocks are executed in the same order as they appear in the class body, interleaved with the property initializers:
I therefore conclude that in my case, the constructor of the ArrowWordsViewModel
class is called BEFORE the end of Hilt's injection in the constructor of the AdsViewModel
class. Since the primary constructor of the ArrowWordsViewModel
class is called, it then allows the call to the init
block which triggers the computeViewModel
methods and leads to NullPointerExceptions
since Hilt doesn't seem to have finished its injection (since the attributes of the AdsViewModel
class are called afterwards).
How can I rework my code to ensure that Hilt has finished its injection before calling the init
block of the parent class which triggers the data loading in my case?
Note that the code is intentionally simplified and I would like to keep this system of "function that loads data" if possible.
since Hilt doesn't seem to have finished its injection
Your superclass is launching a coroutine that depends upon completion of initialization of the subclass. This is a race condition: you have assumed that the completion of initialization of the subclass will happen before the coroutine runs. This is not guaranteed, in part because you have not done anything to guarantee it. The coroutine runs independently, and if the vagaries of the scheduler cause the IO coroutine to run earlier than you expected... you'll get this problem.
Either have the subclass launch the coroutine (after its constructor initialization is complete), or work out some sort of synchronization such that the coroutine will block until that constructor initialization is complete.
Of those two, I would go with the former option. So, you would have:
abstract class ArrowWordsViewModel(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO.also { println("CPT ERROR Parent constructor") }
) :
ViewModel() {
open suspend fun computeViewModel() {}
protected fun computeViewModelInternally() {
Timber.d("CPT ERROR - computeViewModelInternal")
viewModelScope.launch(dispatcher + CoroutineExceptionHandler { _, throwable ->
viewModelScope.launch(Dispatchers.Main) {
println("CPT ERROR THROWABLE- ${throwable.javaClass.name}")
}
}) {
println("CPT ERROR computeViewModel parent")
computeViewModel()
}
}
}
and:
@HiltViewModel
class AdsViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val preferences: ArrowWordsPreferences,
private val fake: Int
) : ArrowWordsViewModel() {
val isOnBoardingContextTest= savedStateHandle.get<Boolean>(ScreenArgs.ADS_IS_ONBOARDING_EXTRA).also { println("CPT ERROR ATTRIBUTE") }
val isOnBoardingContext = MutableStateFlow(false)
val showAlertDialog = MutableStateFlow(false)
init {
computeViewModelInternally()
}
override suspend fun computeViewModel() {
println("CPT ERROR computeViewModel")
super.computeViewModel()
isOnBoardingContext.value = savedStateHandle.get<Boolean>(ScreenArgs.ADS_IS_ONBOARDING_EXTRA) ?: false
}
}