This project uses a combination of kotlin-inject and context receivers to implement dependency injection.
kotlin-inject is used to create a dependency graph of singleton and Activity
-scoped classes
(data layer, preferences, and algorithm configuration).
These dependencies are then provided to UI components via context receivers with the approach outlined below.
The most basic approach to provide a @Composable
method a dependency is directly passing it as
an argument:
@Composable
fun InnerComposable(
random: Random,
) {
// use random
}
@Composable
fun OuterComposable(
random: Random,
) {
InnerComposable(
random = random,
)
}
This is the most explicit form of providing a dependency, but starts to scale poorly
for the usage of dependency injection. In particular, providing a long-lived dependency to a
@Composable
method deep in the call hierarchy requires passing that dependency through all
intermediate layers. This has the downside that all intermediate layers are aware of this
dependency, even if it doesn't use it directly. For example, adding a new required dependency
requires changing the call sites and declaration sites of all intermediate layers:
@Composable
fun InnerComposable(
random: Random,
clock: Clock,
) {
// use random
}
@Composable
fun OuterComposable(
random: Random,
clock: Clock,
) {
InnerComposable(
random = random,
clock = clock,
)
}
Context receivers are
an experimental feature that allow an additional way to pass parameters to methods by adding
additional receivers to methods. These are also experimentally supported by @Composable
methods.
For example, passing the Random
from before as a context receiver can be done like the following.
context(Random)
@Composable
fun InnerComposable() {
// use methods and properties in Random
}
context(Random)
@Composable
fun OuterComposable() {
InnerComposable() // Random is passed as a context receiver
}
This is an improvement, in that dependencies are passed down more transparently, and don't impact the intermediate call sites.
However, each additional dependency still requires changing the declaration site:
context(Random, Clock)
@Composable
fun InnerComposable() {
// use methods and properties in Random and Clock
}
context(Random, Clock)
@Composable
fun OuterComposable() {
InnerComposable() // Random and Clock are passed as context receivers
}
One addition issue is that Random
and Clock
are both implicit this
targets throughout the
call hierarchy, meaning that all layers have properties and methods of each dependency polluting
the call space, potentially resulting in erroneous methods or introducing unclear usages of
dependencies.
We can make one refinement to this second issue first.
Instead of using the dependency directly as a context receiver, we can define a canonical Provider
interface for a dependency injected in this manner:
interface RandomProvider {
val random: Random
}
We can then use that provider interface as the context receiver:
context(RandomProvider)
@Composable
fun InnerComposable() {
random // from RandomProvider scope
}
context(RandomProvider)
@Composable
fun OuterComposable() {
InnerComposable() // RandomProvider passed as context receiver
}
Each dependency would then have its own provider interface declared:
interface ClockProvider {
val clock: Clock
}
context(RandomProvider, ClockProvider)
@Composable
fun InnerComposable() {
random // from RandomProvider scope
clock // from ClockProvider scope
}
context(RandomProvider, ClockProvider)
@Composable
fun OuterComposable() {
InnerComposable() // RandomProvider and ClockProvider are passed as context receivers
}
Having a canonical provider interface for each dependency means that multiple components can
use the same interface type to request being injected with a particular dependency. Additionally,
using a provider interface specific to each dependency type (as opposed to using the Dagger
Provider<T>
interface) keeps the naming to retrieve each dependency.
This avoids polluting the call-space too much, as only the dependencies themselves are available. However, they are still available throughout the entire call stack, and the declaration of each component in all layers has to change to inject a new dependency.
The next step is to collect these provider into a combined super-interface for each component.
I've termed these "entry-point" interfaces for a component, and are the name of the component
suffixed by EntryPoint
. Each component that is capable of being injected gets one of these entry
point interfaces:
interface InnerComposableEntryPoint : RandomProvider
context(InnerComposableEntryPoint)
@Composable
fun InnerComposable() {
random // from inherited RandomProvider scope
}
interface OuterComposableEntryPoint : InnerComposableEntryPoint
context(OuterComposableEntryPoint)
@Composable
fun OuterComposable() {
InnerComposable() // InnerComposableEntryPoint passed as context receiver
}
This does not change the call-site usage at all, since the entry-point component interfaces extend from the provider interfaces (meaning that the same properties of the provider interfaces are still in scope).
However, this does change the declaration site: Now, each component only declares a combination of its direct dependencies and any subcomponents it may call. This localizes the requirement of knowing about dependencies to a particular component, and breaks the requirement that every intermediate component changes in some way due to a new dependency introduced:
interface InnerComposableEntryPoint : RandomProvider, ClockProvider
context(InnerComposableEntryPoint)
@Composable
fun InnerComposable() {
random // from inherited RandomProvider scope
clock // from inherited ClockProvider scope
}
interface OuterComposableEntryPoint : InnerComposableEntryPoint
context(OuterComposableEntryPoint)
@Composable
fun OuterComposable() {
InnerComposable() // InnerComposableEntryPoint passed as context receiver
}
Dependencies are still available in scope throughout the entire call stack, if a subcomponent is depending on it. This is unfortunate, but is more a problem of explict-ness and convention instead of correctness. This should be fixable with a lint check of some sort, where each component should only use dependencies that are from directly inherited interfaces, and not the transitively inherited interfaces.
The final step is providing the actual implementations for the entry points.
The project here creates a distinction between two types of dependencies:
- Injected,
Activity
-scoped dependencies - Local scoped dependencies defined by Compose
These two sets of dependencies are then provided by two different entry points for each component:
interface InnerComposableInjectEntryPoint : RandomProvider, ClockProvider
interface InnerComposableLocalEntryPoint : LoadedComposeLifePreferencesProvider
context(InnerComposableInjectEntryPoint, InnerComposableLocalEntryPoint)
@Composable
fun InnerComposable() {
random // from inherited RandomProvider scope
clock // from inherited ClockProvider scope
preferences // from inherited LoadedComposeLifePreferencesProvider scope
}
interface OuterComposableInjectEntryPoint :
InnerComposableInjectEntryPoint,
ComposeLifePreferencesProvider
context(OuterComposableInjectEntryPoint)
@Composable
fun OuterComposable() {
val loadedPreferencesState = composeLifePreferences.loadedPreferencesState
when (loadedPreferencesState) {
is ResourceState.Failure -> {
// Error screen
}
ResourceState.Loading -> {
CircularProgressIndicator()
}
is ResourceState.Success -> {
with(
remember(loadedPreferencesState) {
object : InnerComposableLocalEntryPoint {
override val preferences = loadedPreferencesState
}
}
) {
// InnerComposableInjectEntryPoint passed as context receiver
// InnerComposableLocalEntryPoint created in-scope
InnerComposable()
}
}
}
}
With the approach above, the scope of local entry points is governed by normal Compose state mechanisms, where the entry points are created and remembered with snapshot state.
This allows, as in the example above, creating a type-safe subcomponent where loaded preferences are available, with the loading state handled by a higher-level component.
For @Preview
s, each of these entry points can also be implemented directly with appropriate
fakes or mock values, as in ui-app
's
PreviewEntryPoint
.