Practical Guide to Building Powerful and Easy-to-Maintain Android Apps with Clean Architecture
Dive into the dynamic world of Android app development, where building robust and easily maintainable apps is key. This article takes you on a practical journey, applying the principles of Clean Architecture, Modularization. We’ll explore a real-world example: using the MusicBrainz API to fetch artist details. Our project is neatly divided into distinct modules, each with its own role, making the process clear and manageable. Get ready to learn how to create Android apps that are as powerful as they are sustainable.
In the quest to build an exceptional Android app, we’ll be exploring a real-life scenario, leveraging the MusicBrainz API to search for detailed artist information. To make this journey clearer, we have organized our project into distinct modules, each serving a specific purpose.
Here is what we are building so grab your coffee and let’s dive in! ☕
Project Modules:
- artist-datasource: This module acts as the gateway to external data sources, responsible for fetching artist information from the MusicBrainz API.
- artist-data: In this module, we define the data models and repositories, which interact with the artist-datasource to provide structured artist information to the app.
- artist-presentation: The presentation module is responsible for preparing and transforming data to be presented to the user. It uses the ViewModel from Jetpack to manage UI-related data.
- artist-ui: This module focuses on the user interface, including activities, fragments, and views, to display artist information and enable user interaction.
- artist-domain: The domain module defines the core business logic and use cases for artist information.
- app: The app module serves as the final layer, consolidating all other modules and housing the dependency injection framework
In our quest to build a top-notch Android app, we’re leaning on a set of powerful libraries and technologies. Here’s a quick rundown:
- LiveData (Jetpack): This is our go-to for keeping the UI perfectly in sync with our data. LiveData keeps an eye on data changes and updates the UI accordingly, making sure everything feels smooth and responsive.
- ViewModel (Jetpack): Think of ViewModel as the brains managing UI data. It keeps this data in check, even when our app goes through its lifecycle gymnastics. This separation of powers makes our app neater and easier to handle.
- Hilt Android: Here’s our secret weapon for dependency injection. Hilt keeps things clean and organized, making it easier to manage the many moving parts of our app. It’s our pick for this project, but hey, Koin and Dagger are also cool contenders.
- Retrofit2: When it comes to talking to the MusicBrainz API, Retrofit2 is our trusty messenger. It handles network calls like a pro, making data retrieval a breeze.
- Coroutines: These are our multitasking heroes, handling asynchronous tasks with ease. While we love Coroutines, RxJava is also a great option, depending on what you’re comfortable with.
if you’re curious to see how all these pieces fit together in the real world, check out our complete source code on GitHub. Let’s get started on building something amazing!
Starting with the Domain Layer
In the heart of our Android application lies the domain layer, the cornerstone where our business logic takes shape. Let’s begin our journey by establishing the artist-domain
module.
Setting Up the Artist-Domain Module in Android Studio, open Android Studio and navigate to File -> New -> New Module
, choose either a Kotlin or Java library, depending on your preference or project requirements. This flexibility allows us to work in a language we’re most comfortable with.
Our first step in the artist-domain
module is to define a Domain Model
. This is a strategic move to ensure our domain logic remains independent of external models. Here’s how our ArtistDomainModel
looks
data class ArtistDomainModel(
val id: String,
val name: String,
val gender: String,
val type: String,
val state: String,
val disambiguation: String,
val score: Int
)
Next, we craft an ArtistRepository
interface. This interface serves as a contract, declaring the necessary data operations for our use cases. Here's a glimpse of what it includes:
interface ArtistRepository {
suspend fun artistList(artistName: String): List<ArtistDomainModel>
}
With this, we’ve laid out a clear and concise pathway for data retrieval within our app.
Finally, we introduce the ArtistUseCase
. This class is where the magic happens. It fetches a list of artists based on a request, leveraging Kotlin's coroutines for efficient background execution:
class ArtistUseCase(
private val artistRepository: ArtistRepository,
coroutineContextProvider: CoroutineContextProvider
) : BackgroundUseCaseExecutor<String, List<ArtistDomainModel>>(coroutineContextProvider) {
override suspend fun executeInBackground(
request: String
) = artistRepository.artistList(artistName = request)
}
Note: Our use of BackgroundUseCaseExecutor
is an opinionated approach. It abstracts the coroutine handling from the use case and facilitates an easy switch to RxJava if needed. This design choice ensures that our use cases remain decoupled and testable, a critical aspect of maintainable app development.
Moving to Data Layer
This module will serve as the intermediary between the data sources and domain layer, ensuring a smooth data flow. Our first task in the artist-data
module is to establish theArtistDataModel
data class ArtistDataModel(
val id: String,
val name: String,
val gender: String,
val type: String,
val state: String,
val disambiguation: String,
val score: Int
)
next isArtistDataToDomainMapper,
a key component in our data layer. Think of it as an adapter that enables seamless power flow, much like connecting a laptop to a socket
class ArtistDataToDomainMapper {
fun toDomain(input: ArtistDataModel): ArtistDomainModel {
return ArtistDomainModel(
id = input.id,
name = input.name,
gender = input.gender,
type = input.type,
state = input.state ,
disambiguation = input.disambiguation,
score = input.score
)
}
}
let createArtistDataSource
interface, which acts like a diligent errand boy, fetching and communicating data between the artist-data
layer and external sources
interface ArtistDataSource {
suspend fun getArtistListFromApi(artistName: String): List<ArtistDataModel>
}
The final piece of our data layer is the ArtistLiveDataRepository
. This repository implements the interface from our domain layer and orchestrates the flow of data:
class ArtistLiveRepository(
private val artistDataSource: ArtistDataSource,
private val artistDataToDomainMapper: ArtistDataToDomainMapper,
) : ArtistRepository {
override suspend fun artistList(artistName: String): List<ArtistDomainModel> =
artistDataSource.getArtistListFromApi(artistName).map(
artistDataToDomainMapper::toDomain
)
}
With this repository, we effectively channel data through the ArtistDataSource
to the ArtistRepository
, using our mapper to translate between data models.
Note: The strategy of defining a model for each layer and using mappers for conversion is a cornerstone of our decoupling mechanism. It ensures that each layer remains independent, flexible, and less prone to being affected by changes in other layers.
Data Source Layer
The Data Source Layer plays a crucial role in our Android app’s architecture. It’s the gateway through which our app communicates with the external world, such as APIs, database and cloud messaging services. Let’s dive into setting up this layer, focusing on integrating with an API using Retrofit.
Our first step is to define the MusicApiService
interface. This interface uses Retrofit to specify how our app interacts with a music API
interface MusicApiService {
@Headers("Accept: application/json")
@GET("ws/2/artist")
suspend fun fetchArtistFromServer(
@Query("query") artistName: String,
@Query("fmt") fmt: String = "json"
): ArtistListApiModel
}
Here, we define a GET request to fetch artist information. The use of Retrofit simplifies the process of making network calls and processing the responses.
Next, we implement the ArtistDataSource
from our data layer. The ArtistRemoteDataSource
class is responsible for fetching data from the API and transforming it into a format our data layer want
class ArtistRemoteDataSource(
private val musicApiService: MusicApiService,
private val artistApiToResponseDataMapper: ArtistApiToResponseDataMapper
) : ArtistDataSource {
override suspend fun getArtistListFromApi(artistName: String): List<ArtistDataModel> {
val data = musicApiService.fetchArtistFromServer(artistName = artistName)
return data.artists.map { artistApiToResponseDataMapper.toData(it) }
}
}
The use of a mapper (artistApiToResponseDataMapper
) here is pivotal. It converts the API dataSourceModel response into a ArtistDataModel
, the beauty is dataSourceModel data may change or might be more that what data layer want but with artistApiToResponseDataMapper
, we only extract the data we want
Note on Architectural Choices: While we’ve started our exploration from the domain layer, it’s worth noting that the choice to move to the data layer next is subjective. Some developers prefer to proceed to the presentation and UI layers. This flexibility allows multiple developers to work concurrently on different modules, enhancing collaboration and speeding up the development process.
presentation Layer
Moving on to the Presentation Layer, we focus on how data is represented and interacted with in our app. This layer is key in transforming domain data into a format that’s user-friendly and suitable for UI rendering. Let’s explore how we implement this using a ViewModel and other components.
ArtistViewModel — The Core of the Presentation Layer: The ArtistViewModel
plays a central role in our presentation layer. It leverages Hilt for dependency injection, ensuring smooth integration of use cases and mappers.
private typealias DoNothing = Unit
@HiltViewModel
class ArtistViewModel @Inject constructor(
private val artistUseCase: ArtistUseCaseExecutor,
private val artistDomainMapper: ArtistDomainToPresentationMapper,
useCaseExecutorProvider: UseCaseExecutorProvider
) : BaseViewModel<ArtistViewState>(
useCaseExecutorProvider
) {
override fun initialState() = ArtistViewState()
fun onEntered(artistName: String) {
updateViewState(ArtistViewState::isLoading)
execute(
artistUseCase,
artistName,
onSuccess = { artist -> presentDishDetails(artist) },
onException = { DoNothing }
)
}
private fun presentDishDetails(artist: List<ArtistDomainModel>) {
updateViewState { isArtistReady(artist.map (artistDomainMapper::toPresentation )) }
}
}
In this ViewModel:
- We initiate an API call when a user enters an artist name.
- The state of the view is updated to indicate loading during data retrieval.
- Once data is fetched, it is transformed by
artistDomainMapper
and presented to the UI layer for user to see.
Defining the ViewState: ArtistViewState
manages the state of our UI components. It holds data related to loading status and artist details:
data class ArtistViewState(
val isLoading: Boolean = false,
val artist: List<ArtistPresentationModel>? = null
) {
fun isLoading() = copy(isLoading = true)
fun isArtistReady(
artist: List<ArtistPresentationModel>
) = copy(artist = artist, isLoading = false)
}
With this, we can efficiently manage and update our UI based on the current state of data fetching and presentation.
ArtistPresentationModel
is crafted to fit the needs of our UI layer and decoupled from domain layer model
data class ArtistPresentationModel(
val id: String,
val name: String,
val gender: String,
val city: String,
val state: String,
val description: String,
val score: Int
)
NB: Our strategic choice of BaseViewModel streamlines and cleans up our codebase, enhancing reusability and maintainability in the ViewModel layer. While this approach, including specific mappers, suits our aim for a scalable and tidy architecture
UI Layer
Now that we’ve explored the depths of the Presentation Layer, it’s time to unveil the interesting piece of our architecture. UI Layer.
This is where our app interacts directly with users, making it a crucial aspect of the development process. Let’s delve into the UI Layer.
we’ve used XML for data binding, but you might opt for Jetpack Compose and that’s the beauty of this architecture
ArtistFragment
@AndroidEntryPoint
class ArtistFragment : BaseFragment<ArtistViewState>(){
override val viewModel: ArtistViewModel by viewModels()
@Inject
lateinit var artistToUiMapper: ArtistPresentationToUIMapper
override val layoutResourceId = R.layout.artist_fragment_home
lateinit var artistListView: RecyclerView
lateinit var progressBar: ProgressBar
lateinit var searchButton: Button
lateinit var musicSearch: EditText
lateinit var emptyState: View
override fun View.bindViews() {
artistListView = findViewById(R.id.artist_recyclerView)
progressBar = findViewById(R.id.artist_progressbar)
searchButton = findViewById(R.id.search_button)
musicSearch = findViewById(R.id.music_search)
emptyState = findViewById(R.id.empty_state)
}
private val artist = "London"
private val artistAdapter by lazy {
ArtistAdapter().apply {
clickedArtist = {
closeSoftKeyboard()
viewModel.onEntered(artistName = musicSearch.text.toString())
}
}
}
override fun onResume() {
super.onResume()
viewModel.viewState.observe(viewLifecycleOwner){viewState->
if (artistListView.adapter == null) {
artistListView.adapter = artistAdapter
}
if (viewState.isLoading) {
emptyState.visibility = View.GONE
progressBar.visibility = View.VISIBLE
artistListView.visibility = View.INVISIBLE
} else {
progressBar.visibility = View.GONE
artistListView.visibility = View.VISIBLE
artistAdapter.differ.submitList(viewState.artist?.map(artistToUiMapper::toUi))
}
searchButton.setOnClickListener {
closeSoftKeyboard()
viewModel.onEntered(artistName = musicSearch.text.toString())
}
}
viewModel.onEntered(artist)
}
}
In ArtistFragment
, we've extended BaseFragment
, mirroring our approach with BaseViewModel
. This not only ensures consistency across our architecture but also significantly reduces boilerplate code.
- We use
viewModel.viewState.observe
to manage the state of our UI components based on the data received. - Event listeners, like the one on
searchButton
, trigger actions in the ViewModel, exemplifying the seamless connection between the UI and Presentation layers.
Just like BaseViewModel
, BaseFragment
is a game-changer, simplifying our code and keeping everything in line with best practices.
APP Layer
The App Layer is like the conductor of an orchestra. It’s where everything comes together , the place that holds all the different module in our project, making sure they communicate with each other. This layer is super important for a couple of reasons:
First off, it’s where all the magic of dependency injection happens. This is a fancy way of saying that the App Layer makes sure every part of the layer can easily get to the tools and services it needs, without tripping over each other.
Also, the App Layer is kind of like the welcoming committee for your app. It’s responsible for getting everything up and running. And let’s not forget about the AndroidManifest.xml file that lives here. This file is like the ID card for your app. It tells the Android system what your app is allowed to do, what it needs to function, and what parts it’s made up of like the screens (activities),permission declaration, broadcast receiver and services.
I’ve kept this article as concise as possible, focusing on the key aspects of building decoupled, scalable, and robust apps. Remember, while some parts of this approach are based on my preferences, the goal is to demonstrate a clear, maintainable structure for your Android development. There’s always more to explore and I’m just a message away if you have any questions or want to dive deeper. 😊 Feel free to check out the full source code for a comprehensive view of my methods. And if you want to chat or need any assistance, you can easily find me on LinkedIn or drop me an email. I’m always ready for a good tech talk or to help out with any queries you might have. Let’s connect and keep the tech conversation flowing! 🚀✉️.