paint-brush
Dependency Injection With Dagger 2: What Is It, Key Concepts, and Moreby@dilip2882
412 reads
412 reads

Dependency Injection With Dagger 2: What Is It, Key Concepts, and More

by Dilip PatelAugust 28th, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

Dependency Injection (DI) is a design pattern used to implement Inversion of Control (IoC) It is a technique where the responsibility of creating objects is transferred to other parts of the code. This promotes loose coupling, making the code more modular and easier to manage.
featured image - Dependency Injection With Dagger 2: What Is It, Key Concepts, and More
Dilip Patel HackerNoon profile picture

Dependency Injection in Android

Introduction to Dependency Injection

Dependency Injection (DI) is a design pattern used to implement Inversion of Control (IoC) where the control of creating and managing dependencies is transferred from the application to an external entity. This helps in creating more modular, testable, and maintainable code.


It is a technique where the responsibility of creating objects is transferred to other parts of the code. This promotes loose coupling, making the code more modular and easier to manage.


Classes often need references to other classes to function properly. For instance, consider a Library class that requires a Book class. These necessary classes are known as dependencies. The Library class depends on having an instance of the Book class to operate.

hyperskill.org

There are three primary ways for a class to obtain the objects it needs:

  1. Self-construction: The class creates and initializes its own dependencies. For example, the Library class would create and initialize its own instance of the Book class.


  2. External retrieval: The class retrieves dependencies from an external source. Some Android APIs, such as Context getters and getSystemService(), work this way.


  3. Dependency Injection: Dependencies are provided to the class, either when it is constructed or through methods that require them. For example, the Library constructor would receive a Book instance as a parameter.


The third option is dependency injection! With DI, you provide the dependencies of a class rather than having the class instance obtain them itself.

Example Without Dependency Injection

Without DI, a Library that creates its own Book dependency might look like this:

class Library {
    private Book book = new Book();

   void open() {
        book.read();
    }
}

public class Main {
    public static void main(String[] args) {
        Library library = new Library();
        library.open();
    }
}

This is not an example of DI because the Library class constructs its own Book. This can be problematic because:

  • Tight coupling: Library and Book are tightly coupled. An instance of Library uses one type of Book, making it difficult to use subclasses or alternative implementations.


  • Testing difficulties: The hard dependency on Book makes testing more challenging. Library uses a real instance of Book, preventing the use of test doubles to modify Book for different test cases.

Example With Dependency Injection

With DI, instead of each instance of Library constructing its own Book object, it receives a Book object as a parameter in its constructor:

class Library {
    private Book book;

Library(Book book) {
        this.book = book;
    }
    void open() {
        book.read();
    }
}

public class Main {
    public static void main(String[] args) {
        Book book = new Book();
        Library library = new Library(book);
        library.open();
    }

The main function uses Library. Since Library depends on Book, the app creates an instance of Book and then uses it to construct an instance of Library. The benefits of this DI-based approach are:


  • Reusability ofLibrary: You can pass in different implementations of Book to Library. For example, you might define a new subclass of Book called EBook that you want Library to use. With DI, you simply pass an instance of EBook to Library, and it works without any further changes.


  • Easy testing ofLibrary: You can pass in test doubles to test different scenarios.

Another DI Example

Consider a scenario where a NotificationService class relies on a Notification class. Without DI, the NotificationService directly creates an instance of Notification, making it difficult to use different types of notifications or to test the service with various notification implementations.


To illustrate DI, let’s refactor this example:

interface Notification {
    void send();
}
class EmailNotification implements Notification {
    @Override
    public void send() {
        // Send email notification
    }
}
class SMSNotification implements Notification {
    @Override
    public void send() {
        // Send SMS notification
    }
}
class NotificationService {
    void sendNotification(Notification notification) {
        notification.send();
    }
}

Now, NotificationService depends on the Notification interface rather than a specific class. This allows different implementations of Notification to be used interchangeably. You can set the implementation you want to use through the sendNotification method:

NotificationService service = new NotificationService();
service.sendNotification(new EmailNotification());
service.sendNotification(new SMSNotification());

Methods of Dependency Injection in Android

There are three main types of DI:

  1. Method (Interface) Injection: Dependencies are passed through methods that the class can access via an interface or another class. The previous example demonstrates method injection.


  2. Constructor Injection: Dependencies are passed to the class through its constructor.

class NotificationService {
    private final Notification notification;

public NotificationService(Notification notification) {
        this.notification = notification;
    }
    public void sendNotification() {
        notification.send();
    }
}
public class Main {
    public static void main(String[] args) {
        NotificationService service = new NotificationService(new EmailNotification());
        service.sendNotification();
    }
}

3. Field Injection (or Setter Injection): Certain Android framework classes, such as activities and fragments, are instantiated by the system, so constructor injection is not possible. With field injection, dependencies are instantiated after the class is created.


class NotificationService {
    private Notification notification;

public Notification getNotification() {
        return notification;
    }
    public void setNotification(Notification notification) {
        this.notification = notification;
    }
    public void sendNotification() {
        notification.send();
    }
}
public class Main {
    public static void main(String[] args) {
        NotificationService service = new NotificationService();
        service.setNotification(new EmailNotification());
        service.sendNotification();
    }
}

4. Method Injection: Dependencies are provided through methods, often using the @Inject annotation.

Advantages of Dependency Injection

  • Classes become more reusable and less dependent on specific implementations. This is due to the inversion of control, where classes no longer manage their dependencies but work with any configuration provided.


  • Dependencies are part of the API surface and can be verified at object creation or compile time, making refactoring easier.


  • Since a class does not manage its dependencies, different implementations can be passed during testing to cover various scenarios.

Automated Dependency Injection

In the previous example, you manually created, provided, and managed the dependencies of different classes without using a library. This approach is known as manual dependency injection. While it works for simple cases, it becomes cumbersome as the number of dependencies and classes increases. Manual dependency injection has several drawbacks:


  • Boilerplate Code: For large applications, managing all dependencies and connecting them correctly can result in a lot of repetitive code. In a multi-layered architecture, creating an object for a top layer requires providing all dependencies for the layers below it. For instance, to build a computer, you need a CPU, a motherboard, RAM, and other components; and a CPU might need transistors and capacitors.


  • Complex Dependency Management: When you can’t construct dependencies beforehand — such as with lazy initializations or scoping objects to specific flows in your app — you need to write and maintain a custom container (or dependency graph) to manage the lifetimes of your dependencies in memory.


Libraries can automate this process by creating and providing dependencies for you. These libraries fall into two categories:


  1. Reflection-based Solutions: These connect dependencies at runtime.


  2. Static Solutions: These generate code to connect dependencies at compile time.

Dagger is a popular dependency injection library for Java, Kotlin, and Android, maintained by Google. Dagger simplifies DI in your app by creating and managing the dependency graph for you. It provides fully static, compile-time dependencies, addressing many of the development and performance issues associated with reflection-based solutions like Guice.

Reflection-based Solutions

These frameworks connect dependencies at runtime:

  1. Toothpick: A runtime DI framework that uses reflection to connect dependencies. It’s designed to be lightweight and fast, making it suitable for Android applications.

Static Solutions

These frameworks generate code to connect dependencies at compile time:

  1. Hilt: Built on top of Dagger, Hilt provides a standard way to incorporate Dagger dependency injection into an Android application. It simplifies the setup and usage of Dagger by providing predefined components and scopes.


  2. Koin: A lightweight and simple DI framework for Kotlin. Koin uses a DSL to define dependencies and is easy to set up and use.


  3. Kodein: A Kotlin-based DI framework that is easy to use and understand. It provides a simple and flexible API for managing dependencies.

Alternatives to Dependency Injection

An alternative to dependency injection is the service locator pattern. This design pattern also helps decouple classes from their concrete dependencies. You create a class known as the service locator that creates and stores dependencies, providing them on demand.

object ServiceLocator {
    fun getProcessor(): Processor = Processor()
}
class Computer {
    private val processor = ServiceLocator.getProcessor()
    fun start() {
        processor.run()
    }
}
fun main(args: Array<String>) {
    val computer = Computer()
    computer.start()
}

The service locator pattern differs from dependency injection in how dependencies are consumed. With the service locator pattern, classes request the dependencies they need; with dependency injection, the app proactively provides the required objects.

What is Dagger 2?

Dagger 2 is a popular DI framework for Android. It uses compile-time code generation and is known for its high performance. Dagger 2 simplifies the process of dependency injection by generating the necessary code to handle dependencies, reducing boilerplate and improving efficiency.


Dagger 2 is an annotation-based library for dependency injection in Android. Here are the key annotations and their purposes:

  • @Module: Used to define classes that provide dependencies. For example, a module can provide an ApiClient for Retrofit.
  • @Provides: Annotates methods in a module to specify how to create and return dependencies.
  • @Inject: Used to request dependencies. Can be applied to fields, constructors, and methods.
  • @Component: An interface that bridges @Module and @Inject. It contains all the modules and provides the builder for the application.
  • @Singleton: Ensures a single instance of a dependency is created.
  • @Binds: Used in abstract classes to provide dependencies, similar to @Provides but more concise.

Dagger Components

Dagger can generate a dependency graph for your project, allowing it to determine where to obtain dependencies when needed. To enable this, you need to create an interface and annotate it with @Component.


Within the @Component interface, you define methods that return instances of the classes you need (e.g., BookRepository). The @Component annotation instructs Dagger to generate a container with all the dependencies required to satisfy the types it exposes. This container is known as a Dagger component, and it contains a graph of objects that Dagger knows how to provide along with their dependencies.


Example

Let’s consider an example involving a LibraryRepository:

  1. Annotate the Constructor: Add an @Inject annotation to the LibraryRepository constructor so Dagger knows how to create an instance of LibraryRepository.
public class LibraryRepository {
    private final LocalLibraryDataSource localDataSource;
    private final RemoteLibraryDataSource remoteDataSource;

    @Inject
    public LibraryRepository(LocalLibraryDataSource localDataSource, RemoteLibraryDataSource remoteDataSource) {
        this.localDataSource = localDataSource;
        this.remoteDataSource = remoteDataSource;
    }
}

2. Annotate Dependencies: Similarly, annotate the constructors of the dependencies (LocalLibraryDataSource and RemoteLibraryDataSource) so Dagger knows how to create them.

public class LocalLibraryDataSource {

    @Inject
    public LocalLibraryDataSource() {
        // Initialization code
    }
}

public class RemoteLibraryDataSource {
    private final LibraryService libraryService;

    @Inject
    public RemoteLibraryDataSource(LibraryService libraryService) {
        this.libraryService = libraryService;
    }
}

3. Define the Component: Create an interface annotated with @Component to define the dependency graph.

@Component
public interface ApplicationComponent {
    LibraryRepository getLibraryRepository();
}

When you build the project, Dagger generates an implementation of the ApplicationComponent interface for you, typically named DaggerApplicationComponent.

Usage

You can now use the generated component to obtain instances of your classes with their dependencies automatically injected:

public class MainApplication extends Application {
    private ApplicationComponent applicationComponent;

@Override
    public void onCreate() {
        super.onCreate();
        applicationComponent = DaggerApplicationComponent.create();
    }
    public ApplicationComponent getApplicationComponent() {
        return applicationComponent;
    }
}


In your activity or fragment, you can retrieve the LibraryRepository instance:

public class MainActivity extends AppCompatActivity {

    @Inject
    LibraryRepository libraryRepository;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ((MainApplication) getApplication()).getApplicationComponent().inject(this);
        // Use the injected libraryRepository
    }
}

Key Concepts in Dagger 2

1. Modules


∘ Key Concepts of Modules


∘ Including Modules in Components


2. Scopes


3. Components


4. Component Dependencies


5. Runtime Bindings

1. Modules

Modules in Dagger 2 are classes annotated with @Module that provide dependencies to the components. They contain methods annotated with @Provides or @Binds to specify how to create and supply dependencies. Modules are essential for organizing and managing the creation of objects that your application needs.

Key Concepts of Modules

  1. @Module Annotation: This annotation is used to define a class as a Dagger module. A module class contains methods that provide dependencies.


  2. @Provides Annotation: This annotation is used on methods within a module to indicate that the method provides a certain dependency. These methods are responsible for creating and returning instances of the dependencies.


  3. @Binds Annotation: This annotation is used in abstract classes to bind an implementation to an interface. It is more concise than @Provides and is used when the module is an abstract class.


Example of a Module

@Module
public class NetworkModule {

    @Provides
    @Singleton
    Retrofit provideRetrofit() {
        return new Retrofit.Builder()
                .baseUrl("https://api.example.com")
                .addConverterFactory(GsonConverterFactory.create())
                .build();
    }

    @Provides
    @Singleton
    OkHttpClient provideOkHttpClient() {
        return new OkHttpClient.Builder()
                .addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
                .build();
    }
}

In this example, NetworkModule is a class annotated with @Module. It contains two methods annotated with @Provides that create and return instances of Retrofit and OkHttpClient.


Using @Binds

When you have an interface and its implementation, you can use @Binds to bind the implementation to the interface. This is more concise than using @Provides.

public interface ApiService {
    void fetchData();
}

public class ApiServiceImpl implements ApiService {
    @Override
    public void fetchData() {
        // Implementation
    }
}

@Module
public abstract class ApiModule {
    @Binds
    abstract ApiService bindApiService(ApiServiceImpl apiServiceImpl);
}

In this example, ApiModule is an abstract class annotated with @Module. The bindApiService method is annotated with @Binds to bind ApiServiceImpl to ApiService.


Modules can be organized based on the functionality they provide. For example, you can have separate modules for network operations, database operations, and UI-related dependencies.


Example:

  • NetworkModule: Provides network-related dependencies like Retrofit and OkHttpClient.
  • DatabaseModule: Provides database-related dependencies like RoomDatabase.
  • UIModule: Provides UI-related dependencies like ViewModel and Presenter.

Including Modules in Components

Modules are included in components to provide dependencies to the classes that need them. Here’s how you can set it up:

ApplicationComponent.java:

@Singleton
@Component(modules = {NetworkModule.class, DatabaseModule.class})
public interface ApplicationComponent {
    void inject(MyApplication application);
}

In this example, ApplicationComponent includes NetworkModule and DatabaseModule to provide dependencies to the application.

2. Scopes

Scopes in Dagger 2 are annotations that define the lifecycle of dependencies. They ensure that a single instance of a dependency is created and shared within a specified scope. This helps in managing memory efficiently and ensuring that dependencies are reused where appropriate.


  • Singleton Scope: Ensures a single instance of a dependency throughout the application’s lifecycle.
  • Activity Scope: Ensures a single instance of a dependency within the lifecycle of an activity.
  • Fragment Scope: Ensures a single instance of a dependency within the lifecycle of a fragment.


1. Singleton Scope

Definition: The @Singleton scope ensures that a single instance of a dependency is created and shared throughout the entire application’s lifecycle.


This scope is typically used for dependencies that need to be shared across the entire application, such as network clients, database instances, or shared preferences.


Example:

@Singleton
@Component(modules = {NetworkModule.class, DatabaseModule.class})
public interface ApplicationComponent {
    void inject(MyApplication application);
}

In this example, the @Singleton annotation ensures that the Retrofit and Database instances provided by NetworkModule and DatabaseModule are singletons and shared across the entire application.


2. Activity Scope


Definition: The @ActivityScope (a custom scope) ensures that a single instance of a dependency is created and shared within the lifecycle of an activity.


This scope is useful for dependencies that are specific to an activity and should be recreated each time the activity is recreated, such as presenters or view models.


Example:

@Scope
@Retention(RetentionPolicy.RUNTIME)
public @interface ActivityScope {
}

@ActivityScope
@Component(dependencies = ApplicationComponent.class, modules = ActivityModule.class)
public interface ActivityComponent {
    void inject(MainActivity mainActivity);
}

In this example, the @ActivityScope annotation ensures that dependencies provided by ActivityModule are scoped to the lifecycle of the activity.


3. Fragment Scope


Definition: The @FragmentScope (another custom scope) ensures that a single instance of a dependency is created and shared within the lifecycle of a fragment.


Use Case: This scope is useful for dependencies that are specific to a fragment and should be recreated each time the fragment is recreated, such as fragment-specific presenters or view models.


Example:

@Scope
@Retention(RetentionPolicy.RUNTIME)
public @interface FragmentScope {
}

@FragmentScope
@Component(dependencies = ActivityComponent.class, modules = FragmentModule.class)
public interface FragmentComponent {
    void inject(MyFragment myFragment);
}

In this example, the @FragmentScope annotation ensures that dependencies provided by FragmentModule are scoped to the lifecycle of the fragment.

3. Components

Component dependencies allow one component to depend on another, enabling the reuse of dependencies. There are two main types of component dependencies:


  • Application Component: Provides dependencies that are needed throughout the entire application.
  • Activity Component: Provides dependencies that are needed within a specific activity.


1. Application Component


Definition: The Application Component provides dependencies that are needed throughout the entire application. It is typically scoped with @Singleton to ensure that the dependencies are shared across the application.


This component is used for dependencies that need to be available globally, such as network clients, database instances, or shared preferences.


Example:

@Singleton
@Component(modules = {NetworkModule.class, DatabaseModule.class})
public interface ApplicationComponent {
    void inject(MyApplication application);
}

In this example, the ApplicationComponent is responsible for providing Retrofit and Database instances, which are shared across the entire application.


2. Activity Component


Definition: The Activity Component provides dependencies that are needed within a specific activity. It is typically scoped with a custom scope, such as @ActivityScope, to ensure that the dependencies are recreated each time the activity is recreated.


This component is used for dependencies that are specific to an activity, such as presenters or view models.


Example:

@ActivityScope
@Component(dependencies = ApplicationComponent.class, modules = ActivityModule.class)
public interface ActivityComponent {
    void inject(MainActivity mainActivity);
}

In this example, the ActivityComponent depends on the ApplicationComponent and provides dependencies specific to the MainActivity.

4. Component Dependencies

Component dependencies allow one component to depend on another, enabling the reuse of dependencies. There are two main types of component dependencies:


  • Subcomponents: A subcomponent is a child of another component and can access its parent’s dependencies.
  • Dependency Attribute: This allows a component to depend on another component without being a subcomponent.


1. Subcomponents:


A subcomponent is a child of another component and can access its parent’s dependencies. Subcomponents are defined within the parent component and can inherit its scope.


Example:

@ActivityScope
@Subcomponent(modules = ActivityModule.class)
public interface ActivitySubcomponent {
    void inject(MainActivity mainActivity);
}

In this example, ActivitySubcomponent is a subcomponent of the parent component and can access its dependencies.

2. Dependency Attribute


This allows a component to depend on another component without being a subcomponent. The dependent component can access the dependencies provided by the parent component.

Example:

@ActivityScope
@Component(dependencies = ApplicationComponent.class, modules = ActivityModule.class)
public interface ActivityComponent {
    void inject(MainActivity mainActivity);
}

In this example, ActivityComponent depends on ApplicationComponent and can access its dependencies.

5. Runtime Bindings

Runtime bindings in Dagger 2 refer to the provision of dependencies that are created and managed at runtime, based on the context in which they are needed.


  • Application Context: Used for dependencies that need to live as long as the application.
  • Activity Context: Used for dependencies that need to live as long as an activity.


1. Application Context


Definition: The application context is a context that is tied to the lifecycle of the entire application. It is used for dependencies that need to live as long as the application itself.


Dependencies are shared across the entire application and do not need to be recreated for each activity or fragment. Examples include network clients, database instances, and shared preferences.


Example:

@Module
public class AppModule {
    private final Application application;

public AppModule(Application application) {
        this.application = application;
    }
    @Provides
    @Singleton
    Application provideApplication() {
        return application;
    }
    @Provides
    @Singleton
    Context provideApplicationContext() {
        return application.getApplicationContext();
    }
}

In this example, AppModule provides the application context as a singleton dependency. The provideApplicationContext method ensures that the context provided is tied to the lifecycle of the application.


2. Activity Context


Definition: The activity context is a context that is tied to the lifecycle of a specific activity. It is used for dependencies that need to live as long as the activity itself.


Dependencies that are specific to an activity and should be recreated each time the activity is recreated. Examples include view models, presenters, and UI-related dependencies.


Example:

@Module
public class ActivityModule {
    private final Activity activity;

public ActivityModule(Activity activity) {
        this.activity = activity;
    }
    @Provides
    @ActivityScope
    Activity provideActivity() {
        return activity;
    }
    @Provides
    @ActivityScope
    Context provideActivityContext() {
        return activity;
    }
}

In this example, ActivityModule provides the activity context as a scoped dependency. The provideActivityContext method ensures that the context provided is tied to the lifecycle of the activity.

Using Runtime Bindings in Components

To use these runtime bindings, you need to include the corresponding modules in your components:

Application Component:

@Singleton
@Component(modules = {AppModule.class, NetworkModule.class})
public interface ApplicationComponent {
    void inject(MyApplication application);
    Context getApplicationContext();
}


Activity Component:

@ActivityScope
@Component(dependencies = ApplicationComponent.class, modules = ActivityModule.class)
public interface ActivityComponent {
    void inject(MainActivity mainActivity);
    Context getActivityContext();
}

Injecting Contexts

Once you have set up your components and modules, you can inject the contexts into your classes as needed.


Example in an Activity:

public class MainActivity extends AppCompatActivity {

    @Inject
    Context activityContext;
    @Inject
    Context applicationContext;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ApplicationComponent appComponent = ((MyApplication) getApplication()).getApplicationComponent();
        ActivityComponent activityComponent = DaggerActivityComponent.builder()
                .applicationComponent(appComponent)
                .activityModule(new ActivityModule(this))
                .build();
        activityComponent.inject(this);
        // Use the injected contexts
        Log.d("MainActivity", "Activity Context: " + activityContext);
        Log.d("MainActivity", "Application Context: " + applicationContext);
    }
}

In this example, MainActivity receives both the activity context and the application context through dependency injection. This allows the activity to use the appropriate context based on the specific needs of the dependencies.

Example: Using Dagger 2 in an Android Application

Setting Up Dagger 2

To use Dagger 2 in your project, you need to add the following dependencies to your build.gradle file:

dependencies {
    implementation 'com.google.dagger:dagger:2.x'
    annotationProcessor 'com.google.dagger:dagger-compiler:2.x'
}

Replace 2.x with the latest version of Dagger 2.

Step 1: Define a Module

Create a module to provide dependencies. For example, a NetworkModule to provide a Retrofit instance:

@Module
public class NetworkModule {

    @Provides
    @Singleton
    Retrofit provideRetrofit() {
        return new Retrofit.Builder()
                .baseUrl("https://api.example.com")
                .addConverterFactory(GsonConverterFactory.create())
                .build();
    }
}

Step 2: Define a Component

Create a component to bridge the module and the classes that need the dependencies:

@Singleton
@Component(modules = {NetworkModule.class})
public interface ApplicationComponent {
    void inject(MyApplication application);
}

Step 3: Inject Dependencies

Use the component to inject dependencies into your classes. For example, in your Application class:

public class MyApplication extends Application {

private ApplicationComponent applicationComponent;
    @Override
    public void onCreate() {
        super.onCreate();
        applicationComponent = DaggerApplicationComponent.builder()
                .networkModule(new NetworkModule())
                .build();
        applicationComponent.inject(this);
    }
    public ApplicationComponent getApplicationComponent() {
        return applicationComponent;
    }
}

Step 4: Use Injected Dependencies

Now, you can use the injected dependencies in your classes. For example, in an Activity:

public class MainActivity extends AppCompatActivity {

    @Inject
    Retrofit retrofit;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ((MyApplication) getApplication()).getApplicationComponent().inject(this);
        // Use the injected Retrofit instance
        // ...
    }
}

Conclusion

Let’s summarize this topic:

  • The main point of DI is to loosen coupling, making it easier to manage dependencies.
  • By using DI, you can increase code flexibility and simplify the testing process.
  • DI is a complex topic with different implementations based on the scenario.
  • DI in different languages has peculiarities that can affect how you work with it.
  • Dagger 2 automates the process of creating and providing dependencies, reducing boilerplate code and improving maintainability.
  • Dagger 2 provides compile-time safety, ensuring that all dependencies are satisfied before the application runs.
  • By generating code at compile time, Dagger 2 avoids the performance overhead associated with reflection-based solutions.