Post

Dagger2 is hard, but it can be easy, part 5

In the previous post we’ve explored scope operators.

In this post we’ll encounter subcomponents and their hierarchy, custom scopes and let’s not waste any time.

Starting off by including the androidx.preferences library

1
implementation 'androidx.preference:preference-ktx:1.1.1'

Let’s delete all the classes from previous posts and remove all of the code within MainActivity also rename TestApplication to

1
2
3
4
5
6
class DaggerIsEasyApplication : Application() {

    override fun onCreate() {
        super.onCreate()
    }
}

Create a simple shared preference manager that’ll hide most of the logic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Singleton
class SharedPreferencesManager @Inject constructor(context: Context) {

    private companion object {
        private const val FIRST_TIME_LAUNCH_KEY = "firstTimeLaunch"
    }

    private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)

    val firstTimeLaunchSinceInstall get() = sharedPrefs.getBoolean(FIRST_TIME_LAUNCH_KEY, true)
    fun appHasBeenLaunchedForTheFirstTime() = sharedPrefs.edit(true) {
        putBoolean(FIRST_TIME_LAUNCH_KEY, false)
    }
}

Annotate it with the scope @Singleton because this instance needs to be alive for the app’s lifetime since that’s when we’ll create the graph.

The second thing we do is create our component and we do the following

1
2
3
4
5
@Component
@Singleton
interface SingletonComponent {
    fun provideGraphInside(application: DaggerIsEasyApplication)
}

we press build or F10 and then we go in our Application inheritance to do the following

1
2
3
4
5
6
7
8
class DaggerIsEasyApplication : Application() {

    override fun onCreate() {
        DaggerSingletonComponent.create().provideGraphInside(this)
        super.onCreate()
        
    }
}

Now we get to the real deal, we need an instance of our PreferenceManager inside the application level, then we have something like.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class DaggerIsEasyApplication : Application() {

    @Inject
    lateinit var sharedPreferencesManager: SharedPreferencesManager

    override fun onCreate() {
        DaggerSingletonComponent.create().provideGraphInside(this)
        super.onCreate()
        

        if (sharedPreferencesManager.firstTimeLaunchSinceInstall){
            Log.d(this::class.java.simpleName, "App is launched for the first time since installation time")
            sharedPreferencesManager.appHasBeenLaunchedForTheFirstTime()
        }
    }
}

If we click compile, this will go ahead and compile but there’s one big issue, when we run the app we get something like

1
2
error: [Dagger/MissingBinding] android.content.Context cannot be provided without an @Provides-annotated method.
public abstract interface SingletonComponent

that’s because we need a Context in our SharedPreferencesManager, let’s to do the following changes in our SingletonComponent

1
2
3
4
5
6
7
8
9
10
@Component
@Singleton
interface SingletonComponent {
    @Component.Factory
    interface SingletonComponentFactory {
        fun create(@BindsInstance context: Context): SingletonComponent
    }

    fun provideGraphInside(application: DaggerIsEasyApplication)
}

We’ve learnt about a new citizen in the Dagger world @Component.Factory a factory has to create our component, because our component needs a parameter in this case a Context, in order to create our SharedPreferencesManager, because Dagger doesn’t know how to get a Context we have to provide it to Dagger ourselves.

Inside we have an interface which is essentially our factory and one important function called create, which returns (creates) the SingletonComponent but with a parameter of the type we wanted, in our case a Context.

Also there’s @BindsInstance, this annotation knows about the type of the parameter in our case Context and later on whenever we request this Context within our @Singleton scope we’ll have it provided to us by Dagger we don’t have to do anything else (because this runtime variable is tied to the component’s scope that binds it, in our case SingletonComponent and it’s children, more about that later on).

As I mentioned earlier, Dagger would be far easier without Android’s runtime, but Context is a runtime variable which is available once the app’s started and that’s what complicates things.

Now for the most important part, let’s create the component with a runtime variable, inside DaggerIsEasyApplication and after onCreate() just right before the super call

1
2
3
4
5
6
7
8
9
    override fun onCreate() {
        DaggerSingletonComponent.factory().create(this).provideGraphInside(this)
        super.onCreate()

        if (sharedPreferencesManager.firstTimeLaunchSinceInstall){
            Log.d(this::class.java.simpleName, "App is launched for the first time since installation time")
            sharedPreferencesManager.appHasBeenLaunchedForTheFirstTime()
        }
    }

now when we launch the application, we see the following log

when we rerun the app, we won’t see the log.

Let’s say we wanted to get that same SharedPreferencesManager in our MainActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var sharedPreferencesManager: SharedPreferencesManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (sharedPreferencesManager.firstTimeLaunchMainActivitySinceInstall){
            Log.d(this::class.java.simpleName, "Main activity is launched for the first time since installation time")
            sharedPreferencesManager.mainActivityHasBeenLaunchedForTheFirstTime()
        }

    }
}

run the code and see it crash 💥

1
Caused by: kotlin.UninitializedPropertyAccessException: lateinit property sharedPreferencesManager has not been initialized

The first thing that we need to do is make our ApplicationComponent available to do more than one thing, so we expose it as a variable that we initialize with our create(this) and we also provideGraphInside the application level.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class DaggerIsEasyApplication : Application() {

    lateinit var applicationComponent: SingletonComponent
        private set

    @Inject
    lateinit var sharedPreferencesManager: SharedPreferencesManager

    override fun onCreate() {
        super.onCreate()
        applicationComponent = DaggerSingletonComponent.factory().create(this).also { it.provideGraphInside(this) }

        if (sharedPreferencesManager.firstTimeLaunchSinceInstall) {
            Log.d(this::class.java.simpleName, "App is launched for the first time since installation time")
            sharedPreferencesManager.appHasBeenLaunchedForTheFirstTime()
        }
    }
}

Now SingletonComponent is @Singleton scoped, that means we’ve gotta come with our own scoping if we need something inside an Activity.

1
2
3
4
@Scope
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class ActivityScoped

as we know that we need an Activity component as well, we create one too, now this time we annotate it with @Subcomponent and annotate it with our own scope ActivityScoped. Since this is a subcomponent we need a @Subcomponent.Factory just like we did with the singleton component (there it was a Component.Factory).

1
2
3
4
5
6
7
8
9
10
11
@Subcomponent
@ActivityScoped
interface ActivityComponent {

    @Subcomponent.Factory
    interface Factory {
        fun create(): ActivityComponent
    }

    fun inject(mainActivity: MainActivity)
}

The main idea here is that the SingletonComponent is the parent and ActivityComponent is it’s child.

We have to make sure of that, now inside our SingletonComponent we provide the factory that creates ActivityComponent.

ActivityComponent is a child, that means every module we include in the SingletonComponent and instance annotated with @BindsInstance would be available inside the ActivityComponent as well, since it’s like ActivityComponent inherited the public variables from SingletonComponent that were annotated with @BindsInstance.

1
2
3
4
5
6
7
8
9
10
@Component
@Singleton
interface SingletonComponent {
    @Component.Factory
    interface SingletonComponentFactory {
        fun create(@BindsInstance context: Context): SingletonComponent
    }
    fun activityComponentFactory() : ActivityComponent.Factory
    fun provideGraphInside(application: DaggerIsEasyApplication)
}

the last thing we do is call the creation, that happens inside our MainActivity right before onCreate(), just like in our Application.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var sharedPreferencesManager: SharedPreferencesManager

    override fun onCreate(savedInstanceState: Bundle?) {
        (application as DaggerIsEasyApplication).applicationComponent.activityComponentFactory().create().also { 
            it.inject(this)
        }
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)


        if (sharedPreferencesManager.firstTimeLaunchMainActivitySinceInstall){
            Log.d(this::class.java.simpleName, "Main activity is launched for the first time since installation time")
            sharedPreferencesManager.mainActivityHasBeenLaunchedForTheFirstTime()
        }

    }
}

When we run the app we can see that we’re suffering from success.

For demonstrational purposes, we want to have savedInstanceState: Bundle? as a runtime variable and the intent within the ActivityScoped instances, change the ActivityComponent's subcomponent factory

1
2
3
4
5
@Subcomponent.Factory
    interface Factory {
        fun create(@BindsInstance savedInstanceState: Bundle?,
                   @BindsInstance intent: Intent): ActivityComponent
    }

and

1
2
override fun onCreate(savedInstanceState: Bundle?) {
    (application as DaggerIsEasyApplication).applicationComponent.activityComponentFactory().create(savedInstanceState, intent).also {it.inject(this)}

Let’s pretend that we have a class IntentHandler coming from a library that we didn’t create, and we included it as an implementation, that class receives an Intent param.

1
2
3
4
5
6
7
8
9
10
11
class IntentHandler(private val intent: Intent) {

    fun getObfuscatedClipData() = obfuscate(intent.clipData)

    private fun obfuscate(clipData: ClipData?): ClipData? {
        //some magic obfuscation
        //call it magic
        //call it true
        return clipData
    }
}

since we don’t own that class, we have to create a module and make Dagger be aware of it.

1
2
3
4
5
6
@Module
object ActivityModule {
    @Provides
    @ActivityScoped
    fun intentHandler(intent: Intent) = IntentHandler(intent)
}

we use @Provides since the code is coming from outside of our own project, otherwise we could’ve just annotated the class with @ActivityScoped and @Inject constructor() and voila.

However can do the following for example:

1
2
3
4
5
6
7
@ActivityScoped
class Bundler @Inject constructor(private val savedInstanceState: Bundle?) {

    fun unbundle(){
        savedInstanceState // > perform some magic
    }
}

we’ve already provided the runtime argument of savedInstanceState: Bundle? using @BindsInstance, now we can play with it.

Our activity looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var sharedPreferencesManager: SharedPreferencesManager

    @Inject
    lateinit var intentHandler: IntentHandler

    @Inject
    lateinit var bundler: Bundler

    override fun onCreate(savedInstanceState: Bundle?) {
        (application as DaggerIsEasyApplication).applicationComponent.activityComponentFactory().create(
            savedInstanceState, intent
        ).also {it.inject(this)}
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)


        if (sharedPreferencesManager.firstTimeLaunchMainActivitySinceInstall){
            Log.d(this::class.java.simpleName, "Main activity is launched for the first time since installation time")
            sharedPreferencesManager.mainActivityHasBeenLaunchedForTheFirstTime()
        }

        if (intentHandler.getObfuscatedClipData() != null){
            Log.d(this::class.java.simpleName, "send obfuscated clip data to server")
        } else {
            Log.d(this::class.java.simpleName, "obfuscated data not available, ignore")
        }

        bundler.unbundle()
    }
}

Let’s tidy up the tings a little bit, create a file called DaggerExtensions and inside

1
2
3
inline fun AppCompatActivity.injector(action: SingletonComponent.() -> Unit) {
    (application as DaggerIsEasyApplication).applicationComponent.action()
}

let’s enjoy a bit tidier code, even tidier when we’ll start using Hilt in the future articles.

Some might argue that you’ll replace vanilla Dagger completely, well… not quite, inside feature modules you’ll still need to know the concepts (especially factories and components) of pure Dagger, because Hilt doesn’t entirely replace Dagger, it’s built on top of Dagger to abstract away the Android’s runtime boilerplate setup that you have to do every time you create a new project.

Part #6

This post is licensed under CC BY 4.0 by the author.