Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Extending kotlin-inject for fun & profit

Ralf
September 20, 2024

Extending kotlin-inject for fun & profit

Kotlin-inject is a dependency injection framework for Kotlin Multiplatform. It verifies the dependency graph during compilaton and generates the necessary code to instantiate the object graph at runtime. It offers a similar feature set as Dagger 2 without the limitation of being tied to the JVM and Android only.

This talk will offer a short introduction to kotlin-inject and discuss its benefits and downsides and how the framework scales in large, modularized code bases. To close some of the gaps and solve project specific use cases KSP and custom code generators will be used to extend the framework and to give us features similar to the ones Anvil provides for Dagger 2. These practical examples serve as an introduction and blueprint into the meta-programming world to reduce boilerplate and simplify patterns in your code base.

Ralf

September 20, 2024
Tweet

More Decks by Ralf

Other Decks in Technology

Transcript

  1. Background • We like Dagger 2 for its many benefits

    • We like Anvil for its simplicity • But both don’t work with KMP in common code
  2. Dagger 2 interface WeatherRepository { fun provideForecast(location: Location): Forecast }

    class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository { ... }
  3. Dagger 2 interface WeatherRepository { fun provideForecast(location: Location): Forecast }

    class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository { ... } @Module object WeatherRepositoryModule { @Provides @Singleton fun provideWeatherRepository(client: HttpClient): WeatherRepository { return WeatherRepositoryImpl(client) } }
  4. Dagger 2 interface WeatherRepository { fun provideForecast(location: Location): Forecast }

    class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository { ... } @Module object WeatherRepositoryModule { @Provides @Singleton fun provideWeatherRepository(client: HttpClient): WeatherRepository { return WeatherRepositoryImpl(client) } }
  5. Dagger 2 interface WeatherRepository { fun provideForecast(location: Location): Forecast }

    class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository { ... } @Module object WeatherRepositoryModule { @Provides @Singleton fun provideWeatherRepository(client: HttpClient): WeatherRepository { return WeatherRepositoryImpl(client) } }
  6. Dagger 2 @Singleton @Component( modules = [ WeatherRepositoryModule::class, ] )

    interface AppComponent val appComponent = DaggerAppComponent.create()
  7. Dagger 2 @Singleton @Component( modules = [ WeatherRepositoryModule::class, ] )

    interface AppComponent { fun weatherRepository(): WeatherRepository } val appComponent = DaggerAppComponent.create() val weatherRepository = appComponent.weatherRepository()
  8. Dagger 2 @Singleton @Component( modules = [ WeatherRepositoryModule::class, ] )

    interface AppComponent { fun weatherRepository(): WeatherRepository } val appComponent = DaggerAppComponent.create() val weatherRepository = appComponent.weatherRepository()
  9. Dagger 2 class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository {

    ... } @Module object WeatherRepositoryModule { @Provides @Singleton fun provideWeatherRepository(client: HttpClient): WeatherRepository { return WeatherRepositoryImpl(client) } }
  10. Dagger 2 @Singleton class WeatherRepositoryImpl @Inject constructor( client: HttpClient, )

    : WeatherRepository @Module object WeatherRepositoryModule { @Provides @Singleton fun provideWeatherRepository(client: HttpClient): WeatherRepository { return WeatherRepositoryImpl(client) } }
  11. Dagger 2 @Singleton class WeatherRepositoryImpl @Inject constructor( client: HttpClient, )

    : WeatherRepository @Module object WeatherRepositoryModule { @Provides @Singleton fun provideWeatherRepository(client: HttpClient): WeatherRepository { return WeatherRepositoryImpl(client) } }
  12. Dagger 2 @Singleton class WeatherRepositoryImpl @Inject constructor( client: HttpClient, )

    : WeatherRepository @Module interface WeatherRepositoryModule { @Binds fun bindWeatherRepository( weatherRepositoryImpl: WeatherRepositoryImpl ): WeatherRepository }
  13. Dagger 2 @Singleton @Component( modules = [ WeatherRepositoryModule::class, ] )

    interface AppComponent { fun weatherRepository(): WeatherRepository }
  14. Dagger 2 interface WeatherComponent { fun weatherRepository(): WeatherRepository } @Singleton

    @Component( modules = [ WeatherRepositoryModule::class, ] ) interface AppComponent : WeatherComponent
  15. Dagger 2 @Singleton class WeatherRepositoryImpl @Inject constructor( client: HttpClient, )

    : WeatherRepository @Module interface WeatherRepositoryModule { @Binds fun bindWeatherRepository( weatherRepositoryImpl: WeatherRepositoryImpl ): WeatherRepository }
  16. Anvil @Singleton class WeatherRepositoryImpl @Inject constructor( client: HttpClient, ) :

    WeatherRepository @Module @ContributesTo(AppScope::class) interface WeatherRepositoryModule { @Binds fun bindWeatherRepository( weatherRepositoryImpl: WeatherRepositoryImpl ): WeatherRepository }
  17. Anvil @Singleton @ContributesBinding(AppScope::class) class WeatherRepositoryImpl @Inject constructor( client: HttpClient, )

    : WeatherRepository @Module @ContributesTo(AppScope::class) interface WeatherRepositoryModule { @Binds fun bindWeatherRepository( weatherRepositoryImpl: WeatherRepositoryImpl ): WeatherRepository }
  18. Anvil - Other benefits • Removes boilerplate • Aligns build

    graph with Gradle dependency graph • Improves build times significantly • Easy to replace bindings in tests • Extensible with custom code generators
  19. kotlin-inject • Compile-time dependency injection framework for Kotlin • Architecture

    very similar to Dagger 2 • Similar feature set as Dagger 2: Provider methods, components, scopes, qualifiers, multi-bindings, assisted injection, lazy injection, … • Unique features: KMP support, uses KSP, no modules, no binding methods, override provider functions, function support, default arguments, …
  20. Dagger 2 @Singleton class WeatherRepositoryImpl @Inject constructor( client: HttpClient, )

    : WeatherRepository @Module interface WeatherRepositoryModule { @Binds fun bindWeatherRepository( weatherRepositoryImpl: WeatherRepositoryImpl ): WeatherRepository }
  21. kotlin-inject @Singleton @Inject class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository

    @Module interface WeatherRepositoryModule { @Binds fun bindWeatherRepository( weatherRepositoryImpl: WeatherRepositoryImpl ): WeatherRepository }
  22. kotlin-inject @Singleton @Inject class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository

    @Module interface WeatherRepositoryModule { @Binds fun bindWeatherRepository( weatherRepositoryImpl: WeatherRepositoryImpl ): WeatherRepository }
  23. kotlin-inject @Singleton @Inject class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository

    interface WeatherRepositoryComponent { @Provides fun provideWeatherRepository( weatherRepository: WeatherRepositoryImpl ): WeatherRepository = weatherRepository }
  24. Dagger 2 @Singleton @Component( modules = [ WeatherRepositoryModule::class, ] )

    interface AppComponent val appComponent = DaggerAppComponent.create()
  25. kotlin-inject @Singleton @Component interface AppComponent : WeatherRepositoryComponent { val weatherRepository:

    WeatherRepository } val appComponent = AppComponent::class.create() val weatherRepository = appComponent.weatherRepository
  26. kotlin-inject interface WeatherComponent { val weatherRepository: WeatherRepository } @Singleton @Component

    interface AppComponent : WeatherRepositoryComponent, WeatherComponent
  27. kotlin-inject - the good Reduced API surface: Member injection @Binds

    @BindsInstance @Module @Subcomponent @Assisted @AssistedInject
  28. kotlin-inject - the good Arguments: @Component abstract class AppComponent( @get:Provides

    val application: Application, ) val application = … val appComponent = AppComponent::class.create(application)
  29. kotlin-inject - the good Component inheritance: @Component abstract class AppComponent

    @Component abstract class LoggedInComponent( @get:Provides val user: User, )
  30. kotlin-inject - the good Component inheritance: @Component abstract class AppComponent

    @Component abstract class LoggedInComponent( @Component val appComponent: AppComponent, @get:Provides val user: User, )
  31. kotlin-inject - the good Component inheritance: @Component abstract class AppComponent

    @Component abstract class LoggedInComponent( @Component val appComponent: AppComponent, @get:Provides val user: User, ) LoggedInComponent::class.create(appComponent, user)
  32. kotlin-inject - the good Override functions @Component abstract class AppComponent

    @Component abstract class LoggedInComponent(...) { interface Factory { fun loggedInComponent(user: User): LoggedInComponent } }
  33. kotlin-inject - the good Override functions @Component abstract class AppComponent

    : LoggedInComponent.Factory { override fun loggedInComponent(user: User): LoggedInComponent { return LoggedInComponent::class.create(this, user) } } @Component abstract class LoggedInComponent(...) { interface Factory { fun loggedInComponent(user: User): LoggedInComponent } }
  34. kotlin-inject - the good Qualifiers typealias AppScopeCoroutineScope = CoroutineScope typealias

    LoggedInScopeCoroutineScope = CoroutineScope typealias PresenterCoroutineScope = CoroutineScope … https://github.com/evant/kotlin-inject/issues/253
  35. kotlin-inject - the good Qualifiers typealias AppScopeCoroutineScope = CoroutineScope typealias

    LoggedInScopeCoroutineScope = CoroutineScope typealias PresenterCoroutineScope = CoroutineScope … https://github.com/evant/kotlin-inject/issues/253
  36. kotlin-inject - best practices :app1 AppComponent, LoggedInComponent :weather WeatherRepositoryModule :forecast

    WeatherComponent :app2 AppComponent, LoggedInComponent :app3 AppComponent, LoggedInComponent
  37. kotlin-inject - best practices Naming: interface WeatherRepositoryComponent { val weatherRepository:

    WeatherRepository @Provides fun provideWeatherRepository( weatherRepository: WeatherRepositoryImpl ): WeatherRepository = weatherRepository }
  38. kotlin-inject - best practices Naming: interface ${descriptiveName}Component { val weatherRepository:

    WeatherRepository @Provides fun provide${returnType}( weatherRepository: WeatherRepositoryImpl ): WeatherRepository = weatherRepository }
  39. kotlin-inject - best practices Naming: interface ${descriptiveName}Component { val weatherRepository:

    WeatherRepository @Provides fun provide${returnType}( weatherRepository: WeatherRepositoryImpl ): WeatherRepository = weatherRepository }
  40. kotlin-inject - best practices Component interfaces as inner classes: abstract

    class ViewRenderer<T : BaseModel> : Renderer<T> { fun init(...) { val coroutineScope = CoroutineScope( rootScopeProvider.rootScope.diComponent<Component>().dispatcher + Job(), ) } interface Component { val dispatcher: MainCoroutineDispatcher } }
  41. kotlin-inject - best practices Component interfaces as inner classes: abstract

    class ViewRenderer<T : BaseModel> : Renderer<T> { fun init(...) { val coroutineScope = CoroutineScope( rootScopeProvider.rootScope.diComponent<Component>().dispatcher + Job(), ) } interface Component { val dispatcher: MainCoroutineDispatcher } }
  42. kotlin-inject - best practices Component per platform @Component abstract class

    AndroidAppComponent( @get:Provides val application: Application, ) : LoggedInComponent.Factory { override fun loggedInComponent(user: User): LoggedInComponent { return AndroidLoggedInComponent::class.create(this, user) } }
  43. kotlin-inject - best practices Component per platform @Component abstract class

    AndroidAppComponent( @get:Provides val application: Application, ) : LoggedInComponent.Factory { override fun loggedInComponent(user: User): LoggedInComponent { return AndroidLoggedInComponent::class.create(this, user) } }
  44. kotlin-inject - best practices Component per platform @Component abstract class

    IosAppComponent( @get:Provides val uiApplication: UIApplication, ) : LoggedInComponent.Factory { override fun loggedInComponent(user: User): LoggedInComponent { return IosLoggedInComponent::class.create(this, user) } }
  45. kotlin-inject - best practices Component per platform @Component abstract class

    IosAppComponent( @get:Provides val uiApplication: UIApplication, ) : LoggedInComponent.Factory { override fun loggedInComponent(user: User): LoggedInComponent { return IosLoggedInComponent::class.create(this, user) } }
  46. kotlin-inject - best practices Component per platform @Component abstract class

    DesktopAppComponent : LoggedInComponent.Factory { override fun loggedInComponent(user: User): LoggedInComponent { return DesktopLoggedInComponent::class.create(this, user) } }
  47. kotlin-inject - best practices Component per platform interface LoggedInComponent( val

    appComponent: AppComponent val user: User ) @Component abstract class AndroidLoggedInComponent( @Component override val appComponent: AndroidAppComponent, @get:Provides override val user: User, ) : LoggedInComponent
  48. kotlin-inject - best practices iOS @Component abstract class IosAppComponent( @get:Provides

    val iosNativeImplementations: IosNativeImplementations, ) : IosNativeImplementations.Component
  49. kotlin-inject - best practices iOS interface IosNativeImplementations { val uiApplication:

    UIApplication interface Component { @Provides fun provideUiApplication( iosNativeImplementations: IosNativeImplementations ): UIApplication = iosNativeImplementations.uiApplication } }
  50. kotlin-inject - boilerplate @Component abstract class AppComponent(...) : Component1, Component2,

    Component3, Component4, Component5, Component6, Component7, Component8
  51. kotlin-inject - boilerplate @Component abstract class AppComponent(...) : Component1, Component2,

    Component3, Component4, Component5, Component6, Component7, Component8, Component9
  52. kotlin-inject - boilerplate @Component abstract class AppComponent(...) : Component1, Component2,

    Component3, Component4, Component5, Component6, Component7, Component8, Component9, …
  53. kotlin-inject - boilerplate interface NewComponent { … } @Component abstract

    class AndroidApp1Component : NewComponent @Component abstract class AndroidApp2Component : NewComponent
  54. kotlin-inject - boilerplate interface NewComponent { … } @Component abstract

    class AndroidApp1Component : NewComponent @Component abstract class AndroidApp2Component : NewComponent @Component abstract class DemoAppComponent : NewComponent
  55. kotlin-inject - boilerplate interface NewComponent { … } @Component abstract

    class AndroidApp1Component : NewComponent @Component abstract class AndroidApp2Component : NewComponent @Component abstract class DemoAppComponent : NewComponent @Component abstract class IosAppComponent : NewComponent
  56. kotlin-inject - boilerplate interface NewComponent { … } @Component abstract

    class AndroidApp1Component : NewComponent @Component abstract class AndroidApp2Component : NewComponent @Component abstract class DemoAppComponent : NewComponent @Component abstract class IosAppComponent : NewComponent @Component abstract class DesktopAppComponent : NewComponent …
  57. kotlin-inject @Inject @Singleton class WeatherRepositoryImpl( client: HttpClient, ) : WeatherRepository

    interface WeatherRepositoryComponent { @Provides fun provideWeatherRepository( weatherRepository: WeatherRepositoryImpl ): WeatherRepository = weatherRepository }
  58. kotlin-inject-anvil @ContributesBinding(AppScope::class) @Inject @Singleton class WeatherRepositoryImpl( client: HttpClient, ) :

    WeatherRepository interface WeatherRepositoryComponent { @Provides fun provideWeatherRepository( weatherRepository: WeatherRepositoryImpl ): WeatherRepository = weatherRepository }
  59. kotlin-inject interface WeatherComponent { val weatherRepository: WeatherRepository } @Singleton @Component

    interface AppComponent : WeatherRepositoryComponent, WeatherComponent
  60. kotlin-inject-anvil @ContributesTo(AppScope::class) interface WeatherComponent { val weatherRepository: WeatherRepository } @Singleton

    @Component @MergeComponent(AppScope::class) interface AppComponent : AppComponentMerged
  61. :runtime-optional @Scope annotation class SingleIn( val scope: KClass<*>, ) @Qualifier

    annotation class ForScope( val scope: KClass<*>, ) abstract class AppScope private constructor()
  62. :runtime-optional @Scope annotation class Singleton @Inject @Singleton @ContributesBinding(AppScope::class) class WeatherRepositoryImpl(

    client: HttpClient, ) : WeatherRepository @ContributesTo(AppScope::class) interface WeatherComponent { val weatherRepository: WeatherRepository } @Component @MergeComponent(AppScope::class) @Singleton interface AppComponent : AppComponentMerged
  63. :runtime-optional @Scope annotation class Singleton @Inject @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class WeatherRepositoryImpl(

    client: HttpClient, ) : WeatherRepository @ContributesTo(AppScope::class) interface WeatherComponent { val weatherRepository: WeatherRepository } @Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) interface AppComponent : AppComponentMerged
  64. Extending kotlin-inject-anvil class ComposeCounterRenderer : ComposeRenderer<Model>() { @ContributesTo(RendererScope::class) interface Component

    { @Provides @IntoMap fun provideComposeCounterRendererCounterPresenterModel( renderer: () -> ComposeRenderer<Model> ): Pair<KClass<out BaseModel>, () -> Renderer<*>> = Model::class to renderer @Provides @SingleIn(RendererScope::class) fun provideComposeCounterRenderer(): ComposeCounterRenderer = ComposeCounterRenderer() } }
  65. :compiler // compiler/build.gradle plugins { id 'org.jetbrains.kotlin.jvm' id 'com.google.devtools.ksp' }

    dependencies { ksp "software.amazon.lastmile.kotlin.inject.anvil:compiler:$kia_version" implementation "software.amazon.lastmile.kotlin.inject.anvil:runtime:$kia_version" implementation "com.google.devtools.ksp:symbol-processing-api:$ksp_version" implementation "com.squareup:kotlinpoet:$kotlin_poet_version" implementation "com.squareup:kotlinpoet-ksp:$kotlin_poet_version" testImplementation "dev.zacsweers.kctfork:core:$kotlin_compile_testing_version" testImplementation "dev.zacsweers.kctfork:ksp:$kotlin_compile_testing_version" }
  66. :compiler // compiler/src/main/kotlin/…/ContributesRendererSymbolProcessor.kt private class ContributesRendererSymbolProcessor( private val environment: SymbolProcessorEnvironment,

    ) : SymbolProcessor { override fun process(resolver: Resolver): List<KSAnnotated> { resolver .getSymbolsWithAnnotation(ContributesRenderer::class) .filterIsInstance<KSClassDeclaration>() .onEach { checkIsPublic(it) } .forEach { generateComponentInterface(it) } return emptyList() } }
  67. :compiler private fun generateComponentInterface(clazz: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE,

    clazz.safeClassName) val allModels = ... val fileSpec = FileSpec.builder(componentClassName) .addType( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addAnnotation(rendererScope) .addOriginAnnotation(clazz) .apply { ... } .addFunctions(allModels.map { createModelBindingFunction(clazz, it) }.toList()) .build(), ) .build() fileSpec.writeTo(codeGenerator, aggregating = false) }
  68. :compiler private fun generateComponentInterface(clazz: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE,

    clazz.safeClassName) val allModels = ... val fileSpec = FileSpec.builder(componentClassName) .addType( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addAnnotation(rendererScope) .addOriginAnnotation(clazz) .apply { ... } .addFunctions(allModels.map { createModelBindingFunction(clazz, it) }.toList()) .build(), ) .build() fileSpec.writeTo(codeGenerator, aggregating = false) }
  69. :compiler private fun generateComponentInterface(clazz: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE,

    clazz.safeClassName) val allModels = ... val fileSpec = FileSpec.builder(componentClassName) .addType( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addAnnotation(rendererScope) .addOriginAnnotation(clazz) .apply { ... } .addFunctions(allModels.map { createModelBindingFunction(clazz, it) }.toList()) .build(), ) .build() fileSpec.writeTo(codeGenerator, aggregating = false) }
  70. :compiler private fun generateComponentInterface(clazz: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE,

    clazz.safeClassName) val allModels = ... val fileSpec = FileSpec.builder(componentClassName) .addType( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addAnnotation(rendererScope) .addOriginAnnotation(clazz) .apply { ... } .addFunctions(allModels.map { createModelBindingFunction(clazz, it) }.toList()) .build(), ) .build() fileSpec.writeTo(codeGenerator, aggregating = false) }
  71. :compiler private fun generateComponentInterface(clazz: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE,

    clazz.safeClassName) val allModels = ... val fileSpec = FileSpec.builder(componentClassName) .addType( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(clazz.requireContainingFile()) .addAnnotation(rendererScope) .addOriginAnnotation(clazz) .apply { ... } .addFunctions(allModels.map { createModelBindingFunction(clazz, it) }.toList()) .build(), ) .build() fileSpec.writeTo(codeGenerator, aggregating = false) }
  72. :compiler @Test fun `a component interface is generated in the

    lookup package for a contributed renderer`() { compile( """ package software.amazon.test import … class Model : BaseModel @ContributesRenderer class TestRenderer : Renderer<Model> { override fun render(model: Model) = Unit } """, componentInterfaceSource, ) { val generatedComponent = testRenderer.generatedComponent assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) assertThat(generatedComponent).isAnnotatedWith(SingleInRendererScope::class) assertThat(generatedComponent.origin).isEqualTo(testRenderer) … } } https://github.com/square/anvil/blob/main/compiler-utils/src/testFixtures/java/com/squareup/anvil/compiler/internal/testing/AnvilCompilation.kt
  73. :compiler val generatedComponent = componentInterface.generatedComponent internal val JvmCompilationResult.componentInterface: Class<*> get()

    = classLoader.loadClass("software.amazon.test.ComponentInterface") internal val Class<*>.generatedComponent: Class<*> get() = classLoader.loadClass( "$LOOKUP_PACKAGE." + canonicalName.split(".").joinToString(separator = "") { it.capitalize() }, )
  74. :compiler val generatedComponent = componentInterface.generatedComponent internal val JvmCompilationResult.componentInterface: Class<*> get()

    = classLoader.loadClass("software.amazon.test.ComponentInterface") internal val Class<*>.generatedComponent: Class<*> get() = classLoader.loadClass( "$LOOKUP_PACKAGE." + canonicalName.split(".").joinToString(separator = "") { it.capitalize() }, )
  75. :compiler val generatedComponent = componentInterface.generatedComponent internal val JvmCompilationResult.componentInterface: Class<*> get()

    = classLoader.loadClass("software.amazon.test.ComponentInterface") internal val Class<*>.generatedComponent: Class<*> get() = classLoader.loadClass( "$LOOKUP_PACKAGE." + canonicalName.split(".").joinToString(separator = "") { it.capitalize() }, )
  76. Summary • KSP is great ◦ KMP support, multiple rounds,

    deferred processing, incremental processing, … ◦ Simple introduction to the meta-programming world • kotlin-inject is great and improving • kotlin-inject-anvil removes boilerplate and is extensible • Find creative ways to make tools and libraries work for you