Intro
If you’ve used Anvil before, you know it takes away a lot of the boilerplate code and makes DI seamless. If Anvil is new to you, it basically allows you to contribute dagger modules and component interfaces to your DI graph, merge all the contributions, and add them to your component during compilation. Ralf Wonderatschek and Gabriel Peal gave an in-depth talk about this. Dagger + Anvil: Learning to Love Dependency Injection.. You should check it out.
I have been using kotlin-inject on my pet project for a while now and I have had a good time with it coming from using Dagger in other projects. One thing I missed was using Anvil. This was not availalbe until recently. kotlin-inject-anvil joined the chat.
This article will focus on my expericence and journey integrating/migrating to kotlin-inject-anvil into the project.
If you’d like to see the code, here’s the pull request.
Koltlin-Inject-Anvil Integration
Before integrating kotlin-inject-anvil, one thing that bothered me was how to approach the integration/migration. I thought the process would be a pain as I already have multiple modules in my project. Do I rip the bandaid off and do it all at once? Is it possible to do it gradually? Spoiler alert: it is possible to do it gradually. This approach might not work for your project, depending on the size of the team. There are multiple ways of doing this, but this worked for me. This approach made it easier to determine if I broke the current implementation or introduced new errors.
Hereโs a quick overview of how I approached the migration.
- Add dependencies
- Apply
@ContributesTo
annotation - Apply
@ContributesBinding
annotation - Add ksp kotlin-inject-anvil compiler dependencies.
- Delete component interfaces.
- Replace
@Component
with@MergeComponent
and create subcomponent.
Let’s take a quick look at how each of these steps is implemented.
Add kotin-inject-anvil Dependencies.
This is pretty straightforward. We need to add the dependencies to our project.
kotlinInject-anvil-compiler = { group = "software.amazon.lastmile.kotlin.inject.anvil", name = "compiler", version.ref = "kotlin-inject-anvil" }
kotlinInject-anvil-runtime = { group = "software.amazon.lastmile.kotlin.inject.anvil", name = "runtime", version.ref = "kotlin-inject-anvil" }
kotlinInject-anvil-runtime-optional = { group = "software.amazon.lastmile.kotlin.inject.anvil", name = "runtime-optional", version.ref = "kotlin-inject-anvil" }
kotlinInject-anvil-runtime-optional
is optional, and your project would work without it. I added it so I can get rid of my custom scope and use
kotlin-inject-anvil’s scopes to keep everything consistent.
To make things easier, I created a bundle with kotlin-inject dependencies, and I use that instead.
[bundles]
kotlinInject = [
"kotlinInject-runtime",
"kotlinInject-anvil-runtime",
"kotlinInject-anvil-runtime-optional"
]
We can then add it to our module like so. implementation(libs.bundles.kotlinInject)
Add @ContributesTo
Annotation
We can now annotate our interface components with @ContributesTo
. I also replaced my custom scope with kotlin-inject-anvil scope:
@ApplicationScope
-> @SingleIn(AppScope::class)
. As I mentioned, this is optional and will work with your custom scopes. Here’s how the component looks.
Before
interface CastComponent {
@Provides
@ApplicationScope
fun provideCastDao(bind: DefaultCastDao): CastDao = bind
@Provides
@ApplicationScope
fun provideCastRepository(bind: DefaultCastRepository): CastRepository = bind
}
After
@ContributesTo(AppScope::class)
interface CastComponent {
@Provides
@SingleIn(AppScope::class)
fun provideCastDao(bind: DefaultCastDao): CastDao = bind
@Provides
@SingleIn(AppScope::class)
fun provideCastRepository(bind: DefaultCastRepository): CastRepository = bind
}
One small thing I did later was move the @SingleIn
annotation to the class instead of having it in the binding functions.
Add @ContributesBinding
Annotation
The next thing we can do is annotate all classes that have interface implementations with @ContributesBinding
. Once we’ve plugged everything in, Anvil will provide the bindings for us, and we can get rid of the component above with the manual binding.
Before
@Inject
class DefaultCastRepository(
private val dao: CastDao,
) : CastRepository {
...
}
After
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCastRepository(
private val dao: CastDao,
) : CastRepository {
...
}
Add KSP Dependencies.
To check if the changes we’ve done work as intended, we can add the Kotlin inject Anvil compiler dependency, which will generate the component classes.
addKspDependencyForAllTargets(libs.kotlinInject.anvil.compiler)
. addKspDependencyForAllTargets
is an extension function that creates KSP configurations for each target. e.g kspAndroid
kspIosArm64
We can build our app and take a look at the generated code.
Anvil will generate the bindings for us similarly to what we had above. This will be generated for all our classes annotated with @ContributesBinding(AppScope::class)
.
@Origin(value = DefaultCastRepository::class)
public interface ComThomaskiokoTvmaniacDataCastImplementationDefaultCastRepository {
@Provides
public
fun provideDefaultCastRepositoryCastRepository(defaultCastRepository: DefaultCastRepository):
CastRepository = defaultCastRepository
}
Delete Manual Bindings.
Now that our bindings and components are being generated, we can delete our component interfaces with provider functions. In my previous implementation, each module was responsible for creating its own DI component. The shared module then added all these SuperType Components to the parent/final component for each platform component. This is a bit painful and can easily get out of hand as your project grows ๐ฎโ๐จ
Thanks to kotlin-inject-anvil, we can get rid of these as they are now generated for us once we add the merge annotation. ๐ฅณ
Final Boss: @MergeComponent
Annotation
@ContributesSubcomponent
Annotation
Since we can only have one component annotated with @MergeComponent
, we need to annotate ActivityComponent to @ContributesSubcomponent
, create a
factory that our parent scope will implement.
Before
@SingleIn(ActivityScope::class)
@Component
abstract class ActivityComponent(
@get:Provides val activity: ComponentActivity,
@get:Provides val componentContext: ComponentContext = activity.defaultComponentContext(),
@Component
val applicationComponent: ApplicationComponent =
ApplicationComponent.create(activity.application),
) : NavigatorComponent, TraktAuthAndroidComponent {
abstract val traktAuthManager: TraktAuthManager
abstract val rootPresenter: RootPresenter
companion object
}
After
You should note that we converted our abstract class to an interface, as only interfaces can be annotated with contributed @ContributesSubcomponent
.
For more details on annotation usage and behavior, see the documentation.
@ContributesSubcomponent(ActivityScope::class)
@SingleIn(ActivityScope::class)
interface ActivityComponent {
@Provides
fun provideComponentContext(
activity: ComponentActivity
): ComponentContext = activity.defaultComponentContext()
val traktAuthManager: TraktAuthManager
val rootPresenter: RootPresenter
@ContributesSubcomponent.Factory(AppScope::class)
interface Factory {
fun createComponent(
activity: ComponentActivity
): ActivityComponent
}
}
@MergeComponent
Annotation
To create our graph and our components to our graph, we need to replace kotlin-injects
@Component
with kotlin-inject-anvil
@MergeComponent
and
get rid of the SharedComponent
.
Before
@Component
@SingleIn(AppScope::class)
abstract class ApplicationComponent(
@get:Provides val application: Application,
) : SharedComponent() {
abstract val initializers: AppInitializers
companion object
}
After
I added annotation, removed the supertype from the application component, and added ActivityComponent.Factory
.
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class ApplicationComponent(
@get:Provides val application: Application,
) : ActivityComponent.Factory {
abstract val initializers: AppInitializers
abstract val activityComponentFactory: ActivityComponent.Factory
}
Now, if we look at the generated code, we can see that Anvil adds all the generated components to our graph when we compile the app.
If you forget to delete any provide functions, you will get the following error at compile time.
e: [ksp] Cannot provide: com.thomaskioko.tvmaniac.data.cast.api.CastDao
e: [ksp] as it is already provided
This is expected; you can track down the duplicate provide method and delete it.
Conclusion
With this in place, we have now gotten rid of manual bindings, replacing them with @ContributesTo
and @ContributesBinding
.
We also deleted our god component class and got rid of a lot of boilerplate, thanks to Anvil.
@Ralf and all the contributors have done a fantastic job with kotlin-inject-anvil. The integration was smooth. I’m looking forward to how these libraries evolve. (Maybe it should be renamed to KiAnvil. Get it? You know, like Keanu, because of how lethal it feels? No? ๐ Don’t worry, I will see myself out.)
Thanks, @Ralf for reviewing the article. Until we meet again, folks. Happy coding! โ๏ธ
References
- Dagger + Anvil: Learning to Love Dependency Injection..
- KSP with Kotlin Multiplatform
- Kotlin Inject Anvil README