Ritesh Singh logo
Ritesh Sohlot
Ritesh Sohlot

MVVM Architecture in Android: A Complete Guide

Learn how to implement the Model-View-ViewModel (MVVM) architecture pattern in Android applications for better code organization and maintainability.

Published
Reading Time
7 min read
Views
0 views

The Model-View-ViewModel (MVVM) architecture pattern has become the standard for Android development, providing a clean separation of concerns and making applications more testable and maintainable. This comprehensive guide will walk you through implementing MVVM in Android applications using modern Android development practices.

What is MVVM?

MVVM is an architectural pattern that separates an application into three main components:

  • Model: Represents the data and business logic
  • View: Handles the UI and user interactions
  • ViewModel: Manages the UI-related data and survives configuration changes

Benefits of MVVM

  • Separation of Concerns: Clear boundaries between UI, business logic, and data
  • Testability: Each component can be tested independently
  • Maintainability: Easier to modify and extend
  • Lifecycle Awareness: ViewModels survive configuration changes
  • Data Binding: Automatic UI updates when data changes

Setting Up MVVM

Dependencies

Add the necessary dependencies to your build.gradle file:

dependencies {
    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
    
    // LiveData
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    
    // Data Binding
    implementation "androidx.databinding:databinding-runtime:$databinding_version"
}

Model Layer

The Model layer represents your data and business logic. It should be independent of the UI.

Data Classes

data class User(
    val id: Int,
    val name: String,
    val email: String,
    val avatar: String
)

Repository Pattern

interface UserRepository {
    suspend fun getUsers(): List<User>
    suspend fun getUserById(id: Int): User?
    suspend fun saveUser(user: User)
}

class UserRepositoryImpl(
    private val apiService: ApiService,
    private val userDao: UserDao
) : UserRepository {
    
    override suspend fun getUsers(): List<User> {
        return try {
            val users = apiService.getUsers()
            userDao.insertAll(users)
            users
        } catch (e: Exception) {
            userDao.getAll()
        }
    }
    
    override suspend fun getUserById(id: Int): User? {
        return userDao.getUserById(id)
    }
    
    override suspend fun saveUser(user: User) {
        userDao.insert(user)
    }
}

ViewModel Layer

The ViewModel manages UI-related data and survives configuration changes.

Basic ViewModel

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    
    private val _users = MutableLiveData<List<User>>()
    val users: LiveData<List<User>> = _users
    
    private val _loading = MutableLiveData<Boolean>()
    val loading: LiveData<Boolean> = _loading
    
    private val _error = MutableLiveData<String>()
    val error: LiveData<String> = _error
    
    fun loadUsers() {
        viewModelScope.launch {
            try {
                _loading.value = true
                _error.value = null
                val userList = repository.getUsers()
                _users.value = userList
            } catch (e: Exception) {
                _error.value = e.message
            } finally {
                _loading.value = false
            }
        }
    }
}

ViewModel with StateFlow (Modern Approach)

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUsers() {
        viewModelScope.launch {
            _uiState.value = UserUiState.Loading
            try {
                val users = repository.getUsers()
                _uiState.value = UserUiState.Success(users)
            } catch (e: Exception) {
                _uiState.value = UserUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

sealed class UserUiState {
    object Loading : UserUiState()
    data class Success(val users: List<User>) : UserUiState()
    data class Error(val message: String) : UserUiState()
}

View Layer

The View layer handles UI rendering and user interactions.

Activity with ViewModel

class UserActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityUserBinding
    private val viewModel: UserViewModel by viewModels {
        UserViewModelFactory(UserRepositoryImpl(apiService, userDao))
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityUserBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        setupObservers()
        viewModel.loadUsers()
    }
    
    private fun setupObservers() {
        viewModel.users.observe(this) { users ->
            // Update UI with users
            updateUserList(users)
        }
        
        viewModel.loading.observe(this) { isLoading ->
            binding.progressBar.isVisible = isLoading
        }
        
        viewModel.error.observe(this) { error ->
            error?.let { showError(it) }
        }
    }
    
    private fun updateUserList(users: List<User>) {
        // Update RecyclerView or other UI components
    }
    
    private fun showError(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_LONG).show()
    }
}

Fragment with ViewModel

class UserFragment : Fragment() {
    
    private var _binding: FragmentUserBinding? = null
    private val binding get() = _binding!!
    
    private val viewModel: UserViewModel by viewModels()
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentUserBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        setupObservers()
        viewModel.loadUsers()
    }
    
    private fun setupObservers() {
        viewModel.users.observe(viewLifecycleOwner) { users ->
            updateUserList(users)
        }
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

Compose with ViewModel

@Composable
fun UserScreen(
    viewModel: UserViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    LaunchedEffect(Unit) {
        viewModel.loadUsers()
    }
    
    when (uiState) {
        is UserUiState.Loading -> {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        }
        is UserUiState.Success -> {
            UserList(users = (uiState as UserUiState.Success).users)
        }
        is UserUiState.Error -> {
            ErrorScreen(
                message = (uiState as UserUiState.Error).message,
                onRetry = { viewModel.loadUsers() }
            )
        }
    }
}

@Composable
fun UserList(users: List<User>) {
    LazyColumn {
        items(users) { user ->
            UserItem(user = user)
        }
    }
}

@Composable
fun UserItem(user: User) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            AsyncImage(
                model = user.avatar,
                contentDescription = "User avatar",
                modifier = Modifier
                    .size(50.dp)
                    .clip(CircleShape)
            )
            
            Spacer(modifier = Modifier.width(16.dp))
            
            Column {
                Text(
                    text = user.name,
                    style = MaterialTheme.typography.h6
                )
                Text(
                    text = user.email,
                    style = MaterialTheme.typography.body2
                )
            }
        }
    }
}

Data Binding

Data binding allows you to bind UI components to data sources in your layout files.

Layout with Data Binding

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="user"
            type="com.example.User" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{user.name}"
            android:textSize="18sp" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{user.email}"
            android:textSize="14sp" />

    </LinearLayout>
</layout>

Using Data Binding in Activity

class UserDetailActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityUserDetailBinding
    private val viewModel: UserViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityUserDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        binding.lifecycleOwner = this
        binding.viewModel = viewModel
        
        val userId = intent.getIntExtra("user_id", -1)
        viewModel.loadUser(userId)
    }
}

Dependency Injection

Use dependency injection to provide ViewModels with their dependencies.

Hilt Integration

@HiltAndroidApp
class MyApplication : Application()

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    
    @Provides
    @Singleton
    fun provideUserRepository(
        apiService: ApiService,
        userDao: UserDao
    ): UserRepository {
        return UserRepositoryImpl(apiService, userDao)
    }
}

@AndroidEntryPoint
class UserActivity : AppCompatActivity() {
    
    private val viewModel: UserViewModel by viewModels()
    
    // ViewModel will be automatically injected
}

ViewModel with Hilt

@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel() {
    // ViewModel implementation
}

Testing MVVM

Testing ViewModel

@RunWith(MockitoJUnitRunner::class)
class UserViewModelTest {
    
    @Mock
    private lateinit var repository: UserRepository
    
    private lateinit var viewModel: UserViewModel
    
    @Before
    fun setup() {
        viewModel = UserViewModel(repository)
    }
    
    @Test
    fun `loadUsers should update users when successful`() = runTest {
        // Given
        val users = listOf(User(1, "John", "john@example.com", ""))
        whenever(repository.getUsers()).thenReturn(users)
        
        // When
        viewModel.loadUsers()
        
        // Then
        assertEquals(users, viewModel.users.value)
    }
    
    @Test
    fun `loadUsers should show error when failed`() = runTest {
        // Given
        val errorMessage = "Network error"
        whenever(repository.getUsers()).thenThrow(Exception(errorMessage))
        
        // When
        viewModel.loadUsers()
        
        // Then
        assertEquals(errorMessage, viewModel.error.value)
    }
}

Testing with Compose

@Test
fun testUserScreen() {
    val fakeViewModel = FakeUserViewModel()
    
    composeTestRule.setContent {
        UserScreen(viewModel = fakeViewModel)
    }
    
    // Test loading state
    composeTestRule.onNodeWithTag("loading").assertIsDisplayed()
    
    // Test success state
    fakeViewModel.setUsers(listOf(User(1, "John", "john@example.com", "")))
    composeTestRule.onNodeWithText("John").assertIsDisplayed()
}

Best Practices

1. Single Responsibility

  • Each ViewModel should handle one specific feature
  • Keep ViewModels focused and lightweight

2. State Management

  • Use sealed classes for UI states
  • Handle all possible states (Loading, Success, Error)
  • Use StateFlow for modern apps

3. Error Handling

  • Always handle errors gracefully
  • Provide meaningful error messages
  • Implement retry mechanisms

4. Testing

  • Write unit tests for ViewModels
  • Test all UI states
  • Mock dependencies properly

5. Performance

  • Use viewModelScope for coroutines
  • Avoid heavy operations in ViewModels
  • Use LiveData or StateFlow for reactive programming

Common Patterns

Shared ViewModel

class SharedViewModel : ViewModel() {
    private val _selectedUser = MutableLiveData<User>()
    val selectedUser: LiveData<User> = _selectedUser
    
    fun selectUser(user: User) {
        _selectedUser.value = user
    }
}

Event Handling

class UserViewModel : ViewModel() {
    private val _events = MutableSharedFlow<UserEvent>()
    val events: SharedFlow<UserEvent> = _events.asSharedFlow()
    
    fun onUserClick(user: User) {
        viewModelScope.launch {
            _events.emit(UserEvent.NavigateToDetail(user.id))
        }
    }
}

sealed class UserEvent {
    data class NavigateToDetail(val userId: Int) : UserEvent()
    data class ShowError(val message: String) : UserEvent()
}

Conclusion

MVVM architecture provides a robust foundation for Android applications, offering:

  • Clean separation of concerns
  • Improved testability
  • Better maintainability
  • Lifecycle awareness
  • Reactive programming support

By following the patterns and best practices outlined in this guide, you can build scalable, maintainable Android applications that are easy to test and extend. The combination of MVVM with modern Android development tools like Jetpack Compose, Hilt, and Kotlin Coroutines creates a powerful development experience.

Remember to start simple and gradually add complexity as your application grows. The key is to maintain consistency and follow the established patterns throughout your codebase.

Article completed • Thank you for reading!