Kotlin Android Architecture
Introduction
When building Android applications with Kotlin, having a solid architecture is essential for creating maintainable, testable, and scalable apps. Architecture refers to the organization of your code and the patterns you use to separate concerns, manage dependencies, and handle data flow.
In this guide, we'll explore modern Android architecture patterns using Kotlin, understanding why they're important, and how to implement them in your projects. By the end, you'll have a strong foundation in Android architecture principles that will help you build more robust applications.
Why Architecture Matters
Before diving into specific patterns, let's understand why architecture matters:
- Separation of Concerns: Keeps different parts of your app isolated and focused on specific tasks
- Testability: Makes writing unit tests easier by decoupling components
- Maintainability: Makes code easier to understand, debug, and modify
- Scalability: Allows your app to grow without becoming a tangled mess
- Team Collaboration: Enables multiple developers to work on different parts simultaneously
Android Architecture Components
Google provides a set of libraries called Architecture Components that help implement robust, testable, and maintainable architecture. Let's look at the key components:
ViewModel
The ViewModel is responsible for preparing and managing data for the UI (Activity or Fragment).
class UserProfileViewModel(
private val userRepository: UserRepository
) : ViewModel() {
// LiveData to observe user data
private val _userData = MutableLiveData<User>()
val userData: LiveData<User> = _userData
// Load user data
fun loadUserData(userId: String) {
viewModelScope.launch {
val user = userRepository.getUser(userId)
_userData.value = user
}
}
}
LiveData
LiveData is an observable data holder class that is lifecycle-aware.
// In your ViewModel
private val _counter = MutableLiveData<Int>(0)
val counter: LiveData<Int> = _counter
fun incrementCounter() {
_counter.value = (_counter.value ?: 0) + 1
}
// In your Activity or Fragment
viewModel.counter.observe(viewLifecycleOwner) { count ->
counterTextView.text = "Count: $count"
}
Room Database
Room provides an abstraction layer over SQLite to allow for more robust database access.
// Entity
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: String,
val name: String,
val email: String,
val age: Int
)
// DAO (Data Access Object)
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<User>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User)
}
// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Common Architecture Patterns in Android
Now that we understand the building blocks, let's look at some common architecture patterns used in Android development with Kotlin.
1. MVVM (Model-View-ViewModel)
MVVM is one of the most popular architecture patterns in modern Android development.
Components:
- Model: Represents the data and business logic
- View: The UI components (Activities, Fragments)
- ViewModel: Mediates between Model and View, prepares data for display
Implementation Example:
// Model
data class Product(
val id: String,
val name: String,
val price: Double,
val description: String
)
// Repository
class ProductRepository(private val apiService: ApiService, private val productDao: ProductDao) {
suspend fun getProducts(): List<Product> {
return try {
val products = apiService.getProducts()
productDao.insertAll(products)
products
} catch (e: Exception) {
productDao.getAll() // Fallback to local data
}
}
}
// ViewModel
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
private val _products = MutableLiveData<List<Product>>()
val products: LiveData<List<Product>> = _products
private val _isLoading = MutableLiveData<Boolean>(false)
val isLoading: LiveData<Boolean> = _isLoading
fun fetchProducts() {
viewModelScope.launch {
_isLoading.value = true
_products.value = repository.getProducts()
_isLoading.value = false
}
}
}
// View (Fragment)
class ProductListFragment : Fragment() {
private lateinit var viewModel: ProductViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this, viewModelFactory).get(ProductViewModel::class.java)
val adapter = ProductAdapter()
recyclerView.adapter = adapter
viewModel.products.observe(viewLifecycleOwner) { products ->
adapter.submitList(products)
}
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
}
viewModel.fetchProducts()
}
}
2. Clean Architecture
Clean Architecture focuses on separating concerns into layers with explicit dependencies pointing inward.
Layers:
- Presentation Layer: UI (Activities, Fragments) and ViewModels
- Domain Layer: Business logic and use cases
- Data Layer: Repositories and data sources
Directory Structure Example:
app/
├── data/
│ ├── api/
│ ├── db/
│ ├── models/
│ └── repositories/
├── domain/
│ ├── models/
│ └── usecases/
└── presentation/
├── ui/
└── viewmodels/
Implementation Example:
// Domain Layer - Model
data class User(val id: String, val name: String, val email: String)
// Domain Layer - Use Case
class GetUserUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke(userId: String): User {
return userRepository.getUser(userId)
}
}
// Data Layer - Repository Interface (in Domain Layer)
interface UserRepository {
suspend fun getUser(userId: String): User
}
// Data Layer - Repository Implementation
class UserRepositoryImpl(
private val apiService: ApiService,
private val userDao: UserDao
) : UserRepository {
override suspend fun getUser(userId: String): User {
return try {
val networkUser = apiService.getUser(userId)
userDao.insertUser(networkUser.toUserEntity())
networkUser.toDomainUser()
} catch (e: Exception) {
userDao.getUser(userId).toDomainUser()
}
}
}
// Presentation Layer - ViewModel
class UserDetailViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _user = MutableLiveData<User>()
val user: LiveData<User> = _user
fun loadUser(userId: String) {
viewModelScope.launch {
try {
val user = getUserUseCase(userId)
_user.value = user
} catch (e: Exception) {
// Handle error
}
}
}
}
3. MVI (Model-View-Intent)
MVI is a newer pattern focusing on unidirectional data flow and immutable state.
Components:
- Model: Represents the state of the UI
- View: Renders the state and sends user actions as intents
- Intent: Represents user actions that can change the state
Implementation Example with Flow:
// State
data class TodoState(
val todos: List<Todo> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
// Intent
sealed class TodoIntent {
object LoadTodos : TodoIntent()
data class AddTodo(val todo: Todo) : TodoIntent()
data class DeleteTodo(val todoId: Int) : TodoIntent()
}
// ViewModel
class TodoViewModel(private val repository: TodoRepository) : ViewModel() {
private val _state = MutableStateFlow(TodoState())
val state: StateFlow<TodoState> = _state.asStateFlow()
fun processIntent(intent: TodoIntent) {
when (intent) {
is TodoIntent.LoadTodos -> loadTodos()
is TodoIntent.AddTodo -> addTodo(intent.todo)
is TodoIntent.DeleteTodo -> deleteTodo(intent.todoId)
}
}
private fun loadTodos() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val todos = repository.getTodos()
_state.update { it.copy(todos = todos, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message, isLoading = false) }
}
}
}
// Other methods for handling intents...
}
// View (Fragment)
class TodoFragment : Fragment() {
private lateinit var viewModel: TodoViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this, viewModelFactory).get(TodoViewModel::class.java)
// Observe state
lifecycleScope.launch {
viewModel.state.collect { state ->
renderState(state)
}
}
// Send intents
addButton.setOnClickListener {
val todo = Todo(title = todoEditText.text.toString())
viewModel.processIntent(TodoIntent.AddTodo(todo))
}
// Load initial data
viewModel.processIntent(TodoIntent.LoadTodos)
}
private fun renderState(state: TodoState) {
progressBar.isVisible = state.isLoading
todoAdapter.submitList(state.todos)
state.error?.let { showErrorMessage(it) }
}
}
Implementing Repository Pattern
The Repository Pattern is a key element in many Android architectures. It abstracts the data sources from the rest of the app.
// Repository interface
interface MovieRepository {
suspend fun getPopularMovies(): List<Movie>
suspend fun getMovieDetails(movieId: Int): MovieDetails
suspend fun searchMovies(query: String): List<Movie>
suspend fun saveMovieToFavorites(movie: Movie)
suspend fun getFavoriteMovies(): List<Movie>
}
// Repository implementation
class MovieRepositoryImpl(
private val movieApi: MovieApi,
private val movieDao: MovieDao
) : MovieRepository {
override suspend fun getPopularMovies(): List<Movie> {
return try {
// Try to fetch from network
val networkMovies = movieApi.getPopularMovies()
// Cache the results
movieDao.insertMovies(networkMovies.map { it.toMovieEntity() })
// Return domain models
networkMovies.map { it.toDomainMovie() }
} catch (e: Exception) {
// If network request fails, get from local database
movieDao.getAllMovies().map { it.toDomainMovie() }
}
}
// Other method implementations...
}
Dependency Injection with Hilt
Modern Android architecture often uses dependency injection with Hilt, which is built on top of Dagger.
// Application class with Hilt
@HiltAndroidApp
class MovieApplication : Application()
// Module providing dependencies
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideApiService(): MovieApi {
return Retrofit.Builder()
.baseUrl("https://api.themoviedb.org/3/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MovieApi::class.java)
}
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"movie_database"
).build()
}
@Provides
fun provideMovieDao(database: AppDatabase): MovieDao {
return database.movieDao()
}
@Provides
@Singleton
fun provideMovieRepository(
api: MovieApi,
dao: MovieDao
): MovieRepository {
return MovieRepositoryImpl(api, dao)
}
}
// Using dependency injection in a ViewModel
@HiltViewModel
class MovieViewModel @Inject constructor(
private val movieRepository: MovieRepository
) : ViewModel() {
// ViewModel implementation
}
// Using in an Activity or Fragment
@AndroidEntryPoint
class MovieListFragment : Fragment() {
private val viewModel: MovieViewModel by viewModels()
// Fragment implementation
}
Best Practices for Android Architecture
-
Single Source of Truth: Maintain a single source of truth for your data, typically in a repository.
-
Separation of Concerns: Each component should have a single responsibility.
-
Unidirectional Data Flow: Data should flow in one direction, making it easier to track and debug.
-
Immutability: Use immutable data classes to prevent unexpected state changes.
-
Dependency Injection: Use DI to make components more testable and reusable.
-
Coroutines & Flow: Use Kotlin Coroutines and Flow for asynchronous operations and reactive programming.
-
Testing: Write tests for each layer, especially the ViewModel and Repository layers.
Practical Example: Building a Weather App
Let's put everything together in a practical example of a weather app architecture:
// Domain Models
data class WeatherInfo(
val location: String,
val temperature: Double,
val condition: String,
val humidity: Int,
val windSpeed: Double
)
// Repository Interface
interface WeatherRepository {
suspend fun getWeatherForCity(cityName: String): WeatherInfo
suspend fun getSavedLocations(): List<String>
suspend fun saveLocation(cityName: String)
}
// Repository Implementation
class WeatherRepositoryImpl(
private val weatherApi: WeatherApi,
private val weatherDao: WeatherDao
) : WeatherRepository {
override suspend fun getWeatherForCity(cityName: String): WeatherInfo {
return try {
val apiResponse = weatherApi.getWeather(cityName)
// Cache the results
weatherDao.insertWeatherData(apiResponse.toWeatherEntity())
// Return domain model
apiResponse.toWeatherInfo()
} catch (e: Exception) {
// Fallback to cached data
weatherDao.getWeatherForCity(cityName)?.toWeatherInfo()
?: throw Exception("No weather data available for $cityName")
}
}
// Other implementations...
}
// Use Case
class GetWeatherUseCase(private val repository: WeatherRepository) {
suspend operator fun invoke(cityName: String): WeatherInfo {
if (cityName.isBlank()) {
throw IllegalArgumentException("City name cannot be empty")
}
return repository.getWeatherForCity(cityName)
}
}
// ViewModel
@HiltViewModel
class WeatherViewModel @Inject constructor(
private val getWeatherUseCase: GetWeatherUseCase,
private val saveLocationUseCase: SaveLocationUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Loading)
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()
fun getWeather(cityName: String) {
viewModelScope.launch {
_uiState.value = WeatherUiState.Loading
try {
val weatherInfo = getWeatherUseCase(cityName)
_uiState.value = WeatherUiState.Success(weatherInfo)
saveLocationUseCase(cityName)
} catch (e: Exception) {
_uiState.value = WeatherUiState.Error(e.message ?: "Unknown error")
}
}
}
}
// UI State
sealed class WeatherUiState {
object Loading : WeatherUiState()
data class Success(val weatherInfo: WeatherInfo) : WeatherUiState()
data class Error(val message: String) : WeatherUiState()
}
// Fragment
@AndroidEntryPoint
class WeatherFragment : Fragment() {
private val viewModel: WeatherViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchButton.setOnClickListener {
val cityName = cityEditText.text.toString()
viewModel.getWeather(cityName)
}
lifecycleScope.launch {
viewModel.uiState.collect { state ->
when (state) {
is WeatherUiState.Loading -> {
progressBar.isVisible = true
weatherGroup.isVisible = false
errorText.isVisible = false
}
is WeatherUiState.Success -> {
progressBar.isVisible = false
weatherGroup.isVisible = true
errorText.isVisible = false
val weather = state.weatherInfo
locationText.text = weather.location
temperatureText.text = "${weather.temperature}°C"
conditionText.text = weather.condition
humidityText.text = "Humidity: ${weather.humidity}%"
windSpeedText.text = "Wind: ${weather.windSpeed} km/h"
}
is WeatherUiState.Error -> {
progressBar.isVisible = false
weatherGroup.isVisible = false
errorText.isVisible = true
errorText.text = state.message
}
}
}
}
}
}
Summary
Good architecture is essential for building maintainable and scalable Android applications with Kotlin. Throughout this guide, we've explored:
- The importance of architecture in Android development
- Key Android Architecture Components (ViewModel, LiveData, Room)
- Common architecture patterns:
- MVVM (Model-View-ViewModel)
- Clean Architecture
- MVI (Model-View-Intent)
- The Repository Pattern for data management
- Dependency Injection with Hilt
- Best practices for Android architecture
- A practical example of architecture in a weather app
Remember that architecture should serve your app's specific needs. Start with simple patterns and evolve as your app grows in complexity. The goal is to create maintainable code that your future self (and other developers) will thank you for.
Additional Resources
- Android Architecture Components Guide - Official Android documentation
- Guide to App Architecture - Google's guide for app architecture
- Android Architecture Samples - Sample apps from Google
- Clean Architecture for Android - Blog post by Android team
Exercise
Challenge: Build a simple to-do list app using MVVM architecture with the following features:
- Display a list of tasks
- Add new tasks
- Mark tasks as complete
- Delete tasks
- Persist tasks using Room database
Make sure to implement:
- A clear separation between UI, ViewModel, and Repository layers
- Proper error handling
- Unit tests for at least one component
- Use Kotlin Coroutines for asynchronous operations
Happy coding!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)