Components and Scoping in Hilt

August 2, 2020

IMG_1433.jpg

Updated April 4, 2021

Components

Both Dagger and Hilt have a concept of Component which is a dependencies container that follows the Android lifecycle. But unlike Dagger, Hilt users never define or instantiate Dagger components directly. Instead, Hilt offers predefined components that are generated for you. Because of that it’s easier to grok.

Here are the predefined Components in Hilt and their hierarchy:

  • SingletonComponent

  • ActivityRetainedComponent

  • ViewModelComponent

  • ActivityComponent

  • FragmentComponent

  • ViewComponent

  • ViewWithFragmentComponent

  • ServiceComponent

Let’s look at each of them in detail.

SingletonComponent

SingletonComponent is a top-most component in Hilt component hierarchy. It will exist as long as the app is alive. This is a good place to define application-wide bindings such as Repositories, API, SharedPreferences, etc. This is the most long-lived Component in your app.

ActivityRetainedComponent

ActivityRetainedComponent will exist for the duration of Activity lifetime even if the Activity is destroyed and recreated due to configuration change.

arc-final1.png

ViewModelComponent

ViewModelComponent will follow ViewModel lifecycle which is shorter than Application lifecycle but longer than Activity and Fragment lifecycles because ViewModels survive orientation and configuration changes. Both SingletonComponent and ActivityRetainedComponent are parents of this Component—so you’ll be able to depend on all bindings defined in SingletonComponent and ActivityRetainedComponent. Note: ViewModelComponent also contains a default binding of the SavedStateHandle associated with its ViewModel.

vmc-final1.png

ActivityComponent

ActivityComponent follows Activity lifecycle. When an Activity that this Component is attached to is destroyed, the bindings in this Component will be destroyed as well. ActivityRetainedComponent is a parent of this component so you’ll be able to depend on all bindings defined in both ActivityRetainedComponent and SingletonComponent.

ac-final1.png

FragmentComponent

FragmentComponent follows Fragment lifecycle. ActivityComponent is a parent of this Component so you’ll be able to depend on all bindings defined in the ActivityComponent, ActivityRetainedComponent and SingletonComponent.

fc-final1.png

ViewComponent

ViewComponent follows View attached to Activity lifecycle. ActivityComponent is a parent of this Component so you’ll be able to depend on all bindings defined in the ActivityComponent, ActivityRetainedComponent and SingletonComponent.

vc-final1.png

ViewWithFragmentComponent

ViewWithFragmentComponent requires and instance of the View and the Fragment that this View is attached to. During its lifecycle, Fragment instance will go through onAttach and then onCreate and exist until onDestroy followed by onDetach. If this Fragment has views, they will be inflated in onCreateView and destroyed in onDestroyView (callbacks that occur between onCreate and onDestroy of the Fragment instance). During that time, ViewWithFragmentComponent will exist. If the user then navigates deeper in the app causing the Fragment to be added to backstack while staying in memory, each view will be destroyed and its references to the Fragment will be cleaned up in onDestroyView. At that point the Fragment still exists but the views don’t, so the ViewWithFragmentComponent will be destroyed as well. The FragmentComponent still exists at that point. When the user navigates back to the Fragment instance, its views will be re-inflated again and ViewWithFragmentComponent will become active again.

You must use the @WithFragmentBindings annotation with @AndroidEntryPoint for these views.

To sum up, the FragmentComponent is a parent of the ViewWithFragmentComponent and will exist even when Fragment’s View no longer exists.  ViewWithFragmentComponent has access to all bindings defined in the FragmentComponent, ActivityComponent, ActivityRetainedComponent and SingletonComponent.

vwfc-final1.png

ServiceComponent

ServiceComponent follows Service lifecycle. SingletonComponent is a parent of this Component. So you will get access to all bindings defined in SingletonComponent.

sc-final.png

Binding to Components

In order to install dependencies into a Component, you need to use @InstallIn annotation.

@InstallIn can only be used on @Module or @EntryPoint classes.

@Module

@InstallIn(SingletonComponent::class)
@Module
object NetworkModule {
  ...
}

@EntryPoint

class MyContentProvider : ContentProvider() {

  @EntryPoint
  @InstallIn(SingletonComponent::class)
  interface MyContentProviderEntryPoint {
    fun analyticsHelper(): AnalyticsHelper
  }
  ...
}

And then when our MyContentProvider needs the AnalyticsHelper binding, it can get by using:

val entryPoint = EntryPointAccessors.fromApplication(appContext, MyContentProviderEntryPoint::class.java)
val analyticsHelper = entryPoint.analyticsHelper()

Entry Points (interfaces annotate with @EntryPoint) provide access to the Dagger object graph in a given component for code that otherwise does not have access to the object graph. For instance, the ContentProvider above cannot be added to our object graph since it can exist before Application.onCreate() is executed. If the ContentProvider needs a binding from our object graph, the binding gets exposed from a given component as an interface by using an @EntryPoint annotation.

Component Internals

@DefineComponent annotation is used internally to define components:

import dagger.hilt.DefineComponent
import javax.inject.Singleton

@Singleton
@DefineComponent
interface SingletonComponent

And here is a predefined FragmentComponent whose parent is the ActivityComponent.

import dagger.hilt.DefineComponent
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.FragmentScoped

@FragmentScoped
@DefineComponent(parent = ActivityComponent::class)
interface FragmentComponent

You can define custom Components but there are some limitations at the moment.

  • Components must be a direct or indirect child of the SingletonComponent

  • Custom Components may not be inserted between any of the standard components. For example, a component cannot be added between the ActivityComponent and the FragmentComponent

Keep in mind that each component/scope adds cognitive overhead and therefore should be used sparingly. Custom components work against standardization—the more custom Components are used, the harder it is to work with the object graph and to use them in shared libraries.

Scoping

Each Component referenced via @InstallIn has a corresponding scope annotation:

hilt-components-scopes.png

All dependency bindings defined in a Module or via annotating classes with @Inject constructors are unscoped by default. Every time you ask for an unscoped dependency within your component, new instance of that dependency will be generated and returned to you.

For instance in this code the AvatarFetcher is unscoped:

@InstallIn(ActivityComponent::class)
@Module
object UserProfileModule {
  
  @Provides
  fun provideAvatarFetcher(): AvatarFetcher {
    ...
  }
}

Every time an Activity in your app asks for an AvatarFetcher, a new instance of AvatarFetcher will be returned even though the dependency binding is installed in a Container.

If you don’t want to create new AvatarFetcher every time you need to inject one, you need to scope it so the same instance is returned every time.

Each Component maps to a preconfigured scope in Hilt. For example, a binding within an @InstallIn(ActivityComponent.class) module can only be scoped with @ActivityScoped.

@InstallIn(ActivityComponent::class)
@Module
object UserProfileModule {
  
  @ActivityScoped
  @Provides
  fun provideAvatarFetcher(): AvatarFetcher {
    ...
  }
}

Or if you have control over AvatarFetcher and use @Inject constructor:

@ActivityScoped
class AvatarFetcher @Inject constructor() {
  ...
}

Here is a mapping of the built-in Components and their preconfigured scopes, see https://developer.android.com/training/dependency-injection/hilt-android#component-hierarchy

Should you always scope your dependencies? Do it only when it’s necessary for the app to work correctly. For instance, your class instance may maintain some state which needs to be factored in every time you ask for that object. Scoping adds overhead in both the generated code size and runtime performance. Often creating lightweight objects every time is better than managing and returning the same object.

Warning: A common misconception is that all fragment instances will share the same instance of a binding scoped with @FragmentScoped. However, this is not true. Each fragment instance gets a new instance of the fragment component, and thus a new instance of all its scoped bindings.

So if your AvatarFetcher is scoped to a FragmentComponent

@FragmentScoped
class AvatarFetcher @Inject constructor() {
  ...
}

And you have 2 Fragments asking AvatarFetcher.

Here is a first Fragment:

@AndroidEntryPoint
class RegistrationFragment : Fragment() {

  @Inject
  lateinit var avatarFetcher: AvatarFetcher
  ...
}

And another one:

@AndroidEntryPoint
class ProfileFragment : Fragment() {

  @Inject
  lateinit var avatarFetcher: AvatarFetcher
  ...
}

Every time you ask for an instance of the AvatarFetcher in RegistrationFragment, it’s going to be the same instance of AvatarFetcher.

Every time you ask for an instance of the AvatarFetcher in ProfileFragment, it’s going to be the same instance of AvatarFetcher but this instance will be different from the AvatarFetcher instance that the RegistrationFragment gets.

To re-iterate, each Fragment gets its own FragmentComponent container and bindings within it are available to that Fragment only. Scoped bindings guarantee one instance per that instance of the Container.

ViewModel scoping

ViewModels can be scoped to a Fragment, a host Activity or a navigation graph.

First you need to inject a ViewModel using @ViewModelInject available in hilt-lifecycle-viewmodel Gradle dependency:

class ExampleViewModel @ViewModelInject constructor() : ViewModel() {
  ...
}

Then, if your Fragment is using a ViewModel that should be scoped to that Fragment, use by viewModels() delegate:

@AndroidEntryPoint
class RegistrationFragment : Fragment() {
  private val viewModel: RegistrationViewModel by viewModels()
}

If your Fragment is using a ViewModel should be scoped to a host Activity, use by activityViewModels() delegate:

@AndroidEntryPoint
class RegistrationFragment : Fragment() {
  private val viewModel: RegistrationViewModel by activityViewModels()
}

Using a ViewModel from an Activity requires using by viewModels() delegate:

@AndroidEntryPoint
class RegistrationActivity : AppCompatActivity() {
  private val viewModel: RegistrationViewModel by viewModels()
}

If you are using Jetpack Navigation library, you can have custom scopes (for instance, a scope for Onboarding flow, Checkout flow, etc). In that case, if your ViewModel is scoped to the navigation graph, use the defaultViewModelProviderFactory object that is available to Activities and Fragments that are annotated with @AndroidEntryPoint:

val viewModel: ExampleViewModel by navGraphViewModels(R.id.my_graph) {
  defaultViewModelProviderFactory
}
 
Previous
Previous

Conditional Caching with Retrofit and OkHttp

Next
Next

Inject vs Provides vs Binds in Dagger and Hilt