Android Model-View-ViewModel MVVM Architecture with Jetpack Compose

Santosh Devadiga
4 min readMay 25, 2024

--

In modern Android development, Jetpack Compose has revolutionized UI building with a declarative approach. When combined with the Model-View-ViewModel (MVVM) architecture, it enhances the development experience, particularly in large projects. This article explores how to implement MVVM with Kotlin and Jetpack Compose, and the significant benefits this combination offers for maintainability and scalability.

Understanding MVVM Architecture

MVVM divides an application into three main components:

  • Model: Manages the data and business logic.
  • View: Represents the UI layer.
  • ViewModel: Connects the Model and the View, handling UI-related data and state.

This separation of concerns promotes a modular and testable codebase, which is essential for large-scale projects.

Implementing MVVM with Jetpack Compose

  1. Setting Up the Project

Start by creating a new Android project with Kotlin and Jetpack Compose support. Add the necessary dependencies in your build.gradle file:

//Compose BOM
implementation(platform("androidx.compose:compose-bom:2024.05.00"))
implementation("androidx.lifecycle:lifecycle-viewmodel-compose")
implementation("androidx.activity:activity-compose")

//Viewmodel and livedata
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.0")

//Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")

//Hilt
implementation(libs.com.google.dagger.hilt.android)
ksp(libs.hilt.compiler)

2. Creating the Model

The Model represents the data layer. For example, a Recipe data class and a RecipeRepository:

//Data Class to hold Recipe data 
data class Recipe(
val caloriesPerServing: Int,
val cookTimeMinutes: Int,
val cuisine: String,
val difficulty: String,
val id: Int,
val image: String,
val ingredients: List<String>,
val instructions: List<String>,
val mealType: List<String>,
val name: String,
val prepTimeMinutes: Int,
val rating: Double,
val reviewCount: Int,
val servings: Int,
val tags: List<String>,
val userId: Int
)

data class RecipeLists(
val limit: Int,
val recipes: List<Recipe>,
val skip: Int,
val total: Int
)

//Repository Class

class RecipeRepository @Inject constructor(private val receipApi:RecipesAPI) {

private val _receipStateFlow = MutableStateFlow<NetworkResult<RecipeLists>>(NetworkResult.Loading())
val receipStateFlow: StateFlow<NetworkResult<RecipeLists>> = _receipStateFlow

suspend fun getReceipList() {
_receipStateFlow.emit(NetworkResult.Loading())
val response = receipApi.getRecipes()
if (response.isSuccessful && response.body() != null) {
_receipStateFlow.emit(NetworkResult.Success(response.body()!!))
} else if (response.errorBody() != null) {
val errorObj = JSONObject(response.errorBody()!!.charStream().readText())
_receipStateFlow.emit(NetworkResult.Error(errorObj.getString("message")))
} else {
_receipStateFlow.emit(NetworkResult.Error("Something Went Wrong"))
}
}
}

3. Developing the ViewModel

The ViewModel handles data operations and state management:

@HiltViewModel
class RecipeViewmodel @Inject constructor(private val recipRepository: RecipeRepository) :ViewModel() {
val receip:StateFlow<NetworkResult<RecipeLists>>
get() = recipRepository.receipStateFlow

init {
viewModelScope.launch {
recipRepository.getReceipList()
}
}

}

4. Building the View with Jetpack Compose

In Jetpack Compose, the View is built declaratively. Here’s how you can create a composable function to display recepi data:

@Composable
fun ReceipScreen(modifier: Modifier = Modifier) {
val receipViewmodel:RecipeViewmodel = viewModel()
val receip: State<NetworkResult<RecipeLists>> =receipViewmodel.receip.collectAsState()
LazyVerticalGrid(
columns = GridCells.Fixed(1),
modifier=modifier,
verticalArrangement = Arrangement.SpaceAround

) {
receip.value.data?.let { recipeLists ->
items(recipeLists.recipes){
ReceipItem(it)
}
}
}
}

@Composable
fun ReceipItem(recipe: Recipe) {

Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
shape = RoundedCornerShape(8.dp), // Set corner radius
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
) {
Column(
modifier = Modifier.padding(10.dp).fillMaxWidth(),
verticalArrangement = Arrangement.SpaceEvenly
) {
Text(
text = recipe.name,
style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp)
)
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.Start) {
AsyncImage(
model = recipe.image,
contentDescription = "Recipe Image",
modifier = Modifier.width(120.dp).height(120.dp).border(1.dp, Color.Gray) // Use chaining for less indentation
)
Spacer(Modifier.width(20.dp))
Column (modifier = Modifier.height(120.dp),verticalArrangement = Arrangement.SpaceAround, horizontalAlignment = Alignment.Start){
Text(text = "Meal Type", style = TextStyle(fontWeight = FontWeight.W900)) // Use string interpolation for readability
Text(text = "Cuisine", style = TextStyle(fontWeight = FontWeight.W900))
Text(text = "Cal Per Serving", style = TextStyle(fontWeight = FontWeight.W900))
Text(text = "Cook Time (Min)", style = TextStyle(fontWeight = FontWeight.W900))
Text(text = "Prep Time (Min)", style = TextStyle(fontWeight = FontWeight.W900))
}
Column(modifier = Modifier.height(120.dp),verticalArrangement = Arrangement.SpaceAround, horizontalAlignment = Alignment.Start) {
Text(text = ": ${recipe.mealType.first()}", style = TextStyle(fontWeight = FontWeight.W400))
Text(text = ": ${recipe.cuisine}", style = TextStyle(fontWeight = FontWeight.W400))
Text(text = ": ${recipe.caloriesPerServing}", style = TextStyle(fontWeight = FontWeight.W400))
Text(text = ": ${recipe.cookTimeMinutes}", style = TextStyle(fontWeight = FontWeight.W400))
Text(text = ": ${recipe.prepTimeMinutes}", style = TextStyle(fontWeight = FontWeight.W400))
}
}
}
}

}

In your Activity, set the content to the MainScreen composable:

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ReceipScreen(modifier = modifier = Modifier.padding(16.dp))
}
}
}

Explaining Link between model, viewmodel and View :

  1. Model ↔ ViewModel:
  • ViewModel calls Model to fetch/update data.
  • Model sends data back to ViewModel.

2. ViewModel ↔ View:

  • ViewModel exposes data as LiveData/StateFlow.
  • View observes LiveData/StateFlow from ViewModel.
  • View sends user actions to ViewModel.

Conclusion

Integrating MVVM architecture with Jetpack Compose and Kotlin significantly enhances the maintainability and scalability of large Android projects. The separation of concerns in MVVM, combined with the declarative and reactive nature of Jetpack Compose, creates a robust and flexible codebase. This approach not only simplifies development and testing but also ensures a responsive and maintainable application, paving the way for a more productive development lifecycle and a superior user experience.

Visit my GitHub link for a working example of a recipe application with a basic MVVM setup. In the future, I am going to add a top bar, bottom bar, compose navigation, and language support. Stay tuned to this repository for a complete production-ready application with all the latest standard Android practices suggested by Google.

santoshdevadiga/AndroidMvvm: MVVM architecture for Android Kotlin with Jetpack Libraries (github.com) [In Progress]

--

--