DI Framework¶
Note
App Platform provides support for kotlin-inject-anvil and Metro as dependency injection framework. You can choose which one to use and even mix them if needed. Both frameworks are compile-time injection frameworks and ready for Kotlin Multiplatform (Metro still runs into issues). They verify correctness of the object graph at build time and avoid crashes at runtime.
Enabling dependency injection is an opt-in feature through the Gradle DSL. The default value is false
.
appPlatform {
enableKotlinInject true
enableMetro true
}
Tip
Consider taking a look at the kotlin-inject-anvil documentation or
Metro documentation first. App Platform makes heavy use the of
@ContributesBinding
and @ContributesTo
annotations to decompose and assemble components / object graphs.
kotlin-inject-anvil¶
Note
kotlin-inject-anvil
is an opt-in feature through the Gradle DSL. The default value is false
.
appPlatform {
enableKotlinInject true
}
Component¶
Components are added as a service to the Scope
class and can be obtained using the kotlinInjectComponent()
extension
function:
scope.kotlinInjectComponent<AppComponent>()
In modularized projects, final components are defined in the :app
modules, because the object graph has to
know about all features of the app. It is strongly recommended to create a component in each platform specific
folder to provide platform specific types.
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
abstract class AndroidAppComponent(
@get:Provides val application: Application,
@get:Provides val rootScopeProvider: RootScopeProvider,
)
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
abstract class IosAppComponent(
@get:Provides val uiApplication: UIApplication,
@get:Provides val rootScopeProvider: RootScopeProvider,
)
@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
abstract class DesktopAppComponent(
@get:Provides val rootScopeProvider: RootScopeProvider
)
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class WasmJsAppComponent(
@get:Provides val rootScopeProvider: RootScopeProvider
)
Platform implementations¶
kotlin-inject-anvil
makes it simple to provide platform specific implementations for abstract APIs without needing
to use expect / actual
declarations or any specific wiring. Since the final components live in the platform specific
source folders, all contributions for a platform are automatically picked up. Platform specific implementations can
use and inject types from the platform.
interface LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AndroidLocationProvider(
val application: Application,
) : LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class IosLocationProvider(
val uiApplication: UIApplication,
) : LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DesktopLocationProvider(
...
) : LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class WasmLocationProvider(
...
) : LocationProvider
Other common code within commonMain
can safely inject and use LocationProvider
.
Injecting dependencies¶
It’s recommended to rely on constructor injection as much as possible, because it removes boilerplate and makes
testing easier. But it some cases it’s required to get a dependency from a component where constructor injection
is not possible, e.g. in a static context or types created by the platform. In this case a contributed component
interface with access to the Scope
help:
class MainActivityViewModel(application: Application) : AndroidViewModel(application) {
private val component = (application as RootScopeProvider).rootScope.kotlinInjectComponent<Component>()
private val templateProvider = component.templateProviderFactory.createTemplateProvider()
@ContributesTo(AppScope::class)
interface Component {
val templateProviderFactory: TemplateProvider.Factory
}
}
This sample shows an Android ViewModel
that doesn’t use constructor injection. Instead, the Scope
is retrieved
from the Application
class and the kotlin-inject-anvil
component is found through the kotlinInjectComponent()
function.
Sample
The ViewModel
example comes from the sample app.
ViewModels
can use constructor injection, but this requires more setup. This approach of using a component
interface was simpler and faster.
Another example where this approach is handy is in NavigationPresenterImpl
.
This class waits for the user scope to be available and then optionally retrieves the Presenter
that is part
of the user component. Constructor injection cannot be used, because NavigationPresenterImpl
is part of the app
scope and cannot inject dependencies from the user scope, which is a child scope of app scope. This would violate
dependency inversion rules.
@ContributesTo(UserScope::class)
interface UserComponent {
val userPresenter: UserPagePresenter
}
@Composable
override fun present(input: Unit): BaseModel {
val scope = getUserScope()
if (scope == null) {
// If no user is logged in, then show the logged in screen.
val presenter = remember { loginPresenter() }
return presenter.present(Unit)
}
// A user is logged in. Use the user component to get an instance of UserPagePresenter, which is only
// part of the user scope.
val userPresenter = remember(scope) { scope.kotlinInjectComponent<UserComponent>().userPresenter }
return userPresenter.present(Unit)
}
Default bindings¶
App Platform provides a few defaults that can be injected, including a CoroutineScope
and CoroutineDispatchers
.
@Inject
class SampleClass(
@ForScope(AppScope::class) appScope: CoroutineScope,
@IoCoroutineDispatcher ioDispatcher: CoroutineDispatcher,
@DefaultCoroutineDispatcher defaultDispatcher: CoroutineDispatcher,
@MainCoroutineDispatcher mainDispatcher: CoroutineDispatcher,
)
CoroutineScope
The CoroutineScope
uses the IO dispatcher by default. The qualifier @ForScope(AppScope::class)
is needed to
allow other scopes to have their own CoroutineScope
. For example, the sample app provides a CoroutineScope
for the user scope,
which gets canceled when the user scope gets destroyed. The CoroutineScope
for the user scope uses the qualifier
`@ForScope(UserScope::class)
/**
* Provides the [CoroutineScopeScoped] for the user scope. This is a single instance for the user
* scope.
*/
@Provides
@SingleIn(UserScope::class)
@ForScope(UserScope::class)
fun provideUserScopeCoroutineScopeScoped(
@IoCoroutineDispatcher dispatcher: CoroutineDispatcher
): CoroutineScopeScoped {
return CoroutineScopeScoped(dispatcher + SupervisorJob() + CoroutineName("UserScope"))
}
/**
* Provides the [CoroutineScope] for the user scope. A new child scope is created every time an
* instance is injected so that the parent cannot be canceled accidentally.
*/
@Provides
@ForScope(UserScope::class)
fun provideUserCoroutineScope(
@ForScope(UserScope::class) userScopeCoroutineScopeScoped: CoroutineScopeScoped
): CoroutineScope {
return userScopeCoroutineScopeScoped.createChild()
}
CoroutineDispatcher
It’s recommended to inject CoroutineDispatcher
through the constructor instead of using Dispatcher.*
. This
allows to easily swap them within unit tests to remove concurrency and improve stability.
Metro¶
Note
Metro is an opt-in feature through the Gradle DSL. The default value is false
.
appPlatform {
enableMetro true
}
Bug
There are several bugs and issues related to Metro and the integration is considered experimental until these problems are resolved and Metro itself becomes stable. More details are listed in the bugs section.
Dependency graph¶
Dependency graphs are added as a service to the Scope
class and can be obtained using the metroDependencyGraph()
extension function:
scope.metroDependencyGraph<AppGraph>()
In modularized projects, final graphs are defined in the :app
modules, because the object graph has to
know about all features of the app. It is strongly recommended to create an object graph in each platform specific
folder to provide platform specific types.
@DependencyGraph(AppScope::class)
interface AndroidAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(
@Provides application: Application,
@Provides rootScopeProvider: RootScopeProvider,
): AndroidAppGraph
}
}
@DependencyGraph(AppScope::class)
interface IosAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(
@Provides uiApplication: UIApplication,
@Provides rootScopeProvider: RootScopeProvider,
): IosAppGraph
}
}
@DependencyGraph(AppScope::class)
interface DesktopAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides rootScopeProvider: RootScopeProvider): DesktopAppGraph
}
}
@DependencyGraph(AppScope::class)
interface WasmJsAppGraph {
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides rootScopeProvider: RootScopeProvider): WasmJsAppGraph
}
}
Platform implementations¶
Metro makes it simple to provide platform specific implementations for abstract APIs without needing
to use expect / actual
declarations or any specific wiring. Since the final object graphs live in the platform
specific source folders, all contributions for a platform are automatically picked up. Platform specific
implementations can use and inject types from the platform.
interface LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AndroidLocationProvider(
val application: Application,
) : LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class IosLocationProvider(
val uiApplication: UIApplication,
) : LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DesktopLocationProvider(
...
) : LocationProvider
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class WasmLocationProvider(
...
) : LocationProvider
Other common code within commonMain
can safely inject and use LocationProvider
.
Injecting dependencies¶
It’s recommended to rely on constructor injection as much as possible, because it removes boilerplate and makes
testing easier. But it some cases it’s required to get a dependency from an object graph where constructor injection
is not possible, e.g. in a static context or types created by the platform. In this case a contributed object graph
interface with access to the Scope
help:
class MainActivityViewModel(application: Application) : AndroidViewModel(application) {
private val graph = (application as RootScopeProvider).rootScope.metroDependencyGraph<Graph>()
private val templateProvider = graph.templateProviderFactory.createTemplateProvider()
@ContributesTo(AppScope::class)
interface Graph {
val templateProviderFactory: TemplateProvider.Factory
}
}
This sample shows an Android ViewModel
that doesn’t use constructor injection. Instead, the Scope
is retrieved
from the Application
class and the Metro object graph is found through the metroDependencyGraph()
function.
Sample
The ViewModel
example comes from the sample app.
ViewModels
can use constructor injection, but this requires more setup. This approach of using a graph
interface was simpler and faster.
Another example where this approach is handy is in NavigationPresenterImpl
.
This class waits for the user scope to be available and then optionally retrieves the Presenter
that is part
of the user graph. Constructor injection cannot be used, because NavigationPresenterImpl
is part of the app
scope and cannot inject dependencies from the user scope, which is a child scope of app scope. This would violate
dependency inversion rules.
@ContributesTo(UserScope::class)
interface UserGraph {
val userPresenter: UserPagePresenter
}
@Composable
override fun present(input: Unit): BaseModel {
val scope = getUserScope()
if (scope == null) {
// If no user is logged in, then show the logged in screen.
val presenter = remember { loginPresenter() }
return presenter.present(Unit)
}
// A user is logged in. Use the user graph to get an instance of UserPagePresenter, which is only
// part of the user scope.
val userPresenter = remember(scope) { scope.metroDependencyGraph<UserGraph>().userPresenter }
return userPresenter.present(Unit)
}
Default bindings¶
App Platform provides a few defaults that can be injected, including a CoroutineScope
and CoroutineDispatchers
.
@Inject
class SampleClass(
@ForScope(AppScope::class) appScope: CoroutineScope,
@IoCoroutineDispatcher ioDispatcher: CoroutineDispatcher,
@DefaultCoroutineDispatcher defaultDispatcher: CoroutineDispatcher,
@MainCoroutineDispatcher mainDispatcher: CoroutineDispatcher,
)
CoroutineScope
The CoroutineScope
uses the IO dispatcher by default. The qualifier @ForScope(AppScope::class)
is needed to
allow other scopes to have their own CoroutineScope
. For example, the sample app provides a CoroutineScope
for the user scope,
which gets canceled when the user scope gets destroyed. The CoroutineScope
for the user scope uses the qualifier
`@ForScope(UserScope::class)
/**
* Provides the [CoroutineScopeScoped] for the user scope. This is a single instance for the user
* scope.
*/
@Provides
@SingleIn(UserScope::class)
@ForScope(UserScope::class)
fun provideUserScopeCoroutineScopeScoped(
@IoCoroutineDispatcher dispatcher: CoroutineDispatcher
): CoroutineScopeScoped {
return CoroutineScopeScoped(dispatcher + SupervisorJob() + CoroutineName("UserScope"))
}
/**
* Provides the [CoroutineScope] for the user scope. A new child scope is created every time an
* instance is injected so that the parent cannot be canceled accidentally.
*/
@Provides
@ForScope(UserScope::class)
fun provideUserCoroutineScope(
@ForScope(UserScope::class) userScopeCoroutineScopeScoped: CoroutineScopeScoped
): CoroutineScope {
return userScopeCoroutineScopeScoped.createChild()
}
CoroutineDispatcher
It’s recommended to inject CoroutineDispatcher
through the constructor instead of using Dispatcher.*
. This
allows to easily swap them within unit tests to remove concurrency and improve stability.
@ContriubtesScoped
¶
Warning
This is different between kotlin-inject-anvil
and Metro. In kotlin-inject-anvil
we repurpose the
@ContributesBinding
annotation to make it understand the semantics for the Scoped
interface and generate custom
code using a custom code generator. Metro doesn’t support this kind of integration and therefore we had to
introduce @ContributesScoped
for a similar usage.
The Scoped
interface is used to notify implementations when a Scope
gets created and destroyed.
class AndroidLocationProvider : LocationProvider, Scoped {
override fun onEnterScope(scope: Scope) {
...
}
override fun onExitScope() {
...
}
}
AndroidLocationProvider
needs to be bound to the super type LocationProvider
and use
multi-bindings for the Scoped
interface. This is a lot of boilerplate to write that be auto-generated using
@ContributesScoped
instead. When using @ContributesScoped
, all bindings are generated and @ContributesBinding
doesn’t need to be added. A typical implementation looks like this:
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AndroidLocationProvider : LocationProvider, Scoped
See the documentation for Scoped
for more details.
Bugs¶
Metro is in an early stage and there are several bugs blocking a full roll out.
No full KMP support¶
Metro is ready to support KMP, but targets other than JVM/Android fail to merge types contributed with
@ContributesTo
and @ContributesBinding
. App Platform makes heavy use of them. This is called out in the
Metro documentation. There is a chance
this will be fixed in Kotlin 2.3.
There is one issue in the repo right now where the compiler appears to have a bug with generated FIR declarations where it doesn’t deserialize them correctly on non-JVM targets. Waiting for feedback from JB.
Incremental compilation issues¶
While testing Metro in App Platform, we encountered incremental compilation issues that impacted merging components and generated wrong code. This ticket is metro/997.
Other IC issues are reported under KT-75865.
exclude
/ replaces
not support¶
kotlin-inject-anvil
allows you to exclude and replace contribution that use custom annotations like
@ContributesRenderer
. Metro is missing this feature and the generated files have to be referenced instead. The
ticket is metro/1020.
Missing integrations¶
Almost all App Platform specific custom extensions for kotlin-inject-anvil
were migrated to Metro, including
@ContributesRenderer
and @ContributesRobot
. However the integration for @ContributesRealImpl
and
@ContributesMockImpl
is missing and still needs to be ported.
Migration¶
Metro and kotlin-inject-anvil
are conceptionally very similar. A migration is mostly mechanical. Errors will be
reported at compile time and not runtime.
Steps could like this. PR/129 highlights this migration for the
:sample
application.
- It’s strongly recommended to use the latest Kotlin and Metro version. Metro is a compiler plugin and tied to the compiler to a certain degree.
- Enable Metro in the Gradle DSL:
appPlatform { enableMetro true }
- Change kotlin-inject specific imports to Metro:
me.tatarka.inject.annotations.IntoSet -> dev.zacsweers.metro.IntoSet me.tatarka.inject.annotations.Provides -> dev.zacsweers.metro.Provides software.amazon.lastmile.kotlin.inject.anvil.AppScope -> dev.zacsweers.metro.AppScope software.amazon.lastmile.kotlin.inject.anvil.ContributesTo -> dev.zacsweers.metro.ContributesTo software.amazon.lastmile.kotlin.inject.anvil.ForScope -> dev.zacsweers.metro.ForScope software.amazon.lastmile.kotlin.inject.anvil.SingleIn -> dev.zacsweers.metro.SingleIn
- Update the final kotlin-inject components to Metro. The Metro docs explain the API very well. E.g. this component had to adopt a factory:
// Old: @Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) abstract class DesktopAppComponent(@get:Provides val rootScopeProvider: RootScopeProvider) : DesktopAppComponentMerged // New: @DependencyGraph(AppScope::class) interface DesktopAppComponent { @DependencyGraph.Factory fun interface Factory { fun create(@Provides rootScopeProvider: RootScopeProvider): DesktopAppComponent } }
- Change usages of
addKotlinInjectComponent()
toaddMetroDependencyGraph()
and usages ofkotlinInjectComponent()
tometroDependencyGraph()
.
kotlin-inject-anvil
or Metro¶
Given the issues highlighted with Metro, it is strongly advised to use kotlin-inject-anvil
for
production and Metro only for experiments.