Building Scalable and Maintainable Android Apps with Modern Architecture
In today’s dynamic mobile landscape, crafting a robust and scalable Android app requires a well-defined architecture. While traditional Android development prioritized getting apps up and running quickly, this simplicity led to maintenance challenges as apps grew more complex. Modern architecture tackles this by emphasizing separation of concerns and modularity. This leads to cleaner, more maintainable, and scalable apps that are easier to test and adapt for future growth. This article will discuss some main ideas and practices for building modern Android app architectures.
Introduction
In today’s dynamic mobile landscape, building a robust and scalable Android app needs a well-defined architecture. This architecture serves as the blueprint for your app, and ensures maintainability, performance, and a seamless user experience. As you know, traditional Android app development architecture often focused on simplicity, but led to challenges as apps grew complex. In contrast, modern architecture approach prioritizes separation of concerns and modularity for better maintainability and scalability.
Even though the traditional approach could be easier to learn for beginners and quicker to for smaller apps, there are some disadvantages in practice as follows:
Tight coupling: Components (Activities or Fragments) become tightly coupled with data and logic. This makes them difficult to reuse, test, and maintain as the app grows.
State management issues: Manually handling data and state within Activity or Fragment lifecycles can lead to errors, memory leaks, and complex code.
Scalability restrictions: The architecture becomes hard to scale as the app grows in features and complexity.
To address effectively those problematic issues in traditional way, the Modern App Architecture recommends to use the following key ideas and practices:
Reactive and Layered Architecture: The app is divided into different layers (UI, Domain, Data) with clear responsibilities. Each layer reacts to changes from the layer below it. This leads to a more responsive and predictable application.
Unidirectional Data Flow (UDF): Data flows in a single direction, typically from the Data layer to the UI layer. This simplifies reasoning about data updates and avoids potential inconsistencies.
UI with State Holders: State holders, like ViewModels handle the data and state of the UI components. This separates UI logic from the Activity/Fragment lifecycle and promotes better testability.
Coroutines and Flows: These are powerful tools for managing asynchronous operations and managing data streams efficiently. They enhance performance and make code cleaner compared to traditional callbacks.
Dependency Injection: This technique allows loose coupling between components by injecting dependencies at runtime. This makes testing easier and promotes code reusability.
In fact, modern architecture is an industry best practice for a reason. It offers a solid foundation for building robust, maintainable, and scalable Android apps. As your app development skills grow, you will find the benefits of the modern approach far outweigh the initial learning curve.
Reactive and Layered Architecture
Basically, the Android app is divided into three main layers:
UI Layer: Manages user interaction (clicks, gestures) and shows information visually. It should not contain complex business logic. The UI layer includes two key components: UI elements for rendering the data and state holders for exposing the handled logic.
Domain Layer: Encapsulates the core business logic of the app, independent of any specific UI or data source. Also, it can encapsulate simple business logic that is reused by multiple ViewModels
Data Layer: Handles data access from various sources (local storage, network). It retrieves and persists data based on requests from the Domain layer.
Besides, you should create repositories even if your data layer just only include a single data source. In other words, components in the UI layer, like composables, activities, or ViewModels should not interact directly with a data source, such as Databases, DataStore Databases, DataStore, Firebase APIs, and providers for GPS location, Bluetooth data, and Network. The ultimate goal of repository is providing a central point for data access and management, and separating data concerns from the UI and logic layers. The key responsibilities are:
- Expose data: Provide methods for fetching and potentially modifying data.
- Centralize data changes: Manage data updates in a consistent manner.
- Resolve data source conflicts (if applicable): Manage conflicts between data retrieved from different sources (local vs remote).
- Abstract data sources: Hide data source specifics from the rest of the app.
- Limited business logic: Can consist of basic data-related business logic.
Each layer reacts to changes from the layer below it. For instance, the UI layer reacts to data updates from the ViewModel (which in turn gets data from the Domain layer). This promotes loose coupling and easier reasoning about data flow. What this means is layers communicate with each other through well-defined interfaces. These interfaces can be:
Events: The UI layer can trigger events (e.g., user clicks a button) that the ViewModel handles.
Data Streams: The Domain or Data layer can expose data streams (e.g., LiveData, Flow) that the UI layer observes for updates.
For example, this architecture follows the MVVM (Model-View-ViewModel) pattern commonly used in Android development. The ViewModel (UserViewModel
) holds the UI-related data while keeping the UI logic separate from the UI components themselves. The UI component (UserDetailScreen
) observes changes in the ViewModel's state and updates the UI accordingly.
// Data class for user information
data class User(val name: String, val email: String)
// Interface for a user repository (abstraction)
interface UserRepository {
suspend fun getUser(userId: Int): User
}
// ViewModel class
class UserViewModel(private val userRepository: UserRepository) : ViewModel() {
private val _userState = mutableStateOf<User?>(null)
val userState: State<User?> = _userState.asStateFlow()
fun loadUser(userId: Int) {
viewModelScope.launch {
try {
val user = userRepository.getUser(userId)
_userState.value = user
} catch (e: Exception) {
// Handle error
}
}
}
}
// Composable UI function
@Composable
fun UserDetailScreen(viewModel: UserViewModel) {
val user = viewModel.userState.value
if (user != null) {
Text(text = "Name: ${user.name}")
Text(text = "Email: ${user.email}")
} else {
Text("Loading user data...")
}
}
Additionally, you can be able to create the UI elements (in the UI layer) using Views (Traditional approach) or Jetpack Compose functions (Modern App Architecture approach). The Composable functions are the building blocks of Compose UIs. They are annotated with @Composable
and describe how a UI element should look and behave based on its data.
Jetpack Compose is a modern toolkit for building native Android UI. Jetpack Compose simplifies and accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin APIs.
Unidirectional Data Flow (UDF)
In Android development, the Single Source Of Truth (SSOT) principle emphasizes having one definitive source of truth for each data entity within your application. This ensures consistency and avoids data conflicts. This principle can be used with Unidirectional Data Flow (UDF) pattern. In UDF, data flows in a single direction, typically from the Data layer to the UI layer. User interactions trigger events that flow in the opposite direction. Ultimately, this leads to data modifications.
Some key points to remember in this area:
Data flows from the Model layer (repository) to the ViewModel and then to the UI.
User interactions trigger events sent to the ViewModel, not directly modifying data.
The ViewModel updates its state based on events and data updates.
The UI observes the ViewModel’s LiveData and updates itself based on the latest state.
Furthermore, you should collect UI state from the UI using the proper lifecycle-aware coroutine builder, such as repeatOnLifecycle
in the View system and collectAsStateWithLifecycle
in Jetpack Compose.
UI with State Holders
State refers to any value that can change over time within an app. This can cover various data types, from user input to game scores, as long as they potentially influence the UI. Importantly, state specifically refers to data that directly affects the user’s experience. It is the data that specifies what the user sees or interacts with on the screen. In modern Android development, UI with State Holders refers to an architecture pattern that promotes separation of concerns and boost maintainability for managing UI state. The State Holders often encapsulate the state data, business logic related to the state, and methods to update the state. So, this leads to cleaner and more organized code. For example, exposing a MutableStateFlow<UiState>
as a StateFlow<UiState>
in the ViewModel is a common and efficient way to create a stream of UiState
for the UI to observe. In the following code, NewsViewModel
class follows a that pattern for managing UI state and handling data fetching in a ViewModel:
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
private var fetchJob: Job? = null
fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
_uiState.update {
it.copy(newsItems = newsItems)
}
} catch (ioe: IOException) {
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}
The UI layer only receives an immutable StateFlow<UiState>
. This ensures the UI cannot accidentally modify the underlying state directly.
Initially, Jetpack Compose offers various approaches and techniques to manage state effectively within your composable functions.
Keep it clean, keep it Compose! State management best-practices lead to maintainable and performant UIs.
Eventually, some key points to consider:
Defining a ScreenUiState
data class is a great way to encapsulate the data, errors, and loading signals relevant to a specific UI screen. This keeps the uiState
property well-organized and easy to understand.
For simpler cases where the data is not a stream, using a MutableStateFlow
internally within the ViewModel and exposing it as an immutable StateFlow
is acceptable.
Aggregating outputs from multiple state sources into a cohesive whole is a powerful approach for state production in Android apps, especially when dealing with streams of data. In Modern Android Architecture, state production often involves handling data streams from various sources like network requests, local databases, or user interactions. You can use libraries like RxJava or Coroutines with operators like combine
, zip
, or merge
to achieve this aggregation.
Coroutines and Flows
Coroutines are a game-changer for asynchronous programming in Android development. Coroutines support a lightweight mechanism for managing asynchronous tasks without resorting to traditional threading. In coroutines, a flow represents a mechanism for emitting multiple values in sequence, in contrast to suspend functions which return just one value. For instance, flows are useful for receiving continuous updates from a database in real-time. Think of a flow as a stream of water. The producer opens the faucet (starts the asynchronous operation), and water (data) flows out at intervals (emissions). Consumers (the UI or other parts of the code) can tap into the stream and receive the flowing water (data).
Thus, Kotlin Flows provide a concise and structured way to handle asynchronous data streams in Kotlin coroutines. They are particularly helpful for building dynamic and data-driven user interfaces in Jetpack Compose. For example, suppose a composable displaying a list of users fetched from an API as follows:
class UserViewModel : ViewModel() {
val usersFlow = viewModelScope.launch {
val response = networkService.getUsers()
val users = parseResponse(response)
emit(users)
}
}
@Composable
fun UserList(viewModel: UserViewModel) {
val users = viewModel.usersFlow.collectAsState(initial = emptyList())
Column {
Text("Users:")
Spacer(modifier = Modifier.height(8.dp))
users.value.forEach { user ->
UserItem(user)
}
}
}
The ViewModel fetches users in a coroutine and emits them as a flow. Then, UserList
composable collects the usersFlow
using collectAsState
, and convert it to a state that recomposes the UI whenever new users are emitted.
Dependency Injection
Dependency Injection (DI) is a design pattern that promotes loose coupling and improves testability in your Android application. It involves separating the creation of objects (dependencies) from their usage. Traditionally, a class might create or directly reference the objects it depends on (e.g., a NetworkManager class creating a Retrofit instance). With DI, these dependencies are no longer created or managed by the class itself. Instead, they are injected from an external source. This external source can be a framework, a DI library (like Dagger Hilt), or even a factory method. For instance:
class NetworkManager {
// Directly creates a Retrofit instance
private val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com")
.build()
fun getUserData(userId: String): User {
val apiService = retrofit.create(ApiService::class.java)
val response = apiService.getUser(userId)
// ... parse response
}
}
With DI will be:
class NetworkManager(private val retrofit: Retrofit) {
fun getUserData(userId: String): User {
val apiService = retrofit.create(ApiService::class.java)
val response = apiService.getUser(userId)
// ... parse response
}
}
To use NetworkManager
class:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com")
.build()
val networkManager = NetworkManager(retrofit)
In the DI example, retrofit
is injected as a constructor argument to NetworkManager
. This decouples NetworkManager
from the specific way retrofit
is created, allowing for more flexibility and easier testing. Another example for showing manual dependency injection:
MyViewModel
has a constructor that takes an instance of MyRepository
as a parameter. This makes it clear that MyViewModel
depends on MyRepository
to function. So, when creating an instance of MyViewModel
, you need to provide a concrete implementation of MyRepository
.
class MyRepository {
fun getData(): String {
// Simulate fetching data
return "Some data"
}
}
class MyViewModel(private val repository: MyRepository) {
fun fetchData(): String {
return repository.getData()
}
}
// Manual Dependency Injection
fun main() {
// Instantiate MyRepository
val repository = MyRepository()
// Pass MyRepository instance to MyViewModel
val viewModel = MyViewModel(repository)
// Usage
val data = viewModel.fetchData()
println("Data: $data")
}
Although manual dependency injection is straightforward and suitable for smaller projects, it may become difficult to handle dependencies as the project grows. In such cases, consider using DI frameworks, such as Dagger or Hilt for better organization, flexibility, and testability.
Hilt provides a standard way to use DI in your application by providing containers for every Android class in your project and managing their lifecycles automatically. Hilt is built on top of the popular DI library Dagger to benefit from the compile-time correctness, runtime performance, scalability, and Android Studio support that Dagger provides. For more information, see Hilt and Dagger.
In Conclusion
This article explored some main ideas and practices for building modern Android app architectures based on Google documents and resources. By following these core principles and staying updated with the latest advancements, you can build a solid foundation for a successful and scalable Android application. However, modern Android app architecture best-practices are not a one-size-fits-all solution. The optimal approach depends on the complexity and specific needs of your app.