Dependency Injection in Compose

[ad_1]

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.

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.

[ad_2]

Source link


Leave a Reply

Your email address will not be published. Required fields are marked *