4337 words
22 minutes
Android MVVM Implementation Guide: Building Scalable Apps with ViewModel, LiveData, and Data Binding
Android MVVM Implementation Guide: Building Scalable Apps
Introduction
Model-View-ViewModel (MVVM) has become the de facto standard for Android application architecture. This comprehensive guide explores implementing MVVM using Android’s Architecture Components, modern state management techniques, and best practices derived from Google’s official samples.
Understanding MVVM Architecture
Core Components
MVVM separates application logic into three distinct layers:
graph TB subgraph "MVVM Architecture" V[View] VM[ViewModel] M[Model]
V <--> VM VM <--> M
subgraph "View Layer" V --> ACTIVITY[Activity/Fragment] V --> COMPOSE[Compose UI] end
subgraph "ViewModel Layer" VM --> STATE[UI State] VM --> LOGIC[Business Logic] end
subgraph "Model Layer" M --> REPO[Repository] M --> DB[Database] M --> API[API Service] end end
Benefits of MVVM
- Separation of Concerns: Clear responsibility boundaries
- Testability: Easy unit testing of business logic
- Lifecycle Awareness: Automatic lifecycle management
- Data Binding: Reactive UI updates
- Configuration Survival: Survives configuration changes
Setting Up MVVM Foundation
Project Dependencies
// build.gradle (Module: app)dependencies { // Core AndroidX libraries implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
// ViewModel and LiveData implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
// Fragment and Activity implementation "androidx.fragment:fragment-ktx:1.6.2" implementation "androidx.activity:activity-ktx:1.8.2"
// Data Binding implementation "androidx.databinding:databinding-runtime:8.2.0"
// Navigation implementation "androidx.navigation:navigation-fragment-ktx:2.7.5" implementation "androidx.navigation:navigation-ui-ktx:2.7.5"
// Room Database implementation "androidx.room:room-runtime:2.6.1" implementation "androidx.room:room-ktx:2.6.1" kapt "androidx.room:room-compiler:2.6.1"
// Coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
// Hilt Dependency Injection implementation "com.google.dagger:hilt-android:2.48.1" kapt "com.google.dagger:hilt-compiler:2.48.1"
// Testing testImplementation "junit:junit:4.13.2" testImplementation "org.mockito:mockito-core:5.7.0" testImplementation "androidx.arch.core:core-testing:2.2.0" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"}
android { buildFeatures { dataBinding = true viewBinding = true }}
Base Classes
// BaseViewModel.kt - Foundation ViewModelabstract class BaseViewModel : ViewModel() {
protected val _isLoading = MutableLiveData<Boolean>() val isLoading: LiveData<Boolean> = _isLoading
protected val _error = MutableLiveData<String?>() val error: LiveData<String?> = _error
protected fun handleError(throwable: Throwable) { _error.value = when (throwable) { is IOException -> "Network error. Please check your connection." is HttpException -> "Server error: ${throwable.message}" else -> "An unexpected error occurred: ${throwable.message}" } }
protected fun clearError() { _error.value = null }
protected fun setLoading(loading: Boolean) { _isLoading.value = loading }}
// BaseFragment.kt - Foundation Fragmentabstract class BaseFragment<VB : ViewDataBinding, VM : BaseViewModel> : Fragment() {
protected lateinit var binding: VB protected abstract val viewModel: VM
protected abstract fun getLayoutId(): Int protected abstract fun initializeViews() protected abstract fun observeViewModel()
override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { binding = DataBindingUtil.inflate(inflater, getLayoutId(), container, false) binding.lifecycleOwner = viewLifecycleOwner return binding.root }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initializeViews() observeViewModel() setupErrorHandling() }
private fun setupErrorHandling() { viewModel.error.observe(viewLifecycleOwner) { error -> error?.let { showError(it) viewModel.clearError() } }
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> showLoading(isLoading) } }
protected open fun showError(message: String) { Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() }
protected open fun showLoading(show: Boolean) { // Override in subclasses to show/hide loading indicator }}
Model Layer Implementation
Data Models
// Task.kt - Domain Model@Parcelizedata class Task( val id: String = UUID.randomUUID().toString(), val title: String, val description: String, val isCompleted: Boolean = false, val priority: TaskPriority = TaskPriority.NORMAL, val dueDate: LocalDateTime? = null, val createdAt: LocalDateTime = LocalDateTime.now(), val updatedAt: LocalDateTime = LocalDateTime.now()) : Parcelable {
val isActive: Boolean get() = !isCompleted
val isOverdue: Boolean get() = dueDate?.isBefore(LocalDateTime.now()) == true && !isCompleted}
enum class TaskPriority(val displayName: String) { LOW("Low"), NORMAL("Normal"), HIGH("High"), URGENT("Urgent")}
// TaskEntity.kt - Database Entity@Entity( tableName = "tasks", indices = [ Index(value = ["completed"], name = "index_tasks_completed"), Index(value = ["priority"], name = "index_tasks_priority"), Index(value = ["due_date"], name = "index_tasks_due_date") ])data class TaskEntity( @PrimaryKey val id: String, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "description") val description: String, @ColumnInfo(name = "completed") val isCompleted: Boolean, @ColumnInfo(name = "priority") val priority: TaskPriority, @ColumnInfo(name = "due_date") val dueDate: LocalDateTime?, @ColumnInfo(name = "created_at") val createdAt: LocalDateTime, @ColumnInfo(name = "updated_at") val updatedAt: LocalDateTime)
// Extension functions for mappingfun TaskEntity.toDomainModel(): Task = Task( id = id, title = title, description = description, isCompleted = isCompleted, priority = priority, dueDate = dueDate, createdAt = createdAt, updatedAt = updatedAt)
fun Task.toEntity(): TaskEntity = TaskEntity( id = id, title = title, description = description, isCompleted = isCompleted, priority = priority, dueDate = dueDate, createdAt = createdAt, updatedAt = updatedAt)
Data Access Layer
// TaskDao.kt - Room Data Access Object@Daointerface TaskDao {
@Query("SELECT * FROM tasks ORDER BY created_at DESC") fun getAllTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE completed = 0 ORDER BY priority DESC, due_date ASC") fun getActiveTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE completed = 1 ORDER BY updated_at DESC") fun getCompletedTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :taskId") fun getTaskById(taskId: String): Flow<TaskEntity?>
@Query("SELECT * FROM tasks WHERE due_date <= :date AND completed = 0") suspend fun getOverdueTasks(date: LocalDateTime): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%'") fun searchTasks(query: String): Flow<List<TaskEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTask(task: TaskEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTasks(tasks: List<TaskEntity>)
@Update suspend fun updateTask(task: TaskEntity)
@Delete suspend fun deleteTask(task: TaskEntity)
@Query("DELETE FROM tasks WHERE id = :taskId") suspend fun deleteTaskById(taskId: String)
@Query("DELETE FROM tasks WHERE completed = 1") suspend fun deleteCompletedTasks()
@Query("UPDATE tasks SET completed = :completed, updated_at = :updatedAt WHERE id = :taskId") suspend fun updateTaskCompletion(taskId: String, completed: Boolean, updatedAt: LocalDateTime)
@Transaction suspend fun updateTaskWithTimestamp(task: TaskEntity) { val updatedTask = task.copy(updatedAt = LocalDateTime.now()) updateTask(updatedTask) }}
// AppDatabase.kt - Room Database@Database( entities = [TaskEntity::class], version = 1, exportSchema = false)@TypeConverters(DateConverters::class)abstract class AppDatabase : RoomDatabase() { abstract fun taskDao(): TaskDao}
// DateConverters.kt - Type Convertersclass DateConverters { @TypeConverter fun fromTimestamp(value: Long?): LocalDateTime? { return value?.let { LocalDateTime.ofEpochSecond(it, 0, ZoneOffset.UTC) } }
@TypeConverter fun dateToTimestamp(date: LocalDateTime?): Long? { return date?.toEpochSecond(ZoneOffset.UTC) }
@TypeConverter fun fromPriority(priority: TaskPriority): String = priority.name
@TypeConverter fun toPriority(priority: String): TaskPriority = TaskPriority.valueOf(priority)}
Repository Implementation
// TasksRepository.kt - Repository Interfaceinterface TasksRepository { fun getAllTasks(): Flow<List<Task>> fun getActiveTasks(): Flow<List<Task>> fun getCompletedTasks(): Flow<List<Task>> fun getTaskById(taskId: String): Flow<Task?> fun searchTasks(query: String): Flow<List<Task>> suspend fun getOverdueTasks(): List<Task> suspend fun saveTask(task: Task) suspend fun updateTask(task: Task) suspend fun deleteTask(taskId: String) suspend fun completeTask(taskId: String) suspend fun deleteCompletedTasks() suspend fun refreshTasks()}
// DefaultTasksRepository.kt - Repository Implementation@Singletonclass DefaultTasksRepository @Inject constructor( private val localDataSource: TaskDao, private val remoteDataSource: TasksRemoteDataSource?, @IoDispatcher private val dispatcher: CoroutineDispatcher) : TasksRepository {
override fun getAllTasks(): Flow<List<Task>> { return localDataSource.getAllTasks() .map { entities -> entities.map { it.toDomainModel() } } .flowOn(dispatcher) }
override fun getActiveTasks(): Flow<List<Task>> { return localDataSource.getActiveTasks() .map { entities -> entities.map { it.toDomainModel() } } .flowOn(dispatcher) }
override fun getCompletedTasks(): Flow<List<Task>> { return localDataSource.getCompletedTasks() .map { entities -> entities.map { it.toDomainModel() } } .flowOn(dispatcher) }
override fun getTaskById(taskId: String): Flow<Task?> { return localDataSource.getTaskById(taskId) .map { entity -> entity?.toDomainModel() } .flowOn(dispatcher) }
override fun searchTasks(query: String): Flow<List<Task>> { return localDataSource.searchTasks(query) .map { entities -> entities.map { it.toDomainModel() } } .flowOn(dispatcher) }
override suspend fun getOverdueTasks(): List<Task> { return withContext(dispatcher) { localDataSource.getOverdueTasks(LocalDateTime.now()) .map { it.toDomainModel() } } }
override suspend fun saveTask(task: Task) { withContext(dispatcher) { try { val entity = task.toEntity() localDataSource.insertTask(entity)
// Sync with remote if available remoteDataSource?.saveTask(task) } catch (e: Exception) { throw TaskRepositoryException("Failed to save task", e) } } }
override suspend fun updateTask(task: Task) { withContext(dispatcher) { try { val entity = task.copy(updatedAt = LocalDateTime.now()).toEntity() localDataSource.updateTask(entity)
// Sync with remote if available remoteDataSource?.updateTask(task) } catch (e: Exception) { throw TaskRepositoryException("Failed to update task", e) } } }
override suspend fun deleteTask(taskId: String) { withContext(dispatcher) { try { localDataSource.deleteTaskById(taskId) remoteDataSource?.deleteTask(taskId) } catch (e: Exception) { throw TaskRepositoryException("Failed to delete task", e) } } }
override suspend fun completeTask(taskId: String) { withContext(dispatcher) { try { localDataSource.updateTaskCompletion( taskId = taskId, completed = true, updatedAt = LocalDateTime.now() ) remoteDataSource?.completeTask(taskId) } catch (e: Exception) { throw TaskRepositoryException("Failed to complete task", e) } } }
override suspend fun deleteCompletedTasks() { withContext(dispatcher) { try { localDataSource.deleteCompletedTasks() remoteDataSource?.deleteCompletedTasks() } catch (e: Exception) { throw TaskRepositoryException("Failed to delete completed tasks", e) } } }
override suspend fun refreshTasks() { withContext(dispatcher) { try { remoteDataSource?.let { remote -> val remoteTasks = remote.getAllTasks() val entities = remoteTasks.map { it.toEntity() } localDataSource.insertTasks(entities) } } catch (e: Exception) { // Fail silently for refresh - local data still available Timber.w(e, "Failed to refresh tasks from remote") } } }}
// Custom Exceptionclass TaskRepositoryException(message: String, cause: Throwable? = null) : Exception(message, cause)
ViewModel Implementation
UI State Management
// TasksUiState.kt - UI State Classesdata class TasksUiState( val tasks: List<Task> = emptyList(), val isLoading: Boolean = false, val isEmpty: Boolean = false, val errorMessage: String? = null, val filter: TaskFilter = TaskFilter.ALL, val searchQuery: String = "", val selectedTask: Task? = null) { val filteredTasks: List<Task> get() = when (filter) { TaskFilter.ALL -> tasks TaskFilter.ACTIVE -> tasks.filter { !it.isCompleted } TaskFilter.COMPLETED -> tasks.filter { it.isCompleted } TaskFilter.OVERDUE -> tasks.filter { it.isOverdue } TaskFilter.HIGH_PRIORITY -> tasks.filter { it.priority == TaskPriority.HIGH || it.priority == TaskPriority.URGENT } }.let { filtered -> if (searchQuery.isBlank()) filtered else filtered.filter { it.title.contains(searchQuery, ignoreCase = true) || it.description.contains(searchQuery, ignoreCase = true) } }}
enum class TaskFilter(val displayName: String) { ALL("All Tasks"), ACTIVE("Active"), COMPLETED("Completed"), OVERDUE("Overdue"), HIGH_PRIORITY("High Priority")}
// UI Eventssealed class TasksUiEvent { object LoadTasks : TasksUiEvent() object RefreshTasks : TasksUiEvent() data class SearchTasks(val query: String) : TasksUiEvent() data class FilterTasks(val filter: TaskFilter) : TasksUiEvent() data class CompleteTask(val taskId: String) : TasksUiEvent() data class DeleteTask(val taskId: String) : TasksUiEvent() data class SelectTask(val task: Task) : TasksUiEvent() object ClearSelection : TasksUiEvent() object DeleteCompletedTasks : TasksUiEvent() object DismissError : TasksUiEvent()}
ViewModel Implementation
// TasksViewModel.kt - Feature ViewModel@HiltViewModelclass TasksViewModel @Inject constructor( private val tasksRepository: TasksRepository, private val analyticsTracker: AnalyticsTracker, @IoDispatcher private val dispatcher: CoroutineDispatcher) : ViewModel() {
private val _uiState = MutableStateFlow(TasksUiState(isLoading = true)) val uiState: StateFlow<TasksUiState> = _uiState.asStateFlow()
// For backward compatibility with Views val tasks: LiveData<List<Task>> = uiState .map { it.filteredTasks } .asLiveData()
val isLoading: LiveData<Boolean> = uiState .map { it.isLoading } .asLiveData()
val isEmpty: LiveData<Boolean> = uiState .map { it.isEmpty } .asLiveData()
init { loadTasks() startPeriodicRefresh() }
fun handleEvent(event: TasksUiEvent) { when (event) { is TasksUiEvent.LoadTasks -> loadTasks() is TasksUiEvent.RefreshTasks -> refreshTasks() is TasksUiEvent.SearchTasks -> searchTasks(event.query) is TasksUiEvent.FilterTasks -> filterTasks(event.filter) is TasksUiEvent.CompleteTask -> completeTask(event.taskId) is TasksUiEvent.DeleteTask -> deleteTask(event.taskId) is TasksUiEvent.SelectTask -> selectTask(event.task) is TasksUiEvent.ClearSelection -> clearSelection() is TasksUiEvent.DeleteCompletedTasks -> deleteCompletedTasks() is TasksUiEvent.DismissError -> dismissError() } }
private fun loadTasks() { viewModelScope.launch { try { _uiState.update { it.copy(isLoading = true, errorMessage = null) }
tasksRepository.getAllTasks() .catch { throwable -> _uiState.update { it.copy( isLoading = false, errorMessage = "Failed to load tasks: ${throwable.message}" ) } analyticsTracker.trackError("load_tasks_failed", throwable) } .collect { taskList -> _uiState.update { it.copy( tasks = taskList, isLoading = false, isEmpty = taskList.isEmpty(), errorMessage = null ) } analyticsTracker.trackEvent("tasks_loaded", mapOf("count" to taskList.size)) } } catch (e: Exception) { handleError(e) } } }
private fun refreshTasks() { viewModelScope.launch(dispatcher) { try { _uiState.update { it.copy(isLoading = true) } tasksRepository.refreshTasks() analyticsTracker.trackEvent("tasks_refreshed") } catch (e: Exception) { handleError(e) } } }
private fun searchTasks(query: String) { _uiState.update { it.copy(searchQuery = query) } analyticsTracker.trackEvent("tasks_searched", mapOf("query_length" to query.length)) }
private fun filterTasks(filter: TaskFilter) { _uiState.update { it.copy(filter = filter) } analyticsTracker.trackEvent("tasks_filtered", mapOf("filter" to filter.name)) }
private fun completeTask(taskId: String) { viewModelScope.launch(dispatcher) { try { tasksRepository.completeTask(taskId) showTemporaryMessage("Task completed") analyticsTracker.trackEvent("task_completed") } catch (e: Exception) { handleError(e) } } }
private fun deleteTask(taskId: String) { viewModelScope.launch(dispatcher) { try { tasksRepository.deleteTask(taskId) showTemporaryMessage("Task deleted") analyticsTracker.trackEvent("task_deleted") } catch (e: Exception) { handleError(e) } } }
private fun selectTask(task: Task) { _uiState.update { it.copy(selectedTask = task) } }
private fun clearSelection() { _uiState.update { it.copy(selectedTask = null) } }
private fun deleteCompletedTasks() { viewModelScope.launch(dispatcher) { try { tasksRepository.deleteCompletedTasks() showTemporaryMessage("Completed tasks deleted") analyticsTracker.trackEvent("completed_tasks_deleted") } catch (e: Exception) { handleError(e) } } }
private fun dismissError() { _uiState.update { it.copy(errorMessage = null) } }
private fun handleError(throwable: Throwable) { val errorMessage = when (throwable) { is IOException -> "Network error. Please check your connection." is TaskRepositoryException -> throwable.message ?: "Repository error occurred" else -> "An unexpected error occurred" }
_uiState.update { it.copy( isLoading = false, errorMessage = errorMessage ) }
analyticsTracker.trackError("tasks_error", throwable) }
private fun showTemporaryMessage(message: String) { _uiState.update { it.copy(errorMessage = message) }
// Clear message after delay viewModelScope.launch { delay(3000) _uiState.update { currentState -> if (currentState.errorMessage == message) { currentState.copy(errorMessage = null) } else { currentState } } } }
private fun startPeriodicRefresh() { viewModelScope.launch { while (true) { delay(TimeUnit.MINUTES.toMillis(5)) // Refresh every 5 minutes if (uiState.value.tasks.isNotEmpty()) { refreshTasks() } } } }
// Convenience methods for View layer fun refresh() = handleEvent(TasksUiEvent.RefreshTasks) fun search(query: String) = handleEvent(TasksUiEvent.SearchTasks(query)) fun filter(filter: TaskFilter) = handleEvent(TasksUiEvent.FilterTasks(filter)) fun completeTask(taskId: String) = handleEvent(TasksUiEvent.CompleteTask(taskId)) fun deleteTask(taskId: String) = handleEvent(TasksUiEvent.DeleteTask(taskId))}
Specialized ViewModels
// TaskDetailViewModel.kt - Detail Screen ViewModel@HiltViewModelclass TaskDetailViewModel @Inject constructor( private val tasksRepository: TasksRepository, private val savedStateHandle: SavedStateHandle) : BaseViewModel() {
private val taskId: String = savedStateHandle.get<String>("taskId") ?: throw IllegalArgumentException("TaskId is required")
private val _task = MutableLiveData<Task?>() val task: LiveData<Task?> = _task
private val _isEditing = MutableLiveData(false) val isEditing: LiveData<Boolean> = _isEditing
// Two-way data binding fields val title = MutableLiveData<String>() val description = MutableLiveData<String>() val priority = MutableLiveData<TaskPriority>() val dueDate = MutableLiveData<LocalDateTime?>()
init { loadTask() setupFieldObservers() }
private fun loadTask() { viewModelScope.launch { try { setLoading(true) tasksRepository.getTaskById(taskId) .catch { throwable -> handleError(throwable) } .collect { task -> _task.value = task task?.let { populateFields(it) } setLoading(false) } } catch (e: Exception) { handleError(e) setLoading(false) } } }
private fun populateFields(task: Task) { title.value = task.title description.value = task.description priority.value = task.priority dueDate.value = task.dueDate }
private fun setupFieldObservers() { // Enable editing mode when fields are modified val observer = Observer<Any> { _isEditing.value = true }
title.observeForever(observer) description.observeForever(observer) priority.observeForever(observer) dueDate.observeForever(observer) }
fun saveTask() { val currentTask = _task.value ?: return val updatedTask = currentTask.copy( title = title.value ?: "", description = description.value ?: "", priority = priority.value ?: TaskPriority.NORMAL, dueDate = dueDate.value, updatedAt = LocalDateTime.now() )
viewModelScope.launch { try { setLoading(true) tasksRepository.updateTask(updatedTask) _isEditing.value = false setLoading(false) } catch (e: Exception) { handleError(e) setLoading(false) } } }
fun discardChanges() { _task.value?.let { populateFields(it) } _isEditing.value = false }
fun toggleComplete() { viewModelScope.launch { try { tasksRepository.completeTask(taskId) } catch (e: Exception) { handleError(e) } } }}
// AddTaskViewModel.kt - Add Task ViewModel@HiltViewModelclass AddTaskViewModel @Inject constructor( private val tasksRepository: TasksRepository) : BaseViewModel() {
// Two-way data binding fields val title = MutableLiveData<String>() val description = MutableLiveData<String>() val priority = MutableLiveData(TaskPriority.NORMAL) val dueDate = MutableLiveData<LocalDateTime?>()
// Validation private val _titleError = MutableLiveData<String?>() val titleError: LiveData<String?> = _titleError
private val _isSaveEnabled = MediatorLiveData<Boolean>().apply { addSource(title) { updateSaveEnabled() } addSource(description) { updateSaveEnabled() } } val isSaveEnabled: LiveData<Boolean> = _isSaveEnabled
private fun updateSaveEnabled() { val titleValid = !title.value.isNullOrBlank() val descriptionValid = !description.value.isNullOrBlank() _isSaveEnabled.value = titleValid && descriptionValid }
fun saveTask(): LiveData<Boolean> { val result = MutableLiveData<Boolean>()
if (!validateInput()) { result.value = false return result }
val task = Task( title = title.value!!, description = description.value!!, priority = priority.value ?: TaskPriority.NORMAL, dueDate = dueDate.value )
viewModelScope.launch { try { setLoading(true) tasksRepository.saveTask(task) setLoading(false) result.postValue(true) } catch (e: Exception) { handleError(e) setLoading(false) result.postValue(false) } }
return result }
private fun validateInput(): Boolean { var isValid = true
if (title.value.isNullOrBlank()) { _titleError.value = "Title is required" isValid = false } else { _titleError.value = null }
return isValid }
fun clearForm() { title.value = "" description.value = "" priority.value = TaskPriority.NORMAL dueDate.value = null _titleError.value = null }}
View Layer Implementation
Fragment with Data Binding
// TasksFragment.kt - Main Tasks Screen@AndroidEntryPointclass TasksFragment : BaseFragment<FragmentTasksBinding, TasksViewModel>() {
override val viewModel: TasksViewModel by viewModels() private lateinit var tasksAdapter: TasksAdapter
override fun getLayoutId(): Int = R.layout.fragment_tasks
override fun initializeViews() { setupRecyclerView() setupSwipeRefresh() setupFab() setupSearchView() setupFilterChips() }
override fun observeViewModel() { // Observe UI state with StateFlow viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { uiState -> updateUI(uiState) } }
// Observe tasks with LiveData (for compatibility) viewModel.tasks.observe(viewLifecycleOwner) { tasks -> tasksAdapter.submitList(tasks) updateEmptyState(tasks.isEmpty()) }
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> binding.swipeRefresh.isRefreshing = isLoading } }
private fun setupRecyclerView() { tasksAdapter = TasksAdapter( onTaskClick = { task -> findNavController().navigate( TasksFragmentDirections.actionTasksToTaskDetail(task.id) ) }, onTaskComplete = { task -> viewModel.completeTask(task.id) }, onTaskDelete = { task -> showDeleteConfirmation(task) } )
binding.recyclerViewTasks.apply { adapter = tasksAdapter layoutManager = LinearLayoutManager(context)
// Add item decoration addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
// Setup item animations itemAnimator = DefaultItemAnimator().apply { addDuration = 300 removeDuration = 300 } } }
private fun setupSwipeRefresh() { binding.swipeRefresh.setOnRefreshListener { viewModel.refresh() } }
private fun setupFab() { binding.fabAddTask.setOnClickListener { findNavController().navigate( TasksFragmentDirections.actionTasksToAddTask() ) } }
private fun setupSearchView() { binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { query?.let { viewModel.search(it) } return true }
override fun onQueryTextChange(newText: String?): Boolean { newText?.let { viewModel.search(it) } return true } }) }
private fun setupFilterChips() { binding.chipGroupFilters.setOnCheckedStateChangeListener { _, checkedIds -> val filter = when (checkedIds.firstOrNull()) { R.id.chip_all -> TaskFilter.ALL R.id.chip_active -> TaskFilter.ACTIVE R.id.chip_completed -> TaskFilter.COMPLETED R.id.chip_overdue -> TaskFilter.OVERDUE R.id.chip_high_priority -> TaskFilter.HIGH_PRIORITY else -> TaskFilter.ALL } viewModel.filter(filter) } }
private fun updateUI(uiState: TasksUiState) { binding.apply { // Update loading state swipeRefresh.isRefreshing = uiState.isLoading
// Update empty state updateEmptyState(uiState.isEmpty)
// Update search query if (searchView.query.toString() != uiState.searchQuery) { searchView.setQuery(uiState.searchQuery, false) }
// Update filter chips updateFilterChips(uiState.filter)
// Handle errors uiState.errorMessage?.let { error -> showError(error) viewModel.handleEvent(TasksUiEvent.DismissError) } } }
private fun updateEmptyState(isEmpty: Boolean) { binding.groupEmptyState.isVisible = isEmpty binding.recyclerViewTasks.isVisible = !isEmpty }
private fun updateFilterChips(filter: TaskFilter) { val chipId = when (filter) { TaskFilter.ALL -> R.id.chip_all TaskFilter.ACTIVE -> R.id.chip_active TaskFilter.COMPLETED -> R.id.chip_completed TaskFilter.OVERDUE -> R.id.chip_overdue TaskFilter.HIGH_PRIORITY -> R.id.chip_high_priority } binding.chipGroupFilters.check(chipId) }
private fun showDeleteConfirmation(task: Task) { AlertDialog.Builder(requireContext()) .setTitle("Delete Task") .setMessage("Are you sure you want to delete '${task.title}'?") .setPositiveButton("Delete") { _, _ -> viewModel.deleteTask(task.id) } .setNegativeButton("Cancel", null) .show() }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.menu_tasks, menu) super.onCreateOptionsMenu(menu, inflater) }
override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_delete_completed -> { viewModel.handleEvent(TasksUiEvent.DeleteCompletedTasks) true } R.id.action_refresh -> { viewModel.refresh() true } else -> super.onOptionsItemSelected(item) } }}
RecyclerView Adapter
// TasksAdapter.kt - RecyclerView Adapter with Data Bindingclass TasksAdapter( private val onTaskClick: (Task) -> Unit, private val onTaskComplete: (Task) -> Unit, private val onTaskDelete: (Task) -> Unit) : ListAdapter<Task, TasksAdapter.TaskViewHolder>(TaskDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder { val binding = ItemTaskBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return TaskViewHolder(binding) }
override fun onBindViewHolder(holder: TaskViewHolder, position: Int) { holder.bind(getItem(position)) }
inner class TaskViewHolder( private val binding: ItemTaskBinding ) : RecyclerView.ViewHolder(binding.root) {
fun bind(task: Task) { binding.apply { // Set data binding variables this.task = task
// Set click listeners root.setOnClickListener { onTaskClick(task) }
checkboxCompleted.setOnCheckedChangeListener { _, isChecked -> if (isChecked != task.isCompleted) { onTaskComplete(task) } }
buttonDelete.setOnClickListener { onTaskDelete(task) }
// Custom binding logic updatePriorityIndicator(task.priority) updateDueDateStatus(task.dueDate, task.isCompleted)
// Execute pending bindings executePendingBindings() } }
private fun updatePriorityIndicator(priority: TaskPriority) { val color = when (priority) { TaskPriority.LOW -> R.color.priority_low TaskPriority.NORMAL -> R.color.priority_normal TaskPriority.HIGH -> R.color.priority_high TaskPriority.URGENT -> R.color.priority_urgent }
binding.viewPriorityIndicator.setBackgroundColor( ContextCompat.getColor(binding.root.context, color) ) }
private fun updateDueDateStatus(dueDate: LocalDateTime?, isCompleted: Boolean) { binding.textDueDate.apply { when { dueDate == null -> { isVisible = false } isCompleted -> { isVisible = true text = "Completed" setTextColor(ContextCompat.getColor(context, R.color.text_success)) } dueDate.isBefore(LocalDateTime.now()) -> { isVisible = true text = "Overdue" setTextColor(ContextCompat.getColor(context, R.color.text_error)) } else -> { isVisible = true text = formatDueDate(dueDate) setTextColor(ContextCompat.getColor(context, R.color.text_secondary)) } } } }
private fun formatDueDate(dueDate: LocalDateTime): String { val now = LocalDateTime.now() return when { dueDate.toLocalDate() == now.toLocalDate() -> "Today" dueDate.toLocalDate() == now.toLocalDate().plusDays(1) -> "Tomorrow" dueDate.isBefore(now.plusDays(7)) -> dueDate.format(DateTimeFormatter.ofPattern("EEE")) else -> dueDate.format(DateTimeFormatter.ofPattern("MMM dd")) } } }
class TaskDiffCallback : DiffUtil.ItemCallback<Task>() { override fun areItemsTheSame(oldItem: Task, newItem: Task): Boolean { return oldItem.id == newItem.id }
override fun areContentsTheSame(oldItem: Task, newItem: Task): Boolean { return oldItem == newItem } }}
Data Binding Layouts
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools">
<data> <variable name="viewModel" type="com.example.tasks.ui.tasks.TasksViewModel" /> </data>
<androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:title="Tasks" />
<androidx.appcompat.widget.SearchView android:id="@+id/search_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" app:queryHint="Search tasks..." />
<com.google.android.material.chip.ChipGroup android:id="@+id/chip_group_filters" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="8dp" app:selectionRequired="true" app:singleSelection="true">
<com.google.android.material.chip.Chip android:id="@+id/chip_all" style="@style/Widget.Material3.Chip.Filter" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="true" android:text="All" />
<com.google.android.material.chip.Chip android:id="@+id/chip_active" style="@style/Widget.Material3.Chip.Filter" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Active" />
<com.google.android.material.chip.Chip android:id="@+id/chip_completed" style="@style/Widget.Material3.Chip.Filter" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Completed" />
<com.google.android.material.chip.Chip android:id="@+id/chip_overdue" style="@style/Widget.Material3.Chip.Filter" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Overdue" />
<com.google.android.material.chip.Chip android:id="@+id/chip_high_priority" style="@style/Widget.Material3.Chip.Filter" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="High Priority" />
</com.google.android.material.chip.ChipGroup>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:id="@+id/swipe_refresh" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view_tasks" android:layout_width="match_parent" android:layout_height="match_parent" android:clipToPadding="false" android:paddingBottom="88dp" tools:listitem="@layout/item_task" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Empty State --> <androidx.constraintlayout.widget.Group android:id="@+id/group_empty_state" android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone" app:constraint_referenced_ids="image_empty,text_empty_title,text_empty_subtitle" tools:visibility="visible" />
<ImageView android:id="@+id/image_empty" android:layout_width="120dp" android:layout_height="120dp" android:layout_gravity="center" android:layout_marginBottom="32dp" android:alpha="0.6" android:src="@drawable/ic_tasks_empty" app:tint="?attr/colorOnSurfaceVariant" />
<TextView android:id="@+id/text_empty_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="No tasks yet" android:textAppearance="?attr/textAppearanceHeadlineMedium" />
<TextView android:id="@+id/text_empty_subtitle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:layout_marginTop="8dp" android:text="Tap the + button to add your first task" android:textAppearance="?attr/textAppearanceBodyMedium" android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/fab_add_task" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="16dp" android:contentDescription="Add task" app:srcCompat="@drawable/ic_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools">
<data> <variable name="task" type="com.example.tasks.data.model.Task" /> </data>
<com.google.android.material.card.MaterialCardView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" app:cardCornerRadius="8dp" app:cardElevation="2dp" app:strokeWidth="0dp">
<androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp">
<View android:id="@+id/view_priority_indicator" android:layout_width="4dp" android:layout_height="0dp" android:layout_marginEnd="12dp" android:background="@color/priority_normal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" />
<CheckBox android:id="@+id/checkbox_completed" android:layout_width="wrap_content" android:layout_height="wrap_content" android:checked="@{task.completed}" app:layout_constraintStart_toEndOf="@id/view_priority_indicator" app:layout_constraintTop_toTopOf="parent" />
<TextView android:id="@+id/text_title" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginEnd="8dp" android:text="@{task.title}" android:textAppearance="?attr/textAppearanceBodyLarge" android:textStyle="@{task.completed ? `italic` : `normal`}" app:layout_constraintEnd_toStartOf="@id/button_delete" app:layout_constraintStart_toEndOf="@id/checkbox_completed" app:layout_constraintTop_toTopOf="@id/checkbox_completed" app:strikeThrough="@{task.completed}" tools:text="Sample Task Title" />
<TextView android:id="@+id/text_description" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginTop="4dp" android:layout_marginEnd="8dp" android:text="@{task.description}" android:textAppearance="?attr/textAppearanceBodyMedium" android:textColor="?attr/colorOnSurfaceVariant" android:visibility="@{task.description.empty ? View.GONE : View.VISIBLE}" app:layout_constraintEnd_toStartOf="@id/button_delete" app:layout_constraintStart_toEndOf="@id/checkbox_completed" app:layout_constraintTop_toBottomOf="@id/text_title" tools:text="Sample task description" />
<TextView android:id="@+id/text_due_date" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginTop="4dp" android:textAppearance="?attr/textAppearanceBodySmall" android:visibility="gone" app:layout_constraintStart_toEndOf="@id/checkbox_completed" app:layout_constraintTop_toBottomOf="@id/text_description" tools:text="Due Today" tools:visibility="visible" />
<ImageButton android:id="@+id/button_delete" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="Delete task" android:padding="8dp" android:src="@drawable/ic_delete" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:tint="?attr/colorError" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</layout>
Testing MVVM Implementation
ViewModel Testing
// TasksViewModelTest.kt - Comprehensive ViewModel Tests@ExperimentalCoroutinesApiclass TasksViewModelTest {
@get:Rule var mainCoroutineRule = MainCoroutineRule()
@get:Rule var instantExecutorRule = InstantTaskExecutorRule()
// Test doubles private lateinit var tasksRepository: FakeTasksRepository private lateinit var analyticsTracker: FakeAnalyticsTracker
// Subject under test private lateinit var viewModel: TasksViewModel
@Before fun setupViewModel() { tasksRepository = FakeTasksRepository() analyticsTracker = FakeAnalyticsTracker()
viewModel = TasksViewModel( tasksRepository = tasksRepository, analyticsTracker = analyticsTracker, dispatcher = mainCoroutineRule.dispatcher ) }
@Test fun `loadTasks - success - updates ui state correctly`() = runTest { // Given val tasks = listOf( Task("1", "Task 1", "Description 1"), Task("2", "Task 2", "Description 2", isCompleted = true) ) tasksRepository.addTasks(tasks)
// When viewModel.handleEvent(TasksUiEvent.LoadTasks)
// Then val uiState = viewModel.uiState.value assertEquals(tasks, uiState.tasks) assertFalse(uiState.isLoading) assertNull(uiState.errorMessage) assertFalse(uiState.isEmpty) }
@Test fun `loadTasks - error - shows error message`() = runTest { // Given tasksRepository.setShouldReturnError(true)
// When viewModel.handleEvent(TasksUiEvent.LoadTasks)
// Then val uiState = viewModel.uiState.value assertTrue(uiState.tasks.isEmpty()) assertFalse(uiState.isLoading) assertNotNull(uiState.errorMessage) assertTrue(uiState.isEmpty) }
@Test fun `filterTasks - active - shows only active tasks`() = runTest { // Given val tasks = listOf( Task("1", "Active Task", "Description", isCompleted = false), Task("2", "Completed Task", "Description", isCompleted = true) ) tasksRepository.addTasks(tasks) viewModel.handleEvent(TasksUiEvent.LoadTasks)
// When viewModel.handleEvent(TasksUiEvent.FilterTasks(TaskFilter.ACTIVE))
// Then val uiState = viewModel.uiState.value assertEquals(TaskFilter.ACTIVE, uiState.filter) assertEquals(1, uiState.filteredTasks.size) assertFalse(uiState.filteredTasks[0].isCompleted) }
@Test fun `searchTasks - updates search query and filters results`() = runTest { // Given val tasks = listOf( Task("1", "Important Task", "Urgent description"), Task("2", "Regular Task", "Normal description") ) tasksRepository.addTasks(tasks) viewModel.handleEvent(TasksUiEvent.LoadTasks)
// When viewModel.handleEvent(TasksUiEvent.SearchTasks("Important"))
// Then val uiState = viewModel.uiState.value assertEquals("Important", uiState.searchQuery) assertEquals(1, uiState.filteredTasks.size) assertTrue(uiState.filteredTasks[0].title.contains("Important")) }
@Test fun `completeTask - success - tracks analytics event`() = runTest { // Given val task = Task("1", "Task", "Description") tasksRepository.addTasks(listOf(task))
// When viewModel.handleEvent(TasksUiEvent.CompleteTask(task.id))
// Then verify(analyticsTracker).trackEvent("task_completed") assertTrue(tasksRepository.isTaskCompleted(task.id)) }}
// FakeTasksRepository.kt - Test Doubleclass FakeTasksRepository : TasksRepository { private val tasksData = mutableMapOf<String, Task>() private var shouldReturnError = false
fun addTasks(tasks: List<Task>) { tasks.forEach { tasksData[it.id] = it } }
fun setShouldReturnError(value: Boolean) { shouldReturnError = value }
fun isTaskCompleted(taskId: String): Boolean { return tasksData[taskId]?.isCompleted ?: false }
override fun getAllTasks(): Flow<List<Task>> = flow { if (shouldReturnError) { throw IOException("Network error") } emit(tasksData.values.toList()) }
override suspend fun completeTask(taskId: String) { if (shouldReturnError) { throw IOException("Network error") } tasksData[taskId] = tasksData[taskId]?.copy(isCompleted = true) ?: throw TaskRepositoryException("Task not found") }
// Implement other methods...}
Integration Testing
// TasksRepositoryTest.kt - Integration Tests@ExperimentalCoroutinesApi@RunWith(AndroidJUnit4::class)class TasksRepositoryTest {
@get:Rule var mainCoroutineRule = MainCoroutineRule()
private lateinit var database: AppDatabase private lateinit var taskDao: TaskDao private lateinit var repository: DefaultTasksRepository private lateinit var remoteDataSource: FakeTasksRemoteDataSource
@Before fun createDb() { val context = ApplicationProvider.getApplicationContext<Context>() database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() .build() taskDao = database.taskDao() remoteDataSource = FakeTasksRemoteDataSource()
repository = DefaultTasksRepository( localDataSource = taskDao, remoteDataSource = remoteDataSource, dispatcher = mainCoroutineRule.dispatcher ) }
@After fun closeDb() { database.close() }
@Test fun saveTask_savesToLocalAndRemote() = runTest { // Given val task = Task("1", "Test Task", "Description")
// When repository.saveTask(task)
// Then val localTasks = repository.getAllTasks().first() val remoteTasks = remoteDataSource.getAllTasks()
assertTrue(localTasks.contains(task)) assertTrue(remoteTasks.contains(task)) }
@Test fun getAllTasks_returnsLocalTasksWhenRemoteUnavailable() = runTest { // Given val localTask = Task("1", "Local Task", "Description") taskDao.insertTask(localTask.toEntity()) remoteDataSource.setReturnError(true)
// When val tasks = repository.getAllTasks().first()
// Then assertEquals(1, tasks.size) assertEquals(localTask, tasks[0]) }}
Migration Strategies
From MVP to MVVM
// Before: MVP Patterninterface TasksContract { interface View { fun showTasks(tasks: List<Task>) fun showLoadingIndicator(show: Boolean) fun showError(message: String) }
interface Presenter { fun loadTasks() fun addTask(task: Task) fun completeTask(taskId: String) }}
class TasksPresenter( private val view: TasksContract.View, private val repository: TasksRepository) : TasksContract.Presenter {
fun loadTasks() { view.showLoadingIndicator(true) // Manual lifecycle management and error handling repository.getAllTasks { result -> view.showLoadingIndicator(false) when (result) { is Success -> view.showTasks(result.data) is Error -> view.showError(result.message) } } }}
// After: MVVM Pattern@HiltViewModelclass TasksViewModel @Inject constructor( private val repository: TasksRepository) : ViewModel() {
private val _uiState = MutableStateFlow(TasksUiState()) val uiState: StateFlow<TasksUiState> = _uiState
fun loadTasks() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) }
repository.getAllTasks() .catch { throwable -> _uiState.update { it.copy(isLoading = false, errorMessage = throwable.message) } } .collect { tasks -> _uiState.update { it.copy(tasks = tasks, isLoading = false) } } } }}
From Views to Compose
// Migration from View-based to Compose MVVM@Composablefun TasksScreen( viewModel: TasksViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.handleEvent(TasksUiEvent.LoadTasks) }
TasksContent( uiState = uiState, onEvent = viewModel::handleEvent )}
@Composableprivate fun TasksContent( uiState: TasksUiState, onEvent: (TasksUiEvent) -> Unit) { Column { TasksSearchBar( query = uiState.searchQuery, onQueryChange = { onEvent(TasksUiEvent.SearchTasks(it)) } )
TasksFilterChips( selectedFilter = uiState.filter, onFilterSelected = { onEvent(TasksUiEvent.FilterTasks(it)) } )
LazyColumn { items(uiState.filteredTasks) { task -> TaskItem( task = task, onComplete = { onEvent(TasksUiEvent.CompleteTask(task.id)) }, onDelete = { onEvent(TasksUiEvent.DeleteTask(task.id)) } ) } } }}
Best Practices Summary
MVVM Implementation Guidelines
- Single Responsibility: Each ViewModel should handle one feature
- Reactive Programming: Use StateFlow/LiveData for reactive UI
- Lifecycle Awareness: Leverage ViewModelScope for coroutines
- Error Handling: Centralized error handling in base classes
- Testing: Comprehensive unit tests for ViewModels
- State Management: Immutable UI state with clear state updates
Performance Optimization
- StateFlow vs LiveData: Use StateFlow for better performance
- Background Threading: Use appropriate dispatchers
- Memory Management: Proper cleanup in ViewModels
- Data Binding: Efficient UI updates with data binding
- Caching: Implement proper caching strategies
Code Organization
app/├── data/│ ├── local/│ ├── remote/│ └── repository/├── domain/│ ├── model/│ └── usecase/├── ui/│ ├── base/│ ├── tasks/│ └── addtask/└── di/
Conclusion
MVVM architecture provides a robust foundation for Android applications, offering clear separation of concerns, excellent testability, and strong lifecycle management. By following the patterns demonstrated in Google’s Architecture Samples and implementing comprehensive testing strategies, developers can build maintainable, scalable Android applications that stand the test of time.
Key takeaways:
- Clear Architecture: Proper separation between View, ViewModel, and Model
- Modern Tools: Leverage StateFlow, Coroutines, and Data Binding
- Testing Excellence: Comprehensive unit and integration tests
- Performance: Efficient state management and lifecycle awareness
- Scalability: Patterns that support application growth and team collaboration
Android MVVM Implementation Guide: Building Scalable Apps with ViewModel, LiveData, and Data Binding
https://mranv.pages.dev/posts/android-mvvm-implementation-guide/