Implementing a Repository in our App with MVVM & DI in Android: (Day 07)

In our last blog post, we implemented Retrofit into our app, so at this point we have implemented and injected our RoomDB instance as well as Retrofit. If we look at our architecture pattern image again, it shows us that the next step would be implementing a Repository that would depend on both Models as well as Remote Data Source.

Just to brush up old concepts, here's why we need a repository in the first place.

  1. Abstraction of Data Source: The primary role of the Repository is to abstract the origin of the data that the ViewModel consumes. This means that the ViewModel doesn't need to know where the data comes from. It could be from a local database, a remote API, shared preferences, or any other data source.

  2. Single Source of Truth: By centralizing the data logic in the Repository, you ensure that there's a single source of truth in your application. This means that all parts of your app that need data will get a consistent set of data.

  3. Decoupling: MVVM promotes separation of concerns. By using a Repository, you further decouple the data source logic from the ViewModel. This makes the ViewModel more focused on its primary responsibility: managing and processing data for the View.

Now that we have a basic understanding of the core reason behind creating a repository class, let's go ahead and create a repository class that will handle our login and signup workflows.

The code to this blog post can be found here

Step 1

Start by creating a simple class and name it as UserRepository, as already discussed above, this class will be dependent on two components, i.e. our UserDAO (which will be our local database) and WebService (which will provide access to remote databases or APIs)

class UserRepository @Inject constructor(val webService: WebService, val userDAO: UserDAO) {
//TODO Code to be added here in future.
}

Note:

The @Inject before the constructor exists because we'll be using constructor injection to provide the webService and the userDAO. This is because it's a general practice to prefer constructor injection over field injection wherever possible. We used field injection in our MainActivity only because we didn't have access to it's constructor. To know more, consider reading the following blog post
Field Injection in Dagger2: DI (Day 2)

Step 2

Now we need to create a new Repository module in our di package, this will hold all our future repository injection provider methods.

@Module
class RepositoryModule {
    @Provides
    @Singleton
    fun provideUserRepository(webservice: WebService, dao: UserDAO): UserRepository {
        return UserRepository(webService = webservice, userDAO = dao)
    }
}

The code above is pretty much self-explanatory. We've simply injected an instance of UserRepository into our dagger graph with webService and userDAO which we had already injected in our previous post.

Step 3

Now that our repository has access to webService and userDAO, let's go ahead and try to write methods that will access the database and APIs.

class UserRepository @Inject constructor(val webService: WebService, val userDAO: UserDAO) {
    private val _userDataResponse = MutableLiveData<UserDataResponse>()
    val userDataResponse: LiveData<UserDataResponse> get() = _userDataResponse

    fun storeUserData(userData: UserData){
        userDAO.insertUsers(userData = userData)
    }

    fun fetchAllUserData() {
        webService.fetchAllUsers().enqueue(object : Callback<UserDataResponse> {
            override fun onResponse(call: Call<UserDataResponse>, response: Response<UserDataResponse>) {
                if (response.isSuccessful) {
                    _userDataResponse.postValue(response.body())
                    Log.e("API_RESPONSE", "Success: ${Gson().toJson(response.body())}")
                } else {
                    val errorString = response.errorBody()?.string()
                    Log.e("API_RESPONSE", "Unsuccessful: $errorString")
                }
            }

            override fun onFailure(call: Call<UserDataResponse>, t: Throwable) {
                // Handle failure
                Log.e("API_RESPONSE", "Failure: ${t.message}")
            }
        })
    }
}

Most of the code here has been borrowed from what we had written in the MainActivity earlier and doesn't need any explanation.

The only part which is new to us is the use of LiveData and Mutable LiveData above. So let's go ahead and understand why was it used, and what it does.

What is LiveData?

LiveData:

  • What is it? Think of LiveData as a container that holds some data, like a box. But this isn't just any box; it's a special box that can notify you whenever the data inside it changes.

  • Why is it special? This box is smart. It knows when you're around (like when your app is open and active) and only then will it notify you about changes. If you're not around (like when your app is in the background), it won't bother you.

MutableLiveData:

  • What is it? It's a type of LiveData, but with an extra feature: you can change the data inside it. With the regular LiveData, the box is sealed, and you can't change its contents. But with MutableLiveData, you can open the box and put new things inside.

  • When to use it? When you want to change the data inside the box from within your app's code.

In our code above, we used _userDataResponse to update the data whenever we got a response from our API call. But we used userDataResponse as the exposed member of the class that can be accessed by the UI to observe changes to _userDataResponse.

The reason behind doing this was simply that while _userDataResponse is a MutableLiveData i.e. it can be modified, and the userDataResponse is a simple LiveData which will prevent UI operation from being able to modify the contents ensuring data integrity.

Step 4

Go ahead and now add the Repository Module to our AppComponent, so that we can later use it in our dagger graph.

@Singleton
@Component(modules = [AndroidSupportInjectionModule::class, AppModule::class, DatabaseModule::class, RepositoryModule::class])
interface AppComponent : AndroidInjector<MyApp> {
    @Component.Factory
    interface Factory {
        fun create(@BindsInstance application: Application): AppComponent
    }
}

Step 5

Now that our repository code is ready to broadcast data to other components, let's go to our MainActivity and try to observe it.

class MainActivity : DaggerAppCompatActivity() {

    @Inject
    lateinit var userRepository: UserRepository

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

        val insertButton = findViewById<Button>(R.id.insert)

        insertButton.setOnClickListener {
            fetchUserDataList()
        }

        userRepository.userDataResponse.observe(this, Observer {
            Log.e("Observed", "Some data received in observer ${Gson().toJson(it)}")
        })
    }

    fun storeDataExample(){
        val data = UserData()
        data.emailId = "testemail@test.com"
        data.firstName = "test name"
        data.password = "test password"
        data.uid = "12312"

        CoroutineScope(Dispatchers.IO).launch {
            userRepository.storeUserData(userData = data)
        }
    }

    fun fetchUserDataList(){
        userRepository.fetchAllUserData()
    }
}

Explanation

  • Notice that we have removed our older field injections for webService and userDAO, instead, now we only have a single field injection for the repository which helps keep the code uncluttered and easy to read.

  • We are calling the fetchAllUserData method when the insert button is clicked, but we are not storing the response of the API call here, instead we are observing it asynchronously using LiveData.

LiveData observer in the MainActivity

.observe(this, Observer {...}) method is used to watch the LiveData for changes. It takes two parameters:

  1. A LifecycleOwner (this in this case, which refers to the MainActivity).

  2. An Observer that defines what should be done when the LiveData's data changes.

How It Functions:

  1. Initialization: When the MainActivity is created, the observer is set up to watch the userDataResponse LiveData.

  2. Data Change: Whenever the data inside userDataResponse changes (for instance, after a network request fetches new user data), all active observers are notified.

  3. Observer Action: Once notified, the lambda function inside the observer is executed. In this case, it logs the new data.

  4. Lifecycle Respect: If the MainActivity goes to the background (like when the user navigates away or the screen is turned off), the observer won't receive updates, even if the data changes. This is because LiveData respects the lifecycle and doesn't push updates to inactive components. Once the MainActivity becomes active again, it will start receiving updates.

  5. Activity Destruction: If the MainActivity is destroyed (like when the user finishes the activity or the system reclaims resources), the observer is automatically unregistered, ensuring there are no memory leaks.

Conclusion

And that's it, if you run the code again, you'll see similar results as our last blog post, but now our code is much cleaner and we also have a much streamlined separation of scopes and workflows across repository, API and database modules and of course the UI components.

But please not that this is still not the complete architecture, we are still missing a major piece of the puzzle which is MVVM i.e. the ViewModel class. This class sits between the UI and the repository and makes sure that logic specific to how data is shown on the UI is neither on the UI class nor on the repository class but in a third class with only this business logic as its purpose.

In our next blog post we'll try to understand in more detail the need for a ViewModel and how should it be implemented. Till then, Happy Coding!