Dependency Injection in Compose

Jonathan Koren
Android Developers
Published in
8 min readApr 9, 2024

--

Overview

Dependency injection is a programming pattern which prescribes that classes do not construct instances of their dependencies, instead such instances are provided. This pattern enables separation of concerns, and increases testability, reusability, and ease of maintenance. See Dependency injection in Android to review the benefits of dependency injection and its core concepts.

You may already be familiar with Hilt, the Android library based on Dagger which implements a dependency injection solution for Android apps. Hilt’s approach includes two important features:

  • Providing dependencies: how objects and their dependencies are constructed and acquired by classes that need them.
  • Scoping dependencies: defining where objects are retained and the lifetime in which those objects are valid.

Meanwhile, Compose is rapidly becoming the new standard for building UI in Android apps, using functional programming to control what appears on screen. This article will discuss details of how Hilt provides and scopes dependencies in a traditional Android app and how Compose changes our approach.

Providing dependencies

Hilt uses annotations to generate source code that instantiates objects and provides them as dependencies to other objects. Every type in the dependency graph must be known, as well as how to construct objects of that type, in order to validate that every dependency can be fulfilled, otherwise the code generator will fail and the app will not build.

Doing this work up front comes at a cost of slightly longer build times, but what you get in return is performant code and type safety. The generated code knows exactly which objects are needed where, and how to create them properly, so you do not encounter runtime errors caused by a missing dependency or by receiving a dependency of the wrong type.

Suppose you use dependency injection to create the objects which implement your business logic, and at some point you need to connect them to your UI. In an Android app, the bridge to the UI layer is through Activities and Fragments, but these classes are instantiated by the Android framework and cannot be created by Hilt or any other DI solution.

Fortunately Hilt can automatically inject members into framework classes like Activity and Fragment just by adding a few annotations. For example, this CheckoutFragment needs a PaymentApi so that the user can place their shopping order, and here’s what that would look like:

class PaymentApi @Inject constructor() {
fun submitPayment(...) { /* other business logic */ }
}

@AndroidEntryPoint
class CheckoutFragment : Fragment() {
@Inject
lateinit var paymentApi: PaymentApi // example dependency

override fun onViewCreated(...) {
// ...
submitButton.setOnClickListener {
paymentApi.submitPayment(...)
}
}
}

However, the recommended approach is to use a ViewModel and inject your dependencies there through the constructor. This helps you group dependencies in a way that is agnostic to the UI framework, and also lets those objects be retained across Activity or Fragment recreation. Let’s put that PaymentApi in a ViewModel instead:

@AndroidEntryPoint
class CheckoutFragment : Fragment() {
private val viewModel: CheckoutViewModel by viewModels()

override fun onViewCreated(...) {
// ...
submitButton.setOnClickListener {
viewModel.submitPayment(...)
}
}
}

@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val paymentApi: PaymentApi, // example dependency
) : ViewModel() {

fun submitPayment(...) {
paymentApi.submitPayment(...)
}
}

Sometimes injecting into a ViewModel is not possible. If PaymentApi needs to be constructed with an Activity instance, a ViewModel holding on to that dependency will leak the old Activity after a configuration change. In those cases, fall back to injecting into the framework class.

Scoping dependencies

Hilt allows you to declare dependency scopes and the container in which they are retained; these containers are known as Components. A scope and its corresponding Component define the lifetime for an object.

Hilt comes with an opinionated set of scopes and Components for Android that align with key types like Activity, Fragment, and ViewModel because these all have well defined lifetimes and it’s common to want dependencies to be retained according to those lifetimes.

Choosing the right scope for a dependency has impacts on correctness and performance. If you need an object in one particular screen, but you inject it into a broader lifetime like the Application’s, that object can live and consume memory when it’s not being used. Similarly, choosing too small a scope for an object that manages some shared data can cause issues when that object disappears too soon.

What’s different in Compose

Earlier, we saw two ways that Hilt injects dependencies: through the constructor (as with a ViewModel), or by setting members (as with an Activity). But Composable functions are just functions. They are not classes and are not instantiated, and they have no members, so neither constructor injection nor member injection is possible. All a Composable function has access to are its parameters and any members of its enclosing class — if it has an enclosing class.

Furthermore, the lifetime of a Composable is not as well defined as that of an Activity or a Fragment. It could go into and out of composition frequently, and it could be present in multiple places in the composition, perhaps at different depths of the hierarchy. Having a dependency Component tied to a Composable is therefore not as straightforward, and Hilt does not define a scope or include a Component for use with a Composable.

Recommendations

Use ViewModel and Compose Navigation

If your dependencies can be injected into a ViewModel, that is still the recommended way to connect your objects to the UI layer. ViewModels have well defined lifetimes not tied to a particular Composable, and can be acquired on demand.

@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val paymentApi: PaymentApi // example dependency
) : ViewModel() {

fun submitPayment(...) {
paymentApi.submitPayment(...)
}
}

@Composable
fun CheckoutScreen(
viewModel: CheckoutViewModel = hiltViewModel()
) {
// ...
Button(
onClick = { viewModel.submitPayment(...) }
) {
Text("Submit")
}
}

The hiltViewModel() function takes care of finding the nearest suitable owner, usually an Activity or Fragment, that can retain the ViewModel. However, more and more apps built primarily with Compose only have a single Activity and no Fragments. If the one Activity is the only available owner, then all the ViewModels (and all their injected objects) will be retained for as long as that Activity is alive, which is probably too long for ViewModels meant only for a specific portion of the UI.

To alleviate this, use the Compose Navigation library, where hiltViewModel() will automatically use a navigation destination’s back stack entry as the owner for the ViewModel. The ViewModel will be retained as long as that destination is present in the navigation back stack, which better aligns with the place it is used.

Use an enclosing class with constructor injection

If you have dependencies which can’t be injected into a ViewModel, but you still want to group them logically next to the content that uses them, you can make a class which contains a Composable function and inject the dependencies into it. Then inject the enclosing class into an Activity or Fragment close to where it’s needed and invoke the Composable function from there. The enclosing class should contain no state other than the injected dependencies and it should not use any scope annotations.

// This time the dependency needs an Activity, so Hilt
// can't inject it into a ViewModel.
class PaymentApi @Inject constructor(
private val activity: Activity,
) { ... }

// Encloses the composable which uses the PaymentApi.
class CheckoutScreenFactory @Inject constructor(
private val paymentApi: PaymentApi,
// ... more dependencies
) {
@Composable
fun Content(...) {
// paymentApi can be accessed here
}
}

@AndroidEntryPoint
class ShoppingActivity : ComponentActivity {
@Inject
lateinit var checkoutScreenFactory: CheckoutScreenFactory

fun onCreate(savedInstanceState: Bundle) {
super.onCreate(savedInstanceState)
setContent {
// somewhere down in the composition hierarchy...
checkoutScreenFactory.Content(...)
}
}
}

This is a useful pattern because you can construct a CheckoutScreenFactory with a test double of PaymentApi to test the Composable.

Avoid storing dependencies in CompositionLocal

It might be tempting to store @Injected objects inside a CompositionLocal and let Composables acquire those objects that way. On the surface, this seems simpler than passing those objects down as parameters through a series of Composables.

But this approach gives up some of the safeguards that Hilt provides and introduces possible runtime errors. The desired object might not be present, or could be replaced with another object by any other Composable along the way without your knowledge. If you aren’t careful, you might be providing an object that is part of the wrong scope.

Use Entry Points

Hilt provides a way to acquire an object from the dependency graph using an @EntryPoint. This feels less like “injecting” and more like “requesting” the object, but unlike with CompositionLocal, using an Entry Point guarantees that the object you receive is the correct type and is appropriate for the current scope. For example, this code will acquire a PaymentApi inside of the CheckoutScreen Composable:

@EntryPoint
@InstallIn(ActivityComponent::class)
interface PaymentApiEntryPoint {
fun paymentApi(): PaymentApi
}

@Composable
fun CheckoutScreen() {
val activity = LocalContext.current as Activity
val paymentApi = remember {
EntryPointAccessors.fromActivity(
activity,
PaymentApiEntryPoint::class.java
).paymentApi()
}
// ...
}

A necessary step is knowing which dependency Component has the desired object, and linking the Entry Point to it using @InstallIn. Since we’re getting PaymentApi from the ActivityComponent, we use EntryPointAccessors.fromActivity() to request it.

remember is important here to avoid accessing the dependency graph every time CheckoutScreen is recomposed. Keep in mind that paymentApi will be forgotten any time CheckoutScreen leaves the composition, so this pattern should be reserved for high-level Composables that don’t go away until the user navigates to another part of the app, or some other dramatic UI change occurs.

Another thing to note is that the dependency Component we’re using still belongs to Hilt and it lives longer than our Composable does. This should be sufficient for most use cases, but if what you want is a dependency Component whose lifetime is completely aligned with a specific Composable, read on.

Use a custom dependency Component

The dependency Components in Hilt have lifetimes that cannot be changed, so instead we can create a new Component. Since it’s not one of the included ones, Hilt won’t know where to create it or how long to retain it, so that part must be implemented by us as well. First let’s define a PaymentComponent to hold our PaymentApi:

@DefineComponent(parent = ActivityComponent::class)
interface PaymentComponent {

@DefineComponent.Builder
interface PaymentComponentBuilder {
fun build(): PaymentComponent
}
}

@EntryPoint
@InstallIn(ActivityComponent::class)
interface PaymentComponentBuilderEntryPoint {
fun paymentComponentBuilder(): Provider<PaymentComponentBuilder>
}

@EntryPoint
@InstallIn(PaymentComponent::class)
interface PaymentApiEntryPoint {
fun paymentApi(): PaymentApi
}

A custom Component requires two things: a parent Component, which for this example is ActivityComponent; and a Builder interface to construct it. We add a new Entry Point called PaymentComponentBuilderEntryPoint which will give us access to a builder for the Component, and we change PaymentApiEntryPoint by installing it in PaymentComponent instead.

Returning to the CheckoutScreen Composable, we can acquire a PaymentComponentBuilder and build it, and then acquire a PaymentApi from the resulting PaymentComponent. As before, remember is used to avoid repeating this on every recomposition.

@Composable
fun CheckoutScreen() {
val activity = LocalContext.current as Activity
val paymentApi = remember {
val paymentComponent = EntryPointAccessors.fromActivity(
activity,
PaymentComponentBuilderEntryPoint::class.java
).paymentComponentBuilder().get().build()
EntryPoints.get(
paymentComponent,
PaymentApiEntryPoint::class.java
).paymentApi()
}
// ...
}

Now CheckoutScreen controls the lifetime of the PaymentComponent. That lifetime starts when CheckoutScreen is first composed and the Component is built inside the remember block, and that lifetime ends when CheckoutScreen leaves the composition and the remember calculation is forgotten.

As mentioned in the previous section on Entry Points, try to limit this to high-level Composables that are meant to stay in composition for longer periods of time.

Compose is revolutionizing Android UI development, and with it comes some changes to the way we look at dependency injection in Android apps. We hope these techniques and ideas will help you get the most out of both Compose and Hilt in your apps.

The code snippets in this blog have the following license:

// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0

--

--