Introducing: Anvil-KSP

GitHub - ZacSweers/anvil: A Kotlin compiler plugin to make dependency injection with Dagger 2 easier.
A Kotlin compiler plugin to make dependency injection with Dagger 2 easier. - ZacSweers/anvil

Usage is easy and you can find instructions here: https://github.com/ZacSweers/anvil/blob/main/FORK.md

Why a fork?

Firstly, it's important to acknowledge the elephant in the room: it's a fork!

Much of the phase 1 work for KSP was implemented in the upstream square/anvil, but contribution merging wasn't implemented yet. As the folks at Square are focused on the existing Anvil implementation for the time being and in the interest of finishing this work to make it available to folks that want it, I continued in this fork. This is not "the" Anvil KSP or Anvil K2 implementation, just "an" implementation.

Motivations

At the point of divergence (~2.0.0-beta09), square/anvil ("upstream") had some notable caveats:

  • It only supports Kotlin 1.x.
    • Its code gen is implemented in K1 compiler plugin APIs and its IR merging mechanism is no longer supported in K2.
    • This means that Anvil users today must force Kotlin 1.9.
    • Once Kotlin 2.1 is out, this will no longer be possible as Kotlin only supports n+1 forward compatibility.
    • Heavily mixing K1 and K2 compilers in the same build causes higher pressure on JVM code cache that results in both performing slower.
    • This is the primary motivation for making this fork's implementation available now, as 2.1 is only a few months away at the time of writing.
  • It doesn't support Dagger-KSP.
    • This means KAPT or Java annotation processing is always imposed somewhere in the build pipeline. Given the performance costs of KAPT, this isn't ideal. You can try to optimize this by extracting a separate Java-only project, but this is at the cost of yet another Gradle subproject.
    • Dagger-KSP performance isn't there yet either, but it's clear that this is a long term focus for the Dagger team.
  • It has a long history of issues with incremental compilation
    • This causes extensive build flakes and lost time due to needing to rebuild with --rerun-tasks.
    • Exacerbated with the introduction of compilation avoidance introduced in later versions of Kotlin 1.x
    • A lot of valuable work has gone in to attempting to patch this. But, even with the latest fixes in the latest betas, incremental compilation must still be disabled in the expensive KAPT stub generation task in order for IR merging to work correctly. And, to reiterate the above, these fixes are only useful for K1.

KSP Benefits

A KSP implementation functionally addresses all of the above.

  • KSP will natively support K2 via KSP2 (currently KSP2 is in beta).
  • This Anvil-KSP implementation, aside from obviously running in KSP, changes generated merged component code in a source-compatible way that supports Dagger-KSP.
    • If Dagger-KSP isn't performant enough yet, Anvil-KSP also allows for continuing to run dagger in KAPT/Java APT. This is what we are doing in Slack for now.
  • KSP has native incremental processing support and none of the incremental compilation issues.
    • I've tested this implementation with multiple different IC issue repro cases across my projects and the community and all of them work with KSP.

KSP Costs

As with any solution, KSP isn't perfect.

  • It is not and cannot be as fast as running as an embedded compiler plugin.
  • KSP2, as mentioned above, is in beta.
  • Dagger-KSP is in beta and has known performance issues with larger projects, more so when used in KSP2. This issue appears to be on the dagger-side rather than KSP itself though.
  • This does require (only a couple!) source changes to work.

I've tried to documented all the known rough edges here.

Long Term

I plan to maintain this for the foreseeable future, until Anvil either supports K2 or upstreams this implementation.

Stats

TL;DR At the time of writing, the optimal scenario is to use KSP contribution merging merging + KAPT for dagger-compiler in large project. dagger-ksp performance may be fine for your needs in a smaller project. You should measure!

I've tested with three primary modes

  1. KAPT merging - KSP contribution gen, KAPT for everything else (IR contribution merging, dagger-compiler)
  2. Hybrid - KSP contribution gen and merging, KAPT for dagger-compiler
  3. KSP only - KSP for contribution gen, merging, and dagger-compiler

In the Slack app on Kotlin 1.9.25 + Dagger 2.52, I measured around a ~12% improvement switching from mode 1 (our starting baseline) to mode 2. Mode 3 was a non-starter as it appears that dagger-ksp struggles in larger projects and runs significantly slower (possibly some pathological case with a large number of modules).

Slack Profiling Notes

  • Surprisingly, Anvil's IR merging appears to have a statistically significant impact and is actually the biggest mover going from KAPT -> KSP.
  • Our project's "app-di" subproject is extremely thin, only has components and one module. Four classes.
    • This means that stub generation doesn't really change much even when run non-incrementally, so it's not captured well in these benchmarks. In a larger module, I would expect this to be exacerbated.

Both configurations 2 and 3 enable component merging in their build like so.

anvil {
  useKsp(
    contributesAndFactoryGeneration = true,
    componentMerging = true,
  )
}

Configurations examples for the rest are below

Hybrid Mode

// Connect KSP outputs to KAPT inputs
afterEvaluate {
  // Example config for a "release" build variant in an android project
  val buildType = "Release
  val kspTaskName = "ksp${buildType}Kotlin"
  val useKSP2 = providers.gradleProperty("ksp.useKSP2").getOrElse("false").toBoolean()
  val generatedKspKotlinFiles =
    if (useKSP2) {
      val kspReleaseTask = tasks.named<KspAATask>(kspTaskName)
      kspReleaseTask.flatMap { it.kspConfig.kotlinOutputDir }
    } else {
      val kspReleaseTask = tasks.named<KspTaskJvm>(kspTaskName)
      kspReleaseTask.flatMap { it.destination }
    }
  tasks.named<KotlinCompile>("kaptGenerateStubs${buildType}Kotlin${target}").configure {
    source(generatedKspKotlinFiles)
  }
}

dependencies {
  kapt(libs.dagger.compiler)
}

KSP-only Mode

dependencies {
  ksp(libs.dagger.compiler)
}