Blog Infos
Author
Published
Topics
, , , ,
Published

@pinewatt

 

This is an updated blog post of my previous iteration, in which I’m going to show you how to create custom-scoped components using kotlin-inject + Anvil. This allows you to scope your dependencies to your domain’s lifecycle, rather than a specific lifecycle of Android. If you’re already familiar with Anvil, feel free to skip the introduction and continue with part two, where we create our first component. For setup instructions, please refer to the official GitHub repositories of Anvil and kotlin-inject.

Overview

1 Introduction
2 Setting up the app component
3 Creating the custom user component
4 Managing its lifecycle
5 Wrapping everything up
6 Bonus: Adding a user coroutine scope

1. Introduction

One of the most common dependency injection frameworks used on Android today is Hilt. Opinionated frameworks like Hilt can become a burden with a growing list of business requirements that require objects to be scoped to a domain lifecycle rather than being scoped to a specific lifecycle of Android. Because Hilt is opinionated about its component hierarchy, it is rather difficult to create new Dagger components and slot them into specific parts of your component hierarchy outside the pre-defined ones. With Kotlin Multiplatform (KMP) on the rise, existing Java-based frameworks like Dagger are more difficult to use even with KSP support becoming an option. The team behind the Dagger-based Anvil shared their thoughts on supporting KMP in the future and made clear, that KMP is not a priority right now. Luckily, theres another Dagger-like dependency injection framework called kotlin-inject, that’s purely Kotlin and KMP compatible. Since Dagger has been an industry standard for so long, most people on your team should already be familiar with the concept, making kotlin-inject a good candidate if you want to build or manage custom-scoped components when migrating away from Hilt or when starting new KMP projects.

If you’ve used Hilt before, you are familiar with the @InstallIn annotation, which is used to define the component a specific module should be included in. Without this annotation, a component would need to define each module that should be included in it. Without Hilt, your application-scoped component would look similar to this:

@Singleton
@Component(modules = [AppModule::class, NetworkModule::class])
interface AppComponent

Using @InstallIn moves this responsibility back to the owner of the specific module, which is one of the best features when using Hilt, making your module declaration look like this:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule

This is where Anvil comes into play. To get the best of both worlds, it is a good idea to use Anvil in addition to kotlin-inject, which has its own version of the @InstallIn annotation which is called @ContributesTo:

import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo

@ContributesTo(AppScope::class)
interface NetworkComponent
2. Setting up the app component

Anvil already comes with an AppScope which we are going to use for the root component. This scope is not an annotation class that you would normally find when using kotlin-inject scopes. When working with Anvil, it is usually recommended to use a scope called @SingleIn. This annotation is already provided by Anvil, so make sure to include the runtime-optional artifact in your commonMain module dependencies:

plugins {
  // ...
}

kotlin {
  sourceSets {
    commonMain.dependencies {
      api("software.amazon.lastmile.kotlin.inject.anvil:runtime-optional:0.1.2")
    }
}

android {
  // ...
}

Using the @SingleIn annotation also makes it obvious that any singleton annotated with it is only considered a singleton inside the relevant scope.

Since we’re going to provide common as well as platform-specific components, we can create an interface in commonMain, contribute common dependencies there and finally have a concrete implementation of said component, to be able to provide platform-specific dependencies in the final component using @Provides:

// commonMain
interface AppComponent

 

import android.app.Application
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn

@SingleIn(AppScope::class)
@MergeComponent(AppScope::class)
abstract class AndroidAppComponent(
  @get:Provides val application: Application
) : AppComponent

 

The Anvil annotation @MergeComponent tells Anvil to create a component and merge all components with the same scope into a single, final component.

By contributing it to the root component, the application instance will be available to the contributed component as well as every child subcomponents. Since the AppComponent is scoped to the lifecycle of the entire app, we can expose it as a property inside the Application class to allow any subcomponents to be created from there:

import android.app.Application

class AndroidApplication : Application() {
  val component: AndroidAppComponent by lazy(LazyThreadSafetyMode.NONE) {
    AndroidAppComponent::class.create(this)
  }
}
3. Creating the custom user component

Since we want to create a component that is scoped to the lifecycle of a UserSession object, we will also need to create a UserScope object in addition to our AppScope class:

// commonMain
public abstract class UserScope private constructor()

To make UserComponent a subcomponent of AppComponent, we annotate it with @ContributesSubcomponent and add a subcomponent factory with the parent scope:

// commonMain
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesSubcomponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn

@SingleIn(UserScope::class)
@ContributesSubcomponent(UserScope::class)
interface UserComponent {

  @ContributesSubcomponent.Factory(AppScope::class)
  interface Factory {
    fun create(session: UserSession): UserComponent
  }
}

Passing the current UserSession as a parameter to the factory function will automatically generate kotlin-inject code to contribute this object to the UserComponent using @Provides.

When creating a class that depends on a UserSession object, it can be injected into its constructor like usual and is annotated using the @SingleIn annotation with the desired scope, in this case, the UserScope:

import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn

@Inject
@SingleIn(UserScope::class)
class UserRepository(private val session: UserSession) {
  fun getUserSessionName(): String {
    return session.name
  }
}

We can safely call the instance of UserSession everywhere inside user-scoped classes, since every time these classes are instantiated, we already have a valid object, removing any kind of mental overhead for engineers using this class. Having this custom scope in place also allows you to avoid nullability in classes that depend on a UserSession instance.

This is especially useful when you know that these classes are never needed outside this scope, as they will automatically be cleared from memory too. No need to overwrite or delete any data when logging out to avoid leaking data into another user session, which could happen when scoping this class to a too-wide lifecycle, such as AppScope.

4. Managing its lifecycle

When dealing with custom components, it makes sense to bundle the desired functionality inside some form of manager, that will be responsible for recreating this component if necessary. In this example, we have two functions that will be responsible for creating and resetting a user session appropriately. Because the concrete AppComponent implementation will be platform-specific, I created an expect AppComponentWrapper class inside commonMain and an actual AppComponentWrapper class in androidMain to be able to access the appComponent instance we’re storing in our Application class. (It could also be possible to use kotlin-inject’s KMP features as mentioned here, but I haven’t had time to look into it yet.) Since this manager outlives the scope it is responsible for managing, it is scoped to the AppScope as a singleton:

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn  

@Inject
@SingleIn(AppScope::class)
class UserSessionManager(private val appComponentWrapper: AppComponentWrapper) {

  override val userComponent = MutableStateFlow<UserComponent?>(null)

  override fun createComponent(userSession: UserSession) {
    val factory = appComponentWrapper.appComponent as UserComponent.Factory
    userComponent.value = factory.create(userSession)
  }

  override fun resetComponent() {
    userComponent.value = null
  }
}

Note: The code snippet above uses a simplified version of the UserSessionManager and does not implement any interface. Usually, you would define an interface and bind the implementation to the AppScope using @ContributesTo.

Since Anvil generates subcomponent factories for us, we can access it by casting the parent component the the subcomponent factory, in this case UserComponent.Factory. To allow observing and potentially recreating child components in the future, we’re going to store this reference inside a StateFlow.

5. Wrapping everything up

Our manager can now be injected into other classes such as an onboarding screen to create or reset a session, e.g. when a user is logging in or logging out, causing a partial recreation of our component graph based on the lifecycle of our user session.

6. Bonus: Adding a user coroutine scope

Another common problem is launching or canceling any user scope related coroutines once a session concludes and our user component is destroyed. Usually, you would add an @Provides method inside a module to return a coroutine scope, however, since there are likely more coroutine scopes associated with different components at the same time, returning the same type may lead to issues because of multiple bindings. The best way to avoid this is to use kotlin-injects support for typealiases, that represents the desired coroutine scope:

typealias UserCoroutineScope = CoroutineScope

Similarly to adding a coroutine scope that is part of the application class, we add a coroutine scope in the UserSessionManager class that is scoped to the UserComponent lifecycle:

@Inject
@SingleIn(AppScope::class)
class UserSessionManager(private val appComponentWrapper: AppComponentWrapper) {

  private var _userCoroutineScope: UserCoroutineScope? = null

  private val userCoroutineScope: CoroutineScope
    get() = createCoroutineScope()


  override fun createComponent(userSession: UserSession) {
    // ...
  }

  override fun resetComponent() {
    _userCoroutineScope?.cancel()
    _userCoroutineScope = null
    // ...
  }

  private fun createUserCoroutineScope(): CoroutineScope {
    return _userCoroutineScope ?: CoroutineScope(SupervisorJob())
      .also { _userCoroutineScope = it }
  }
}

To contribute this coroutine scope to the user component, we add it to the subcomponent factory function as a parameter:

import software.amazon.lastmile.kotlin.inject.anvil.AppScope
import software.amazon.lastmile.kotlin.inject.anvil.ContributesSubcomponent
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn

@SingleIn(UserScope::class)
@ContributesSubcomponent(UserScope::class)
interface UserComponent {

  @ContributesSubcomponent.Factory(AppScope::class)
  interface Factory {
    fun create(
      session: UserSession,
      coroutineScope: UserCoroutineScope,
    ): UserComponent
  }
}

Finally, we need to update the manager and ensure we are creating and canceling the coroutine scope in case we create or reset a user session:

@Inject
@SingleIn(AppScope::class)
class UserSessionManager(private val appComponentWrapper: AppComponentWrapper) {

    private var _coroutineScope: UserCoroutineScope? = null

    private val coroutineScope: CoroutineScope
      get() = createCoroutineScope()

    override val userComponent = MutableStateFlow<UserComponent?>(null)

    override fun createComponent(userSession: UserSession) {
      val factory = appComponentWrapper.appComponent as UserComponent.Factory
      userComponent.value = factory.create(userSession, coroutineScope)
    }

    override fun resetComponent() {
      _coroutineScope?.cancel()
      _coroutineScope = null
      userComponent.value = null
    }

    private fun createCoroutineScope(): CoroutineScope {
      return _coroutineScope ?: CoroutineScope(SupervisorJob())
        .also { _coroutineScope = it }
    }
  }

You are now able to inject the UserCoroutineScope inside user-scoped dependencies that will automatically be cancelled when resetting the user session:

import me.tatarka.inject.annotations.Inject
import software.amazon.lastmile.kotlin.inject.anvil.SingleIn

@Inject
@SingleIn(UserScope::class)
class UserRepository(
  private val session: UserSession,
  private val coroutineScope: UserCoroutineScope,
)

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Sharing code across platforms is a wonderful superpower. But sometimes, sharing 100% of your codebase isn’t the goal. Maybe you’re migrating existing apps to multiplatform, maybe you have platform-specific libraries or APIs you want to…
Watch Video

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Developer

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform ...

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Deve ...

Jobs

No results found.

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Using annotations in Kotlin has some nuances that are useful to know
READ MORE
blog
One of the latest trends in UI design is blurring the background content behind the foreground elements. This creates a sense of depth, transparency, and focus,…
READ MORE
blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
With JCenter sunsetted, distributing public Kotlin Multiplatform libraries now often relies on Maven Central…
READ MORE
Menu