amzn / kotlin-inject-anvil

Extensions for the kotlin-inject dependency injection framework
Apache License 2.0
271 stars 8 forks source link
dependency-injection kotlin kotlin-multiplatform

kotlin-inject-anvil

Maven Central CI Slack channel

kotlin-inject is a compile-time dependency injection framework for Kotlin Multiplatform similar to Dagger 2 for Java. Anvil extends Dagger 2 to simplify dependency injection.

This project provides a similar feature set for the kotlin-inject framework. The extensions provided by kotlin-inject-anvil allow you to contribute and automatically merge component interfaces without explicit references in code.

@ContributesTo(AppScope::class)
interface AppIdComponent {
    @Provides
    fun provideAppId(): String = "demo app"
}

@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class RealAuthenticator : Authenticator

// The final kotlin-inject component.
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
interface AppComponent

// Instantiate the component at runtime.
val component = AppComponent::class.create()

From the above example code snippet:

Setup

The project comes with a KSP plugin and a runtime module:

dependencies {
    kspCommonMainMetadata "software.amazon.lastmile.kotlin.inject.anvil:compiler:$version"
    commonMainImplementation "software.amazon.lastmile.kotlin.inject.anvil:runtime:$version"

    // Optional module, but strongly suggested to import. It contains the
    // @SingleIn scope and @ForScope qualifier annotation together with the
    // AppScope::class marker.
    commonMainImplementation "software.amazon.lastmile.kotlin.inject.anvil:runtime-optional:$version"
}

You should setup kotlin-inject as described in the official docs. For details how to setup KSP itself for multiplatform projects, see the official documentation.

Snapshot builds

To import snapshot builds use following repository:

maven {
    url 'https://aws.oss.sonatype.org/content/repositories/snapshots/'
}

Usage

Contributions

@ContributesTo

Component interfaces can be contributed using the @ContributesTo annotation:

@ContributesTo(AppScope::class)
interface AppIdComponent {
    @Provides
    fun provideAppId(): String = "demo app"
}

The scope AppScope::class tells kotlin-inject-anvil in which component to merge this interface.

@ContributesBinding

kotlin-inject requires you to write binding / provider methods in order to provide a type in the object graph. Imagine this API:

interface Authenticator

class RealAuthenticator : Authenticator

Whenever you inject Authenticator the expectation is to receive an instance of RealAuthenticator. With vanilla kotlin-inject you can achieve this with a provider method:

@Inject
@SingleIn(AppScope::class)
class RealAuthenticator : Authenticator

@ContributesTo(AppScope::class)
interface AuthenticatorComponent {
    @Provides
    fun provideAuthenticator(authenticator: RealAuthenticator): Authenticator = authenticator
}

Note that @ContributesTo is leveraged to automatically add the interface to the final component.

However, this is still too much code and can be simplified further with @ContributesBinding:

@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class RealAuthenticator : Authenticator

@ContributesBinding will generate a provider method similar to the one above and automatically add it to the final component.

Multi-bindings

@ContributesBinding supports Set multi-bindings via its multibinding parameter.

@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, multibinding = true)
class LoggingInterceptor : Interceptor

@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class AppComponent {
    // Will be contributed to this set multi-binding.
    abstract val interceptors: Set<Interceptor>
}

@ContributesSubcomponent

The @ContributesSubcomponent annotation allows you to define a subcomponent in any Gradle module, but the final @Component will be generated when the parent component is merged.

@ContributesSubcomponent(LoggedInScope::class)
@SingleIn(LoggedInScope::class)
interface RendererComponent {

    @ContributesSubcomponent.Factory(AppScope::class)
    interface Factory {
        fun createRendererComponent(): RendererComponent
    }
}

For more details on usage of the annotation and behavior see the documentation.

Merging

With kotlin-inject, components are defined similar to the one below in order to instantiate your object graph at runtime:

@Component
@SingleIn(AppScope::class)
interface AppComponent

In order to pick up all contributions, you must change the @Component annotation to @MergeComponent:

@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
interface AppComponent

This will generate a new component class with the original @Component annotation and merge all contributions to the scope AppScope.

To instantiate the component at runtime, call the generated create() function:

val component = AppComponent::class.create()

Parameters

Parameters are supported the same way as with kotlin-inject:

@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class AppComponent(
    @get:Provides val userId: String,
)

val component = AppComponent::class.create("userId")

Kotlin Multiplatform

With Kotlin Multiplatform there is a high chance that the generated code cannot be referenced from common Kotlin code or from common platform code like iosMain. This is due to how common source folders are separated from platform source folders. For more details and recommendations setting up kotlin-inject in Kotlin Multiplatform projects see the official guide.

To address this issue, you can define an expect fun in the common source code next to component class itself. The actual fun will be generated and create the component. The function must be annotated with @MergeComponent.CreateComponent. It's optional to have a receiver type of KClass with your component type as argument. The number of parameters must match the arguments of your component and the return type must be your component, e.g. your component in common code could be declared as:

@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class AppComponent(
    @get:Provides userId: String,
)

// Create this function next to your component class. The actual function will be generated.
@CreateComponent
expect fun create(appId: String): AppComponent

// Or with receiver type:
@CreateComponent
expect fun KClass<AppComponent>.create(appId: String): AppComponent

The generated actual fun will be generated and will look like this:

actual fun create(appId: String): AppComponent {
    return KotlinInjectAppComponent::class.create(appId)
}

Scopes

The plugin builds a connection between contributions and merged components through the scope parameters. Scope classes are only markers and have no further meaning besides building a connection between contributions and merging them. The class AppScope from the sample could look like this:

object AppScope

Scope classes are independent of the kotlin-inject scopes. It's still necessary to set a scope for the kotlin-inject components or to make instances a singleton in a scope, e.g.

@Inject
@SingleIn(AppScope::class) // scope for kotlin-inject
@ContributesBinding(AppScope::class)
class RealAuthenticator : Authenticator

@MergeComponent(AppScope::class)
@SingleIn(AppScope::class) // scope for kotlin-inject
interface AppComponent

kotlin-inject-anvil provides the @SingleIn scope annotation optionally by importing following module. We strongly recommend to use the annotation for consistency.

dependencies {
    commonMainImplementation "software.amazon.lastmile.kotlin.inject.anvil:runtime-optional:$version"
}

Sample

A sample project for Android and iOS is available.

Talk

The idea and more background about this library is covered in this public talk.

Advanced options

Custom symbol processors

kotlin-inject-anvil is extensible and you can create your own annotations and KSP symbol processors. In the generated code you can reference annotations from kotlin-inject-anvil itself and build logic on top of them.

For example, assume this is your annotation:

@Target(CLASS)
@ContributingAnnotation // see below for details
annotation class MyCustomAnnotation

Your custom KSP symbol processor uses this annotation as trigger and generates following code:

@ContributesTo(AppScope::class)
interface MyCustomComponent {
    @Provides
    fun provideMyCustomType(): MyCustomType = ...
}

This generated component interface MyCustomComponent will be picked up by kotlin-inject-anvil's symbol processors and contributed to the AppScope due to the @ContributesTo annotation.

Custom annotations and symbol processors are very powerful and allow you to adjust kotlin-inject-anvil to your needs and your codebase.

There are two ways to indicate these to kotlin-inject-anvil. This is important for incremental compilation and multi-round support.

  1. This is the preferred option: Annotate your annotation with the @ContributingAnnotation marker and run kotlin-inject-anvil's compiler over the project the annotation is hosted in. Adding the compiler as described in the the setup is important, otherwise the @ContributingAnnotation has no effect. With this the annotation is understood as a contributing annotation in all downstream usages of this annotation.
    @ContributingAnnotation // <--- add this!
    @Target(CLASS)
    annotation class MyCustomAnnotation
  2. Alternatively, if you don't control the annotation or otherwise cannot use option 1, you can specify custom annotations via the kotlin-inject-anvil-contributing-annotations KSP option. This option value is a colon-delimited string whose values are the canonical class names of your custom annotations.
    ksp {
      arg("kotlin-inject-anvil-contributing-annotations", "com.example.MyCustomAnnotation")
    }

Disabling processors

In some occasions the behavior of certain built-in symbol processors of kotlin-inject-anvil doesn't meet expectations or should be changed. The recommendation in this case is to disable the built-in processors and create your own. A processor can be disabled through KSP options, e.g.

ksp {
    arg("software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesBindingProcessor", "disabled")
}

The key of the option must match the fully qualified name of the symbol processor and the value must be disabled. All other values will keep the processor enabled. All built-in symbol processors are part of this package.

Security

See CONTRIBUTING for more information.

License

This project is licensed under the Apache-2.0 License.