Android – How To Apply Manual Dependency Injection In A Real Android Application ?

Hello Readers, CoolMonkTechie heartily welcomes you in this article (How To Apply Manual Dependency Injection In A Real Android Application ?).

In this article, we will learn about how to apply manual dependency injection in a real android application. Dependency Injection is a good technique for creating scalable and testable Android applications. When our application gets larger, we will start seeing that we write a lot of boilerplate code (such as factories), which can be error-prone. We also have to manage the scope and lifecycle of the containers ourself, optimizing and discarding containers that are no longer needed in order to free up memory. Doing this incorrectly can lead to subtle bugs and memory leaks in our app. This article reviews an iterated approach of how we might start using manual dependency injection in our application.

A famous quote about learning is :

” Anyone who stops learning is old, whether at twenty or eighty. Anyone who keeps learning stays young. The greatest thing in life is to keep your mind young. “

So Let’s begin.

Manual Dependency Injection

Android’s recommended app architecture encourages dividing our code into classes to benefit from separation of concerns, a principle where each class of the hierarchy has a single defined responsibility. This leads to more, smaller classes that need to be connected together to fulfill each other’s dependencies.

A Model Of An Android App's Application Graph
Source : Android Developers – A Model Of An Android App’s Application Graph

The dependencies between classes can be represented as a graph, in which each class is connected to the classes it depends on. The representation of all our classes and their dependencies makes up the application graph. In figure 1, we can see an abstraction of the application graph. When class A (ViewModel) depends on class B (Repository), there’s a line that points from A to B representing that dependency.

Dependency injection helps make these connections and enables us to swap out implementations for testing. For example, when testing a ViewModel that depends on a repository, we can pass different implementations of Repository with either fakes or mocks to test the different cases.

The approach improves until it reaches a point that is very similar to what Dagger would automatically generate for us.

Example – Login Flow For A Typical Android Application

Consider a flow to be a group of screens in our app that correspond to a feature. Login, registration, and checkout are all examples of flows.

When covering a login flow for a typical Android application, the LoginActivity depends on LoginViewModel, which in turn depends on UserRepository. Then UserRepository depends on a UserLocalDataSource and a UserRemoteDataSource, which in turn depends on a Retrofit service.

A Login Flow For A Typical Android Application
Source : Android Developers –  A Login Flow For A Typical Android Application

LoginActivity is the entry point to the login flow and the user interacts with the activity. Thus, LoginActivity needs to create the LoginViewModel with all its dependencies.

The Repository and DataSource classes of the flow look like this:

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

Here’s what LoginActivity looks like:

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // In order to satisfy the dependencies of LoginViewModel, you have to also
        // satisfy the dependencies of all of its dependencies recursively.
        // First, create retrofit which is the dependency of UserRemoteDataSource
        val retrofit = Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService::class.java)

        // Then, satisfy the dependencies of UserRepository
        val remoteDataSource = UserRemoteDataSource(retrofit)
        val localDataSource = UserLocalDataSource()

        // Now you can create an instance of UserRepository //that LoginViewModel needs
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        // Lastly, create an instance of LoginViewModel with //userRepository
        loginViewModel = LoginViewModel(userRepository)
    }
}

There are issues with this approach:

  • There’s a lot of boilerplate code. If we wanted to create another instance of LoginViewModel in another part of the code, we’d have code duplication.
  • Dependencies have to be declared in order. We have to instantiate UserRepository before LoginViewModel in order to create it.
  • It’s difficult to reuse objects. If we wanted to reuse UserRepository across multiple features, we’d have to make it follow the singleton pattern. The singleton pattern makes testing more difficult because all tests share the same singleton instance.

Example Solutions

Managing Dependencies With A Container

To solve the issue of reusing objects, we can create our own dependencies container class that we use to get dependencies. All instances provided by this container can be public. In the example, because we only need an instance of UserRepository, we can make its dependencies private with the option of making them public in the future if they need to be provided:

// Container of objects shared across the whole app
class AppContainer {

    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

    private val remoteDataSource = UserRemoteDataSource(retrofit)
    private val localDataSource = UserLocalDataSource()

    // userRepository is not private; it'll be exposed
    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

Because these dependencies are used across the whole application, they need to be placed in a common place all activities can use: the application class. Create a custom application class that contains an AppContainer instance.

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

We aware that AppContainer is just a regular class with a unique instance shared across the app placed in our application class. However, AppContainer is not following the singleton pattern; in Kotlin, it’s not an object, and in Java, it’s not accessed with the typical Singleton.getInstance() method.

Now we can get the instance of the AppContainer from the application and obtain the shared of UserRepository instance:

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Gets userRepository from the instance of AppContainer in Application
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

In this way, we don’t have a singleton UserRepository. Instead, we have an AppContainer shared across all activities that contains objects from the graph and creates instances of those objects that other classes can consume.

If LoginViewModel is needed in more places in the application, having a centralized place where we create instances of LoginViewModel makes sense. We can move the creation of LoginViewModel to the container and provide new objects of that type with a factory. The code for a LoginViewModelFactory looks like this:

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

We can include the LoginViewModelFactory in the AppContainer and make the LoginActivity consume it:

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Gets LoginViewModelFactory from the application instance of AppContainer
        // to create a new LoginViewModel instance
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = appContainer.loginViewModelFactory.create()
    }
}

This approach is better than the previous one, but there are still some challenges to consider:

  • We have to manage AppContainer ourself, creating instances for all dependencies by hand.
  • There is still a lot of boilerplate code. We need to create factories or parameters by hand depending on whether we want to reuse an object or not.

Managing Dependencies In Application Flows

AppContainer gets complicated when we want to include more functionality in the project. When our app becomes larger and we start introducing different feature flows, there are even more problems that arise:

  • When we have different flows, we might want objects to just live in the scope of that flow. For example, when creating LoginUserData (that might consist of the username and password used only in the login flow) we don’t want to persist data from an old login flow from a different user. We want a new instance for every new flow. We can achieve that by creating FlowContainer objects inside the AppContainer as demonstrated in the next code example.
  • Optimizing the application graph and flow containers can also be difficult. We need to remember to delete instances that we don’t need, depending on the flow we’re in.

Imagine we have a login flow that consists of one activity (LoginActivity) and multiple fragments (LoginUsernameFragment and LoginPasswordFragment). These views want to:

  • Access the same LoginUserData instance that needs to be shared until the login flow finishes.
  • Create a new instance of LoginUserData when the flow starts again.

We can achieve that with a login flow container. This container needs to be created when the login flow starts and removed from memory when the flow ends.

Let’s add a LoginContainer to the example code. We want to be able to create multiple instances of LoginContainer in the app, so instead of making it a singleton, make it a class with the dependencies the login flow needs from the AppContainer.

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

Once we have a container specific to a flow, we have to decide when to create and delete the container instance. Because our login flow is self-contained in an activity (LoginActivity), the activity is the one managing the lifecycle of that container. LoginActivity can create the instance in onCreate() and delete it in onDestroy().

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var loginData: LoginUserData
    private lateinit var appContainer: AppContainer


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appContainer = (application as MyApplication).appContainer

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
        loginData = appContainer.loginContainer.loginData
    }

    override fun onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

Like LoginActivity, login fragments can access the LoginContainer from AppContainer and use the shared LoginUserData instance.

Because in this case we’re dealing with view lifecycle logic, using lifecycle observation makes sense.

That’s all about in this article.

Related Other Articles / Posts

Conclusion

In this article, we understood about how to apply manual dependency injection in a real android application. Dependency injection is a good technique for creating scalable and testable Android applications. Use containers as a way to share instances of classes in different parts of our application and as a centralized place to create instances of classes using factories. This article reviewed an iterated approach of how we might start using manual dependency injection in our android application.

Thanks for reading! I hope you enjoyed and learned about Manual Dependency Injection (DI) concepts in Android. Reading is one thing, but the only way to master it is to do it yourself.

Please follow and subscribe to the blog and support us in any way possible. Also like and share the article with others for spread valuable knowledge.

You can find other articles of CoolMonkTechie as below link :

You can also follow official website and tutorials of Android as below links :

If you have any comments, questions, or think I missed something, leave them below in the comment box.

Thanks again Reading. HAPPY READING !!???

Loading

Summary
Android - How To Apply Manual Dependency Injection In A Real Android Application ?
Article Name
Android - How To Apply Manual Dependency Injection In A Real Android Application ?
Description
This article reviewed an iterated approach of how we might start using manual dependency injection in android application.
Author

1 thought on “Android – How To Apply Manual Dependency Injection In A Real Android Application ?”

Leave a Comment