Extending Anvil for Fun and Profit

Note: This article assumes some familiarity with Dagger, Anvil, and Kotlin.

We use Dagger heavily in the Slack Android app for compile-time dependency injection. It’s powerful, flexible, supports basic Kotlin idioms, and allows for advanced dependency injection patterns with less boilerplate. It’s not without its sharp edges though. It slows down our builds with kapt, has a steep learning curve, and can often be tedious to write out its module+component plumbing.

Anvil augments Dagger’s own boilerplate. Factory generation enables us to remove kapt from much of our project, @ContributesTo and @ContributesBinding allow us to automatically wire dependencies without modules, and @MergeComponent allows us to automatically wire together component interfaces. Factory generation alone recently helped us reduce our incremental build times by as much as 25%!

Anvil’s core features are fantastic, but they are not (nor pretend to be!) turnkey solutions for every use case, though they cover the common ones. Every codebase has its own patterns and nuances, so Anvil 2.3.0 introduced a new compiler-api artifact that allows us to extend Anvil’s own code generation to suit those remaining needs. In this post we’ll detail a little of how it works with our own Activity injection pattern as an example.

The problem

We historically used dagger-android, which is now deprecated. As such, most of our activities were wired via @ContributesAndroidInjector. While Hilt is the canonical successor to this, we’ve opted not to adopt it for a few reasons specific to our codebase. Our stack is very particular (we don’t have notions of activity-scope or fragment-scope), so building upon low-level Dagger APIs rather than adopting a new opinionated framework was a better solution for us. We still want to move off of dagger-android though, so we needed to devise something that played to our existing structure.

While most injected types can just use constructor injection or assisted injection (such as fragments), activities are a special case as they can only use member injection and require something to inject them in onCreate(). This can be done by hand but it’s tedious, error-prone, and leaves us with a long list of inject() functions in corresponding components. This is something dagger-android handled nicely for us with AndroidInjection.inject(), so we wanted to arrive at a solution that got us a similarly simple touch point. dagger-android has the benefit of intrinsic Dagger support though, so it wouldn’t be as easy for us to have Dagger auto-magically wire things for us.

Scope context

Our standard scope hierarchy is an app-scope component plus org-scoped and user-scoped subcomponents of it.

“app” is effectively app-wide singletons while “org” corresponds to individual organization (which can contain multiple workspaces) and “user” corresponds to individual workspaces. If you’re familiar with the Slack app UI structure, this maps pretty squarely onto it.

Slack Android component hierarchy

Implementation

We had a strict set of requirements for what we were looking for:

  1. Low cognitive overhead. An ideal API would just be more of an “opt-in” than anything, with as little ceremony as possible.
  2. Specifying the target scope, as we have activities in each of our three main scopes.
  3. Does not require dagger-compiler or dagger-android-processor. We actively want to reduce the kapt footprint in our codebase, which renders these unavailable.
  4. No significant reflection at runtime or dynamism. Dagger’s best feature is compile-time safety and we don’t want to sacrifice that. dagger-android actually had some headaches in this area, so we wanted to improve.

We have a base activity that all our Activities extend, which gives us some wiggle room to push shared logic into. We experimented with a number of different ideas, but ended up with this approach that felt pretty compelling.

Consider this example:

@InjectWith(UserScope::class)
class ChannelInfoActivity : BaseActivity { @Inject lateinit var presenter: ChannelInfoActivityPresenter }

The associated Dagger wiring code for this would look like this.

class ChannelInfoActivityAnvilInjector @Inject constructor( override val injector: MembersInjector<ChannelInfoActivity>
) : AnvilInjector<ChannelInfoActivity> @Module
@ContributesTo(UserScope::class)
interface ChannelInfoActivityAnvilInjectorBinder { @IntoMap @Binds @ActivityKey(ChannelInfoActivity::class) fun ChannelInfoActivityAnvilInjector.bind(): AnvilInjector<*>
}

Woah! What’s going on here?! This uses a lesser known feature of Dagger where you can request a target type’s MembersInjector instance. You might know that every class using member injection results in one of these being generated, but you might not know that you can actually ask for this type in Dagger as an intrinsic like Lazy or Provider. In the above case, we request it in ChannelInfoActivityAnvilInjector’s constructor and Dagger will fulfill it.

AnvilInjector itself is just a simple interface that allows us to collect all of these in a multibinding in the host component.

interface AnvilInjector<T> { val injector: MembersInjector<T> fun inject(target: T) { injector.injectMembers(target) }
}

The host component simply exposes a multibinding for accessing these like so:

interface UserComponent { fun activityInjectors(): Map<Class<out BaseActivity>, AnvilInjector<*>>
}

ChannelInfoActivityAnvilInjectorBinder is just an Anvil binding to slingshot it to the target scope. Then our BaseActivity simply pulls this map off the component in onCreate(), looks up the bound injector, and calls inject() with our instance.

private fun anvilInject() { val scope = ... if (scope == UserScope::class) { val injector = application.appComponent() .userComponentFor(userId) .activityInjectors()[javaClass] .inject(this) }
}

This works well. MembersInjector is a really neat API and solves this case perfectly. This solution solves requirements B, C, and D. It’s far from solving A though, look at all this new boilerplate! Every activity requires writing a custom AnvilInjector and associated binding module.

Anvil to the rescue

These two generated types? They’re mechanical in nature. We can infer all the information needed for them from just the activity type and the scope. With Anvil’s compiler API, we can hook into the compiler directly and look for our @InjectWith annotation and generate the above boilerplate for us at compile-time.

The API is simple. In our case, we need to implement a custom CodeGenerator.

@AutoService(CodeGenerator::class)
class AnvilInjectorGenerator : CodeGenerator { override fun isApplicable(context: AnvilContext): Boolean { return true } override fun generateCode( codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection<KtFile> ): Collection<GeneratedFile> { return generatedFiles }
}

Our implementation lives in its own local Gradle subproject and our consuming subprojects just depend on it via the anvil() configuration. This is just a stencil of the logic and our internal implementation is a bit more involved than this example, but the API is easy to understand once you get into the IDE autocomplete. I would recommend looking at Anvil’s own [MembersInjectorGenerator](https://github.com/square/anvil/blob/e5154257c7161361feaed1a49cbf39cc98a7f300/compiler/src/main/java/com/squareup/anvil/compiler/codegen/dagger/MembersInjectorGenerator.kt) as a simple example, our generator is fairly similar. If you’ve ever written a custom Android linter, it uses much of the same PSI infrastructure.

plugins { id("com.squareup.anvil")
} dependencies { anvil(":our:anvil:code-generator-project")
}

Now when a developer wants to enable Dagger injection on an activity, all they have to do is add the @InjectWith annotation and the rest will be handled for them automatically.

@InjectWith(UserScope::class)
class ChannelInfoActivity : BaseActivity { @Inject lateinit var presenter: ChannelInfoActivityPresenter }

Closing

In the end, we got everything we wanted and more!

  • All the mechanical tedium is 100% generated. For developers, it’s literally one line and dead simple.
  • It’s easy to lint against and hard to get wrong. Internally, activities that operate in Org or User scope must also implement another internal interface for retrieving the logged-in user. We have a linter that ensures you implement this if you use @InjectWith + UserScope or OrgScope.
  • Testing this is easy. Anvil ships a Gradle Test Fixtures artifact you can use for in-memory unit testing on top of the kotlin-compile-testing library.
  • The larger design of this can be applied to more than just activities. Internally, we’ve actually generalized this infrastructure to be able to support any member-injected type (services, jobs, etc).
  • Hooks directly into the existing Dagger+Anvil system.
  • No kapt cost! This all runs during standard kotlinc and even supports multiple rounds, so our generated Anvil-using code will be processed by Anvil in a later round and result in more generated code. This allows us to drop @ContributesAndroidInjector and the dagger-android-processor along with it.

Anvil’s compiler API offers a powerful means of extending Anvil in your codebase to fit your needs. It’s well-tested and most of Anvil’s internal generators are built on top of it. We’re big fans of it and hope this deep dive inspires you to look at how you can leverage it to improve your own codebase.

If you like working on stuff like this, we’re hiring!

ホーム - Wiki
Copyright © 2011-2024 iteam. Current version is 2.137.1. UTC+08:00, 2024-11-14 17:07
浙ICP备14020137号-1 $お客様$