Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dagger 2: multi-module project, inject dependency but get "lateinit property repository has not been initialize" error at runtime

Dagger version is 2.25.2.

I have two Android project modules: core module & app module.

In core module, I defined for dagger CoreComponent ,

In app module I have AppComponent for dagger.

CoreComponet in core project module:

@Component(modules = [MyModule::class])
@CoreScope
interface CoreComponent {
   fun getMyRepository(): MyRepository
}

In core project module, I have a repository class, it doesn't belong to any dagger module but I use @Inject annotation next to its constructor:

class MyRepository @Inject constructor() {
   ...
}

My app component:

@Component(modules = [AppModule::class], dependencies = [CoreComponent::class])
@featureScope
interface AppComponent {
    fun inject(activity: MainActivity)
}

In MainActivity:

class MainActivity: AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val coreComponent = DaggerCoreComponent.builder().build()

        DaggerAppComponent
                  .builder()
                  .coreComponent(coreComponent)
                  .build()
                  .inject(this)
     }

}

My project is MVVM architecture, In general:

  • MainActivity hosts MyFragment

  • MyFragment has a reference to MyViewModel

  • MyViewModel has dependency MyRepository (as mentioned above MyRepository is in core module)

Here is MyViewModel :

class MyViewModel : ViewModel() {
    // Runtime error: lateinit property repository has not been initialize
    @Inject
    lateinit var repository: MyRepository

    val data = repository.getData()

}

MyViewModel is initialized in MyFragment:

class MyFragment : Fragment() {
   lateinit var viewModel: MyViewModel

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java)
        ...
    }
}

When I run my app, it crashes with runtime error:

kotlin.UninitializedPropertyAccessException: lateinit property repository has not been initialize

The error tells me dagger dependency injection does't work with my setup. So, what do I miss? How to get rid of this error?

==== update =====

I tried :

class MyViewModel @Inject constructor(private val repository: MyRepository): ViewModel() {
        val data = repository.getData()
    }

Now when I run the app, I get new error:

Caused by: java.lang.InstantiationException: class foo.bar.MyViewModel has no zero argument constructor

====== update 2 =====

Now, I created MyViewModelFactory:

class MyViewModelFactory @Inject constructor(private val creators: Map<Class<out ViewModel>,
                                            @JvmSuppressWildcards Provider<ViewModel>>): ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        val creator = creators[modelClass] ?: creators.entries.firstOrNull {
            modelClass.isAssignableFrom(it.key)
        }?.value ?: throw IllegalArgumentException("unknown model class $modelClass")
        try {
            @Suppress("UNCHECKED_CAST")
            return creator.get() as T
        } catch (e: Exception) {
            throw RuntimeException(e)
        }

    }
}

I updated MyFragment to be :

class MyFragment : Fragment() {
   lateinit var viewModel: MyViewModel
   @Inject
lateinit var viewModelFactory: ViewModelProvider.Factory

   override fun onAttach(context: Context) {
    // inject app component in MyFragment
    super.onAttach(context)
    (context.applicationContext as MyApplication).appComponent.inject(this)
}

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // I pass `viewModelFactory` instance here, new error here at runtime, complaining viewModelFactory has not been initialized
        viewModel = ViewModelProviders.of(this, viewModelFactory).get(MyViewModel::class.java)
        ...
    }
}

Now I run my app, I get new error:

kotlin.UninitializedPropertyAccessException: lateinit property viewModelFactory has not been initialized

What's still missing?

like image 786
Leem Avatar asked Dec 29 '19 22:12

Leem


2 Answers

In order to inject dependencies Dagger must be either:

  • responsible for creating the object, or
  • ask to perform an injection, just like in the activities or fragments, which are instantiated by the system:
DaggerAppComponent
    .builder()
    .coreComponent(coreComponent)
    .build()
    .inject(this)

In your first approach none of the above is true, a new MyViewModel instance is created outside Dagger's control:

viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java)

therefore the dependency doesn't even get initialized. Additionally, even if you'd perform the injection more manually, like in the activity, the code still would fail, because you are trying to reference the repository property during the initialization process of the object val data = repository.getData(), before the lateinit var gets a chance to be set. In such cases the lazy delegate comes handy:

class MyViewModel : ViewModel() {
    @Inject
    lateinit var repository: MyRepository

    val data by lazy { repository.getData() }

    ...
}

However, the field injection isn't the most desirable way to perform a DI, especially when the injectable objects needs to know about it. You can inject your dependencies into ViewModels using the construction injection, but it requires some additional setup.

The problem lies in the way view models are created and managed by the Android SDK. They are created using a ViewModelProvider.Factory and the default one requires the view model to have non-argument constructor. So what you need to do to perform the constructor injection is mainly to provide your custom ViewModelProvider.Factory:

// injects the view model's `Provider` which is provided by Dagger, so the dependencies in the view model can be set
class MyViewModelFactory<VM : ViewModel> @Inject constructor(
    private val viewModelProvider: @JvmSuppressWildcards Provider<VM> 
) : ViewModelProvider.Factory {

    @Suppress("UNCHECKED_CAST")
     override fun <T : ViewModel?> create(modelClass: Class<T>): T = 
         viewModelProvider.get() as T
}

(There are 2 approaches to implementing a custom ViewModelProvider.Factory, the first one uses a singleton factory which gets a map of all the view models' Providers, the latter (the one above) creates a single factory for each view model. I prefer the second one as it doesn't require additional boilerplate and binding every view model in Dagger's modules.)

Use the constructor injection in your view model:

class MyViewModel @Inject constructor(private val repository: MyRepository): ViewModel() {
    val data = repository.getData()
}

And then inject the factory into your activities or fragments and use it to create the view model:

@Component(modules = [AppModule::class], dependencies = [CoreComponent::class])
@featureScope
interface AppComponent {
    fun inject(activity: MainActivity)
    fun inject(fragment: MyFragment)
}

class MyFragment : Fragment() {

   @Inject
   lateinit var viewModelFactory: MyViewModelFactory<MyViewModel>

   lateinit var viewModel: MyViewModel

   override fun onAttach(context: Context) {
      // you should create a `DaggerAppComponent` instance once, e.g. in a custom `Application` class and use it throughout all activities and fragments
      (context.applicationContext as MyApp).appComponent.inject(this)
      super.onAttach(context)
   }

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel = ViewModelProviders.of(this, viewModelFactory)[MyViewModel::class.java]
        ...
    }
}
like image 101
jsamol Avatar answered Nov 12 '22 11:11

jsamol


A few steps you'll need to use Dagger with the AAC ViewModel classes:

  1. You need to use constructor injection in your ViewModel class (as you're doing in the updated question)
  2. You will need a ViewModelFactory to tell the ViewModelProvider how to instantiate your ViewModel
  3. Finally, you will need to tell Dagger how to create your ViewModelFactory

For the first step, pass the repository in the ViewModel constructor and annotate your view model class with @Inject:

class MyViewModel @Inject constructor(private val repository: MyRepository): ViewModel() {
    val data = repository.getData()
}

For the second and third steps, one easy way to create a generic ViewModelFactory for any ViewModels that you will have in your project, and also tell Dagger how to use it you can:

Create a Singleton generic ViewModelFactory:

@Singleton
class ViewModelFactory @Inject constructor(private val viewModels: MutableMap<Class<out ViewModel>, Provider<ViewModel>>) :
        ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T =
            viewModels[modelClass]?.get() as T
}

Create a custom annotation to identify your ViewModels and let Dagger know that it needs to provide them:

@Target(
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY_GETTER,
    AnnotationTarget.PROPERTY_SETTER
)
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
@MapKey
internal annotation class ViewModelKey(val value: KClass<out ViewModel>)

Create a new module for your ViewModels:

@Module
abstract class ViewModelModule {

@Binds
internal abstract fun bindsViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory

// Add any other ViewModel that you may have
@Binds
@IntoMap
@ViewModelKey(MyViewModel::class)
internal abstract fun bindsMyViewModel(viewModel: MyViewModel): ViewModel
}

Don't forget to declare the new module in your dagger component

And use the view model in your activity, instantiating it with the help of the ViewModelFactory:

class MyFragment : Fragment() {
   @Inject
   lateinit var viewModelFactory: ViewModelProvider.Factory
   lateinit var viewModel: MyViewModel

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel = ViewModelProviders.of(this, viewModelFactory).get(MyViewModel::class.java)
        ...
    }
}
like image 25
BMacedo Avatar answered Nov 12 '22 11:11

BMacedo