kotlin-inject

repository·main·Indexed 23 days ago

https://github.com/evant/kotlin-inject

A compile-time dependency injection library for Kotlin that uses KSP to generate implementations of component classes. It supports constructor injection via @Inject, manual provider definitions with @Provides, scoping, qualifiers, multi-bindings, and assisted injection. The library includes specific patterns for Android platform classes and FragmentFactory integration.

Tokens
9K
Snippets
27
Records
27
Agent score
80%

What's inside kotlin-inject

  1. How kotlin-inject processes code

    main

    kotlin-inject operates like a compiler, transforming Kotlin Abstract Syntax Tree (AST) into generated Kotlin code through a multi-step pipeline. Understanding this pipeline helps in conceptualizing how dependencies are discovered and resolved.

    The pipeline follows these steps:

    1. Kotlin AST: The source code is represented as an AST. The library uses a wrapper (defined in kotlin-inject-compiler/core/Ast) to abstract over different backends like KSP.
    2. Collect Types: The TypeCollector scans @Component classes and their superclasses/interfaces to find methods that provide types. During this phase, the library validates that scope annotations are correct and ensures no type is provided multiple times.
    3. Resolve Types: The TypeResultResolver determines how to construct each type, returning a TypeResult. This process builds a Directed Acyclic Graph (DAG) of dependencies. TypeResult instances are cached to ensure shared instances are used wherever a type is required.
    4. Optimize: The TypeResultOptimizer refines the dependency graph. Currently, it identifies types with multiple parents and extracts them into private getters to optimize the generated code.
    5. Generate Code: The final Kotlin code is produced using [KotlinPoet].
    Kotlin AST -> Collect Types -> Resolve Types -> Optimize -> Generate Code
  2. Implement Component Inheritance for testing

    main

    You can define @Provides and scope annotations on an interface or abstract class that is not annotated with @Component. This allows you to create multiple implementations (e.g., RealNetworkComponent and TestNetworkComponent) and provide them to a main application component via a @Component constructor argument annotated with @Component.

    @NetworkScope
    abstract class NetworkComponent {
        @NetworkScope
        @Provides
        abstract fun api(): Api
    }
    
    @Component
    abstract class RealNetworkComponent : NetworkComponent() {
        override fun api(): Api = RealApi()
    }
    
    @Component
    abstract class TestNetworkComponent : NetworkComponent() {
        override fun api(): Api = FakeApi()
    }
    
    @Component 
    abstract class AppComponent(@Component val network: NetworkComponent)
    
    // Usage in App
    AppComponent::class.create(RealNetworkComponent::class.create())
    
    // Usage in Tests
    AppComponent::class.create(TestNetworkComponent::class.create())
  3. Differentiate same-type instances using @Qualifier

    main

    When you have multiple instances of the same type, use a @Qualifier annotation to distinguish them. Qualifiers are treated as unique types for injection. You can apply them to properties, functions, value parameters, or types.

    @Qualifier
    @Target(
      AnnotationTarget.PROPERTY_GETTER,
      AnnotationTarget.FUNCTION,
      AnnotationTarget.VALUE_PARAMETER,
      AnnotationTarget.TYPE
    )
    annotation class Named(val value: String)
    
    @Component
    abstract class MyComponent {
      @Provides
      fun dep1(): @Named("one") Dep = Dep("one")
    
      @Provides
      fun dep2(): @Named("two") Dep = Dep("two")
    
      @Provides
      fun provides(@Named("one") dep1: Dep, @Named("two") dep2: Dep): Thing = Thing(dep1, dep2)
    }
    
    @Inject
    class InjectedClass(@Named("one") dep1: Dep, @Named("two") dep2: Dep)
  4. Share dependencies between App and Test components using interfaces

    main

    When you have a mix of dependencies that should remain real (like a globalScope) and those that should be faked, extract the shared dependencies into an interface. Both your production ApplicationComponent and your TestApplicationComponent can implement this interface to ensure consistency.

    interface CommonComponent {
        @ApplicationScope
        val globalScope: CoroutineScope
            @Provides get() = CoroutineScope(Job())
    }
    
    @Component
    @ApplicationScope
    abstract class ApplicationComponent : CommonComponent
    
    @Component
    @ApplicationScope
    abstract class TestApplicationComponent(@Component val fakes: TestFakes = TestFakes()) : CommonComponent
  5. How components and dependency injection work in kotlin-inject

    main

    The core building block of kotlin-inject is a Component, which is an abstract class annotated with @Component. The library generates an implementation of this class at compile-time.

    Key Concepts:

    • Component Properties/Functions: You declare abstract properties or functions in the component to expose types. kotlin-inject will automatically resolve how to construct these types.
    • @Inject: Annotate your own classes with @Inject to tell the library to use the primary constructor for dependency injection.
    • @Provides: Use this annotation on functions or properties within a @Component to manually define how to create instances for specific types (especially for external dependencies).
    • Component Creation: Use the generated .create() extension function on the component class to instantiate the component.
    @Component
    abstract class AppComponent {
        abstract val repo: Repository
    
        @Provides
        protected fun jsonParser(): JsonParser = JsonParser()
    }
    
    @Inject
    class Repository(private val api: Api)
    
    // Usage
    val appComponent = AppComponent::class.create()
    val repo = appComponent.repo
  6. Use @KmpComponentCreate to instantiate Components in KMP

    main

    In Kotlin 2.0+, commonMain cannot see code from target source sets. If a @Component is declared in commonMain but its create() function is generated in specific target source sets (due to platform-specific bindings or expect/actual requirements), you cannot call MyComponent::class.create() directly from commonMain.

    To solve this, use the @KmpComponentCreate annotation on an expect fun. The kotlin-inject processor will automatically generate the actual fun in each target source set that calls the target's create() function.

    Basic Usage

    @Component
    abstract class MyKmpComponent
    
    @KmpComponentCreate
    expect fun createKmp(): MyKmpComponent

    Using Extension Functions for Namespacing

    You can use an extension function on the Component's Companion object to provide a cleaner API:

    @Component
    abstract class MyKmpComponent
    
    @KmpComponentCreate
    expect fun MyKmpComponent.Companion.createKmp(): MyKmpComponent
    // common source set
    @Component
    abstract class MyKmpComponent
    
    @KmpComponentCreate
    expect fun createKmp(): MyKmpComponent
    
    // each target source set
    actual fun createKmp(): MyKmpComponent = MyKmpComponent::class.create()
  7. Manage instance lifecycles with Scopes

    main

    By default, kotlin-inject creates a new instance for every injection point. To reuse an instance for the lifetime of a component, use Scopes:

    1. Define a scope annotation with @Scope.
    2. Annotate the @Component with the scope.
    3. Annotate both the @Provides methods and the @Inject classes with the same scope.
    @Scope
    @Target(CLASS, FUNCTION, PROPERTY_GETTER)
    annotation class MyScope
    
    @MyScope
    @Component
    abstract class MyComponent() {
        @MyScope
        @Provides
        protected fun provideFoo(): Foo = ...
    }
    
    @MyScope
    @Inject
    class Bar()
  8. Inject top-level functions using Type Aliases

    main

    You can inject top-level functions by annotating the function with @Inject and creating a typealias with the exact same name. You can then inject this typealias into classes or components. You can also include explicit arguments as the last parameters of the function.

    typealias myFunction = () -> Unit
    
    @Inject
    fun myFunction(dep: Dep) {
    }
    
    @Inject
    class MyClass(val myFunction: myFunction)
    
    @Component
    abstract class MyComponent {
        abstract val myFunction: myFunction
    }
    
    // With explicit args
    typealias myFunctionWithArg = (String) -> String
    
    @Inject
    fun myFunctionWithArg(dep: Dep, arg: String): String = ...
  9. Set up kotlin-inject in a Multiplatform (KMP) project

    main

    To use kotlin-inject in a Kotlin Multiplatform project, follow these steps:

    1. Plugins: Replace the kotlin-jvm plugin with org.jetbrains.kotlin.multiplatform and include com.google.devtools.ksp.
    2. Targets: Configure your desired targets (e.g., androidTarget(), iosX64(), etc.) in the kotlin block.
    3. Runtime Dependency: Add me.tatarka.inject:kotlin-inject-runtime-kmp:<version> to the commonMain dependencies. This artifact is identical to kotlin-inject-runtime but includes the @KmpComponentCreate annotation required for multiplatform component instantiation.

    Note: kotlin-inject-runtime-kmp is essential if you need to use the @KmpComponentCreate pattern.

    plugins {
        id("org.jetbrains.kotlin.multiplatform")
        id("com.google.devtools.ksp")
    }
    
    kotlin {
        androidTarget()
    
        listOf(
            iosX64(),
            iosArm64(),
            iosSimulatorArm64()
        ).forEach {
            it.binaries.framework {
                baseName = "shared"
            }
        }
    }
    
    sourceSets {
        commonMain {
            dependencies {
                implementation("me.tatarka.inject:kotlin-inject-runtime-kmp:0.8.0")
            }
        }
    }
  10. Use kotlin-inject with Jetpack Compose

    main

    You can use kotlin-inject's function injection with Compose by injecting @Composable functions. This allows you to treat UI components as injectable dependencies.

    For ViewModel usage within Compose, inject a factory function and use the viewModel { ... } delegate within the Composable to ensure proper lifecycle management.

    typealias Home = @Composable () -> Unit
    
    @Inject
    @Composable
    fun Home(repo: HomeRepository) {
         // ...
    }
    
    @Component
    abstract class ApplicationComponent() {
        abstract val home: Home
    }
    
    class MyActivity : Activity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            val home = ApplicationComponent().home
            setContent {
                home()
            }
        }
    }
    
    // Injecting ViewModels in Compose
    @Inject
    @Composable
    fun Home(homeViewModel: () -> HomeViewModel, otherViewModel: (SavedStateHandle) -> OtherViewModel) {
        val homeViewModel = viewModel { homeViewModel() }
        val otherViewModel = viewModel { otherViewModel(createSavedStateHandle()) }
        ...
    }
  11. Pass instances into components via Constructor Arguments

    main

    You can pass instances into a component by declaring them as constructor arguments. To make these arguments available to the rest of the dependency graph, annotate them with @Provides.

    To compose components, annotate a constructor argument with @Component. This makes the dependencies of the child component available to the parent, allowing you to build a hierarchical dependency graph.

    @Component
    abstract class MyComponent(@get:Provides protected val foo: Foo)
    
    // Usage
    MyComponent::class.create(Foo())
  12. Configure KSP Common Source Set for Kotlin 2.0

    main

    If you are generating code into the commonMain source set, you must explicitly tell Kotlin where the generated files are located and ensure the task dependencies are correct for KSP2.

    1. Add the generated KSP directory to commonMain.srcDir.
    2. If using KSP2, use KspAATask. If using KSP1, use KotlinCompilationTask.
    3. Ensure the kspCommonMainKotlinMetadata task is completed before other tasks depend on it.
    kotlin {
        // add your project's targets here
    
        commonMain {
            kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
        }
    }
    
    // KspAATask should be used for KSP2
    // For KSP1 use KotlinCompilationTask
    tasks.withType<KspAATask>().configureEach {
        if (name != "kspCommonMainKotlinMetadata") {
            dependsOn("kspCommonMainKotlinMetadata")
        }
    }