Using Jetpack Compose with MVVM Architecture

Jetpack Compose is Android’s modern toolkit for building native UI. MVVM (Model-View-ViewModel) is a popular architectural pattern for structuring applications, promoting separation of concerns, testability, and maintainability. Combining Jetpack Compose with the MVVM architecture results in a clean, efficient, and testable Android application.

Understanding the MVVM Architecture

The Model-View-ViewModel (MVVM) architecture is a design pattern that separates the application into three interconnected parts:

  • Model: Manages the data and business logic. It exposes the data to the ViewModel.
  • View: Represents the UI. It observes the ViewModel and displays the data. In the context of Jetpack Compose, this is a composable function.
  • ViewModel: Acts as an intermediary between the Model and the View. It exposes data streams that the View can observe and contains UI-specific logic.

Benefits of MVVM with Jetpack Compose

  • Testability: ViewModels are easily testable since they are independent of the UI.
  • Maintainability: Clear separation of concerns simplifies maintenance and future development.
  • Reusability: ViewModels can be reused across different Views.
  • UI Consistency: Centralized logic in the ViewModel ensures a consistent UI experience.
  • Lifecycle Awareness: ViewModel survives configuration changes, preventing data loss.

How to Implement MVVM with Jetpack Compose

Let’s walk through a practical example to demonstrate how to implement MVVM architecture with Jetpack Compose. We’ll build a simple counter application.

Step 1: Add Dependencies

Ensure you have the necessary dependencies in your build.gradle file:

dependencies {
    implementation "androidx.compose.ui:ui:1.6.4"
    implementation "androidx.compose.material:material:1.6.4"
    implementation "androidx.compose.ui:ui-tooling-preview:1.6.4"
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
    implementation "androidx.activity:activity-compose:1.8.2"
    testImplementation "junit:junit:4.13.2"
    androidTestImplementation "androidx.test.ext:junit:1.1.5"
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.4"
    debugImplementation "androidx.compose.ui:ui-tooling:1.6.4"
    debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.4"
}

Step 2: Create the Model

Define a simple model that manages the counter value:

class CounterModel {
    private var count = 0

    fun increment() {
        count++
    }

    fun getCount(): Int {
        return count
    }
}

Step 3: Create the ViewModel

Create a ViewModel that interacts with the Model and exposes the counter state to the View:

import androidx.lifecycle.ViewModel
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

class CounterViewModel : ViewModel() {
    private val model = CounterModel()
    
    var count by mutableStateOf(model.getCount())
        private set // Only ViewModel can modify the state
    
    fun incrementCounter() {
        model.increment()
        count = model.getCount() // Update the state with the model data
    }
}

Key points in the CounterViewModel:

  • Uses mutableStateOf from Compose to hold the current count, ensuring that changes to the count are observed by the composable functions.
  • Has a method incrementCounter to interact with the model and update the UI state.

Step 4: Create the View (Composable Function)

Implement a composable function to display the counter and handle user interactions:

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.*
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Count: ${viewModel.count}")
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { viewModel.incrementCounter() }) {
            Text(text = "Increment")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CounterScreenPreview() {
    CounterScreen()
}

Explanation of the CounterScreen composable:

  • Retrieves the CounterViewModel instance using viewModel().
  • Displays the current count value.
  • Uses a Button that calls incrementCounter on the ViewModel when clicked.

Step 5: Integrate the Composable into the Activity

Finally, set the content of your Activity to use the CounterScreen composable:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface {
                    CounterScreen()
                }
            }
        }
    }
}

Using StateFlow in ViewModel

StateFlow can provide reactive updates to your composables from the ViewModel.

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import androidx.lifecycle.ViewModel

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun incrementCounter() {
        _count.value = _count.value + 1
    }
}

And use it in your Composable with collectAsState():

import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count = viewModel.count.collectAsState()

    Column {
        Text("Count: ${count.value}")
        Button(onClick = { viewModel.incrementCounter() }) {
            Text("Increment")
        }
    }
}

Testing the ViewModel

Writing tests for the ViewModel ensures its correctness and robustness.

import org.junit.Assert.assertEquals
import org.junit.Test

class CounterViewModelTest {
    @Test
    fun `incrementCounter should increase count by 1`() {
        val viewModel = CounterViewModel()
        val initialCount = viewModel.count.value

        viewModel.incrementCounter()

        assertEquals(initialCount + 1, viewModel.count.value)
    }
}

Conclusion

By combining Jetpack Compose with the MVVM architecture, developers can create scalable, testable, and maintainable Android applications. This approach separates UI concerns from business logic, resulting in a cleaner and more efficient codebase. Leveraging best practices and reactive streams enhances the application’s responsiveness and reliability, delivering an excellent user experience.