Dependency Injection (DI) in Android

Photo by Guido Coppa on Unsplash

Dependency Injection (DI) in Android

A Comprehensive Guide

Imagine yourself as an architect, tasked with designing a colossal skyscraper. To ensure stability and reliability, you meticulously plan how each piece - from giant beams to tiny screws - works together, right? Now, consider each of these components as distinct parts of your software application. Instead of rigidly connecting them, wouldn't it be more practical and efficient to design your program so these individual parts can easily be interchanged, tested, and maintained? This is where the concept of Dependency Injection (DI) steps in, acting as the thoughtful architect of your software design.

Like a skyscraper architect, it can be challenging to fathom the full essence of Dependency Injection by merely skimming through definitions. You might have come across phrases like "Inversion of Control", "loose coupling", or "programming to interfaces, not implementations". They all serve as cryptic clues, hinting at the immense potential this design pattern holds, but fall short of painting a complete picture.

In this series of articles, we will slowly delve into the labyrinth of Dependency Injection, unearthing its core principles and demonstrating how it is crucial to building robust, scalable, and easily maintainable software. We will examine real-life scenarios, translate complex jargon into straightforward language, and demonstrate how Dependency Injection can revolutionize your software development process. By the end, you should be able to not just define Dependency Injection, but understand and apply it, much like an architect who doesn't merely know what a beam or a screw is, but knows when, where, and how to use them effectively in constructing a skyscraper.

An app without DI :

To understand dependency injection (DI), let's imagine a simple app with three classes: MainActivity.java, ComputeLayer.java, and NetworkSetup.java. These classes will perform the following tasks:

  1. NetworkSetup.java will mimic a simple class that sends data to the cloud.

  2. ComputeLayer.java will mimic the behavior of a class that has all the business logic. For our use case, we will imagine a class that can add, subtract, multiply, and divide numbers.

  3. Finally, MainActivity will use the above classes to add numbers and mimic sending that data to a mocked method to the cloud.

In our case, MainActivity will instantiate an object of ComputeLayer.java, while ComputeLayer.java will in turn create an instance of NetworkSetup.java.

Let's start by creating the most basic layer of our app, the NetworkSetup class:

import android.util.Log;

public class NetworkSetup {
    String TAG = this.getClass().getCanonicalName();

    NetworkSetup(long delay){
        try {
            Log.e(TAG, "Initialising network" );
            Thread.sleep(delay);
            Log.e(TAG,"Network initialization done");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public boolean sendDataToCloud(String data){
        Log.e(TAG,"Sending Data to cloud :: " + data);
        return true;
    }
}

As evident from the code above, this will simply log a few strings when instantiated and when its methods are called. There's a delay added in the network setup constructor to mimic the behavior of an app where the instantiation of the constructor takes some time.

Now let's use this class to create our next layer, the ComputeLayer. This layer will use the Network layer to send data to our mock API method while doing all the calculations as shown below:

import android.util.Log;

public class ComputeLayer {
    String TAG = this.getClass().getCanonicalName();
    String layer = "Computation";
    NetworkSetup network;

    ComputeLayer(){
        this.network = new NetworkSetup(4000);
    }

    public void add (int a , int b){
        int c = a+b;
        Log.e(TAG, " addition result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }

    public void subtract (int a , int b){
        int c = a-b;
        Log.e(TAG, " subtraction result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }

    public void divide (int a , int b){
        double c = a/b;
        Log.e(TAG, " division result " + c );
        network.sendDataToCloud(Double.toString(c));
    }

    public void multiply (int a , int b){
        int c = a*b;
        Log.e(TAG, " multiplication result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }
}

Now let's create our topmost layer, where we will use these methods in the UI:

class MainActivity : AppCompatActivity() {
    var calculate : Button? = null
    var TAG = this.javaClass.canonicalName
    var computation : ComputeLayer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        calculate = findViewById(R.id.calculate_sum)

        computation = ComputeLayer()

        calculate?.setOnClickListener {
            computation?.add(1,1)
            Log.e(TAG, "Button click detected")
        }
    }
}

So here we create an object of the class ComputeLayer and then call the add function. This will do two things:

  1. It'll add the two numbers.

  2. It'll call the NetworkSetup class and send data to the mocked API method.

The code above works as intended even without any trace of dependency injection.

Then why do we need DI ?

To understand why we might need DI, let us consider the following cases:

  • 1: We are instantiating the NetworkSetup class in the constructor of the ComputeLayer class, which is not really efficient because this would mean that we'll create an NetworkSetup object each and every time when we create a ComputeLayer object.

    The NetworkSetup object is simply sending data to the cloud and hence instead of recreating it again and again we should use the same object (this will also decrease the time taken for a computation to execute because the delay of 4000 ms will only be called once when we first create this object).

  • 2: Another issue is that if we want to unit test our code, it'd be difficult in our above code because each of the classes is doing more than one job. For example, the ComputeLayer is also responsible for instantiating the NetworkSetup class.

Manual Dependency Injection

From the code above, you might have noticed that one of the issues with initializing the NetworkSetup inside the ComputeLayer constructor can be solved simply if we remove the object creation from Compute Layer and instead request its instance from outside as shown below:

import android.util.Log;

public class ComputeLayer {
    String TAG = this.getClass().getCanonicalName();
    String layer = "Computation";
    NetworkSetup network;

    ComputeLayer(NetworkSetup networkSetup){
        this.network = networkSetup;
    }

    public void add (int a , int b){
        int c = a+b;
        System.out.println(layer + " addition result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }

    public void subtract (int a , int b){
        int c = a-b;
        Log.e(TAG, " subtraction result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }

    public void divide (int a , int b){
        double c = a/b;
        Log.e(TAG, " division result " + c );
        network.sendDataToCloud(Double.toString(c));
    }

    public void multiply (int a , int b){
        int c = a*b;
        Log.e(TAG, " multiplication result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }
}

Now, instead of creating our NetworkSetup inside the ComputeLayer, we are demanding it from outside i.e whichever class chooses to instantiate the ComputeLater class. This way unit testing this class becomes easier, because instead of using resource-intensive production classes, a user can simply use a mock class and pass it to the constructor from outside.

Now let us look at our MainActivity class:

class MainActivity : AppCompatActivity() {
    var calculate : Button? = null
    var TAG = this.javaClass.canonicalName
    var computation : ComputeLayer? = null
    var networkSetup: NetworkSetup? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        calculate = findViewById(R.id.calculate_sum)
        networkSetup = NetworkSetup(6000)
        computation = ComputeLayer(networkSetup)

        calculate?.setOnClickListener {
            computation?.add(1,1)
            Log.e(TAG, "Button click detected")
        }
    }
}

Notice how the networkSetup object is now created separately from the ComputeLayer object, this gives us the flexibility to use the same NetworkSetup object in multiple places.

This is a simple example of manual dependency injection, where we are passing the dependencies of a class through its constructor. This is a very basic form of dependency injection and works well for small projects.

But!

As the project grows, the number of dependencies for a class can grow as well. This can make the code hard to read, for e.g if we had another class that handled saving data to the Storage (we'll look into this practically later in the upcoming blog post), we'd have to create an object for that manually as well and inject it into the constructor. Hence it will keep getting complicated as the number of layers and classes increase.

Dagger: The Saviour

To solve the problem of manual dependency injection, we can use a dependency injection framework. Dagger is one such framework that is widely used in Android. Dagger is a fully static, compile-time dependency injection framework for both Java and Android.

Let's see how we can use Dagger in our project.

Setting Up Dagger2

To set up Dagger2, you need to add the following dependencies to your project:

def dagger_version = '2.46.1'
implementation "com.google.dagger:dagger:$dagger_version"
kapt "com.google.dagger:dagger-compiler:$dagger_version"

After syncing the project, we will create an interface named ComputeComponent. This will be the backbone of our Dagger project and it will work as the entry point for our Dagger graph. This class should look like the following:

import dagger.Component;

@Component
public interface ComputeComponent {
    //annotation processing
    ComputeLayer getComputeLayer();
}

Notice how the class is annotated with @Component. This annotation helps Dagger understand the entry point for the application and create the dependency graph. Next, we create an abstract method as shown above (the name of the method doesn’t really matter), only the type.

Updating NetworkSetup and ComputeLayer Classes

Next, we update our NetworkSetup class and the ComputeLayer class as shown below:

public class NetworkSetup {
    String TAG = this.getClass().getCanonicalName();
    @Inject
    NetworkSetup(){
        try {
            Log.e(TAG, "Initialising network" );
            Thread.sleep(6000);
            Log.e(TAG,"Network initialization done");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public boolean sendDataToCloud(String data){
        Log.e(TAG,"Sending Data to cloud :: " + data);
        return true;
    }
}
import javax.inject.Inject;

public class ComputeLayer {
    String TAG = this.getClass().getCanonicalName();
    String layer = "Computation";
    NetworkSetup network;

    @Inject
    ComputeLayer(NetworkSetup networkSetup){
        this.network = networkSetup;
    }

    public void add (int a , int b){
        int c = a+b;
        System.out.println(layer + " addition result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }

    public void subtract (int a , int b){
        int c = a-b;
        Log.e(TAG, " subtraction result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }

    public void divide (int a , int b){
        double c = a/b;
        Log.e(TAG, " division result " + c );
        network.sendDataToCloud(Double.toString(c));
    }

    public void multiply (int a , int b){
        int c = a*b;
        Log.e(TAG, " multiplication result " + c );
        network.sendDataToCloud(Integer.toString(c));
    }
}

Notice the @Inject annotation in the constructor. This helps Dagger understand when an object should be created and provided to the app.

Updating MainActivity

Now, we can remove all the manual dependency injections that we did in our MainActivity and simply use the component class to get the required objects:

class MainActivity : AppCompatActivity() {
    var calculate : Button? = null
    var TAG = this.javaClass.canonicalName
    var computation : ComputeLayer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        calculate = findViewById(R.id.calculate_sum)
        val component = DaggerComputeComponent.create()
        computation = component.computeLayer

        calculate?.setOnClickListener {
            computation?.add(1,1)
            Log.e(TAG, "Button click detected")
        }
    }
}

Notice that we have NOT manually created the NetworkSetup class anywhere. It’s done by Dagger and provided to us. The above method of dependency injection is also known as Method Injection.

And viola!!, if you run the app now, it'll print all the logs as expected even though we haven't provided any dependencies to our objects manually.

A Word of Caution

While the above introduction to @Inject and @Component annotations in Dagger 2 provide a fundamental understanding of how dependency injection works, it's important to stress that this is just the tip of the iceberg. It represents a simplified implementation, tailored to shed light on the foundational concepts and workings of Dagger 2.

Conclusion

The journey, however, does not end here. This is merely the introductory installment in a series of blog posts dedicated to delving deeper into the labyrinth of Dagger 2. In our upcoming posts, we will venture further, covering a wide array of topics such as @Module and @Provides annotations, scoping, managing dependency graphs, and much more. Each piece will unravel more of the Dagger 2 puzzle, bringing you closer to becoming proficient in using this framework for your production-grade applications.

Stay tuned for these exciting, in-depth explorations. See you in the next post!