Manual Dependency Injection- Realtime Explanation

Assume that we have a Login Feature in Android application and it will have the classes based on the Android's recommended architecture such as MVVM.




--> The LoginActivity has the dependency of LoginViewModel.

--> The LoginViewModel has the dependency of UserRepository

--> The UserRepository has the dependency of UserLocalDataSource and UserRemoteDataSource


UserRepository.kt

class UserRepository(
private val localDataSource: LocalDataSource,
private val remoteDataSource: RemoteDataSource
) {
// Manipulating data from local and/or remote data sources.
}

Data Sources

class LocalDataSource {}

class RemoteDataSource(private val retrofitLoginService: RetrofitLoginService) {
}


LoginViewModel.kt


class LoginViewModel(private val userRepository: UserRepository) {
//Invocation of methods in UserRepository class
}


LoginActivity.kt


class LoginActivity : AppCompatActivity {
private var lateinit loginViewModel: LoginViewModel;
public fun onCreate(bundle: Bundle?) {
...................
...................
val retrofit = Retrofit.Builder().baseUrl("www.abc.com").build()
.create(RetrofitLoginService::class.java)
val localDataSource = LocalDataSource()
val remoteDataSource = RemoteDataSource(retrofit)
val userRepository = UserRepository(localDataSource, remoteDataSource)
localViewModel = LoginViewModel(userRepository)
}
}


Lets assume that the access to UserRepository requires in more than one feature. It is not recommended to create objects in all features as it will increase the boilerplate code. We have to create object in one place and make it available globally.

Two approaches to keep it Globally

---> Create Singleton pattern ----- But by using Singleton the testability of the app will get diminished as all the tests will share the same singleton instance.

---> Creating an AppContainer


AppContainer.kt

class AppContainer{
private val retrofit = Retrofit.Builder().baseUrl("www.abc.com").build()
.create(RetrofitLoginService::class.java)
private val localDataSource = LocalDataSource()
private val remoteDataSource = RemoteDataSource(retrofit)
val userRepository = UserRepository(localDataSource,remoteDataSource)
}


As AppContainer should be accessible to all activities, we can keep it in a common place : Application class.


MyApplication.kt

class MyApplication : Application {
.............
..............
var appContainer = AppContainer()
}

The LoginActivity can  be updated as below,

class LoginActivity : AppCompatActivity {
private var lateinit loginViewModel: LoginViewModel;
public fun onCreate(bundle: Bundle?) {
...................
...................
val appContainer = (application as Application).appContainer
localViewModel = LoginViewModel(appContainer.userRepository)
}
}

If suppose, If we need to get object of the LoginViewModel from many places, we can move the creation of LoginViewModel in a container and create new objects whenever required using a Factory

interface Factory<T> {
fun create() : T
}
class LoginViewModelFactory(private val userRepository : UserRepository):Factory{
override fun create():LoginViewModel{
return LoginViewModel(userRepository)
}
}

AppContainer can hold the LoginViewModelFactory and create the LoginViewModel whenever it needs one object

class AppContainer{
....................
....................
val userRepository = UserRepository(localDataSource,remoteDataSource)
val loginViewModelFactory : LoginViewModelFactory(userRepository)
}

Now we can acquire the object of LoginViewModel like below

class LoginActivity: AppCompatActivity{
private var lateinit loginViewModel: LoginViewModel;
public fun onCreate(bundle: Bundle?){
...................
...................
val appContainer = (application as Application).appContainer
localViewModel = appContainer.loginViewModelFactory.create()
}




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

1. You have to manage AppContainer yourself, creating instances for all dependencies by hand.

2. There is still a lot of boilerplate code. You need to create factories or parameters by hand depending on whether you want to reuse an object or not.


Managing dependencies in application flows

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

1. When you have different flows, you 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) you don't want to persist data from an old login flow from a different user. You want a new instance for every new flow. You can achieve that by creating FlowContainer objects inside the AppContainer as demonstrated in the next code example.

2. Optimizing the application graph and flow containers can also be difficult. You need to remember to delete instances that you don't need, depending on the flow you're in.

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

1. Access the same LoginUserData instance that needs to be shared until the login flow finishes

2. Create a new instance of LoginUserData when the flow starts again.


You 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. You 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 you have a container specific to a flow, you have to decide when to create and delete the container instance. Because your 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 you're dealing with view lifecycle logic, using lifecycle observation makes sense.

Note: If you need the container to survive configuration changes, follow the Saving UI States guide. You need to handle it the same way you handle process death; otherwise, your app might lose state on devices with less memory.


Conclusion

1. Dependency injection is a good technique for creating scalable and testable Android apps. Use containers as a way to share instances of classes in different parts of your app and as a centralized place to create instances of classes using factories.

2. When your application gets larger, you will start seeing that you write a lot of boilerplate code (such as factories), which can be error-prone. You also have to manage the scope and lifecycle of the containers yourself, 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 your app.<-- Here is where Automated DI coming to the picture.


Reference: https://developer.android.com/training/dependency-injection/manual 

Comments

Popular Posts