Chapter 7: Modern App Architecture (MVVM & DI)
The "Why": What is App Architecture?
In your first few apps, you probably wrote all your code inside your MainActivity.kt. You fetched data from an API, updated your TextViews, and saved data to DataStore all in one giant file. This is called a **"Massive View Controller"** or "God Activity," and it's a trap every new developer falls into.
Why is this bad?
- It's Untestable: You can't write a simple unit test for your networking logic without also creating an entire Activity, which is slow and complex.
- It's Unmaintainable: When a bug happens, you don't know where to look. Is the bug in the UI code? The data code? The networking code? It's all mixed together.
- It Breaks on Configuration Changes: As we saw in Chapter 4, if you store your data (like a list of users) in a variable inside your Activity, it will be **destroyed** every time the user rotates the phone.
**App Architecture** is not a library. It's a *blueprint* or a set of rules for organizing your code. Its goal is to create a **Separation of Concerns**. This means:
- Your UI code (
Activity/Composable) is *only* responsible for displaying data and capturing user input. It should be "dumb." - Your data logic (fetching from API, saving to database) is in a separate class.
- A class in the middle connects the two.
Google's Recommended Architecture (MVVM+)
Google officially recommends an architecture pattern that is a more robust version of **MVVM (Model-View-ViewModel)**. This pattern divides your app into 3 main layers:
- UI Layer (The "View"): This is what the user sees (your
ActivityandComposablefunctions). Its job is to *observe* data from the ViewModel and *send* user events (like button clicks) to the ViewModel. - ViewModel (The "Middle-Man"): This is the
ViewModelclass from Jetpack. Its job is to hold the UI's state, survive configuration changes, and talk to the Data Layer. **It knows nothing about the Activity or UI.** - Data Layer (The "Data"): This layer is responsible for *all* data operations. It's made of **Repositories** and **Data Sources**. It handles all the logic for "should I get this from the network or the database?"
(The "Domain Layer" is an optional layer for complex business logic, but we will focus on the main three: UI, ViewModel, and Data).
This chapter will cover the 4 key components that make this architecture work: **ViewModel, LiveData/StateFlow, Repository, and Dependency Injection (Hilt)**.
Pillar 1: The ViewModel
A ViewModel is a class from the Android Jetpack library. It is designed to store and manage UI-related data in a way that is **lifecycle-conscious**. This is the class that *solves* our "screen rotation" problem.
The ViewModel's Superpower: Surviving Configuration Changes
When an Activity is destroyed due to a configuration change (like screen rotation), its ViewModel is **not** destroyed. The Android system keeps it in memory. When the *new* Activity instance is created, the system re-connects it to the *exact same* ViewModel instance.
This means your data (like the list of users you fetched from the API) is safe and is not fetched again, saving time, battery, and network data.
How to Implement a ViewModel
Step 1: Add the Gradle Dependency
You need to add the lifecycle-viewmodel library to your build.gradle.kts (Module: :app).
dependencies {
// ...
val lifecycle_version = "2.6.2"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version") // for viewModelScope
implementation("androidx.activity:activity-ktx:1.8.0") // for 'by viewModels()'
}
Step 2: Create your ViewModel Class
Your ViewModel class must inherit from ViewModel.
import androidx.lifecycle.ViewModel
import android.util.Log
class MyViewModel : ViewModel() {
// 1. A variable to hold our data
private var counter = 0
init {
Log.d("MyViewModel", "ViewModel instance created!")
}
// 2. A function that the UI can call
fun onButtonClicked() {
counter++
Log.d("MyViewModel", "Counter is now: $counter")
}
fun getCounter(): Int {
return counter
}
// 3. This is called when the ViewModel is finally destroyed
// (e.g., when the Activity is permanently finished, not on rotation)
override fun onCleared() {
super.onCleared()
Log.d("MyViewModel", "ViewModel is being destroyed!")
}
}
Step 3: Get an Instance in your Activity
You **NEVER** create a ViewModel like this: val myViewModel = MyViewModel(). If you do, it's just a regular class and will be destroyed with the Activity.
You *must* ask the Android system for it using a ViewModelProvider. The easiest way is to use the by viewModels() Kotlin property delegate.
import androidx.activity.viewModels // This import is key
class MainActivity : AppCompatActivity() {
// 1. Get the ViewModel using the delegate
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
// 2. Use the ViewModel
Log.d("MainActivity", "Current count is: ${viewModel.getCounter()}")
myButton.setOnClickListener {
viewModel.onButtonClicked()
Log.d("MainActivity", "New count is: ${viewModel.getCounter()}")
}
}
}
Now, if you click the button 5 times (count is 5) and rotate the phone, the Activity is destroyed and re-created. But the *same* ViewModel is attached. The onCreate will run again, and viewModel.getCounter() will correctly return 5, not 0.
Pillar 2: State Management (LiveData vs. StateFlow)
Our `MyViewModel` example has a big problem. The Activity has to *ask* the ViewModel for the data (getCounter()). This is bad. The Activity shouldn't have to know *when* to ask. The ViewModel should *tell* the Activity when the data has changed.
We need a way to create "observable" data. This is where **LiveData** and **StateFlow** come in.
Option 1: LiveData (The Original Way)
LiveData is a data holder class from Jetpack that is **lifecycle-aware**. This means it knows if your Activity is in the foreground (onResume) or background (onStop). It will *only* send updates to your UI when the UI is in an active state, which prevents crashes.
How to use LiveData:
Step 1: Add Dependency
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
Step 2: Update ViewModel
We use a pattern of "backing property."
_counter(private, **Mutable**LiveData): This is the *internal* variable that only the ViewModel can change.counter(public, **Live**Data): This is the *external*, read-only variable that the UI can observe.
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
class MyViewModel : ViewModel() {
// 1. Private & Mutable: Only ViewModel can edit
private val _counter = MutableLiveData(0) // Initial value is 0
// 2. Public & Read-only: UI observes this
val counter: LiveData<Int>
get() = _counter
fun onButtonClicked() {
// 3. Use .value to update the LiveData
val currentCount = _counter.value ?: 0
_counter.value = currentCount + 1
}
}
Step 3: Update Activity (Observe the data)
ui/MainActivity.kt (Updated)class MainActivity : AppCompatActivity() {
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
// ...
// 1. We don't "get" the data anymore. We "observe" it.
viewModel.counter.observe(this) { newCount ->
// This code block will run automatically
// every time the 'counter' value changes.
myTextView.text = "You clicked $newCount times"
}
// 2. The button just sends an event. It doesn't care about the result.
myButton.setOnClickListener {
viewModel.onButtonClicked()
}
}
}
This is called a **Unidirectional Data Flow (UDF)**. The `Activity` sends an event *up* to the `ViewModel`, and the `ViewModel` sends new state (data) *down* to the `Activity`. The `Activity` *never* edits its own state.
Option 2: StateFlow (The Modern, Kotlin-native Way)
LiveData is good, but it's an older library built for the Java world. The modern, Kotlin-native way is to use **Flows** (from Kotlin Coroutines, which we saw in Chapter 1).
A **`StateFlow`** is a special type of "hot" Flow that is designed to do *exactly* what LiveData does: hold a single, "state" value that can be observed.
Why `StateFlow` is often better than `LiveData`:
- Kotlin-native: It's part of the coroutines library, not a separate Android component.
- More Powerful: It's a full Flow, so you can use all the powerful Flow operators (like
.map,.filter,.combine) on it. - Better for Business Logic: LiveData should only be used in the UI layer. StateFlow can be used in *any* layer (e.g., in the Repository).
Step 1: Update ViewModel (with `StateFlow`)
ui/MyViewModel.kt (StateFlow Version)import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class MyViewModel : ViewModel() {
// 1. Private & Mutable
private val _counter = MutableStateFlow(0) // Initial value is 0
// 2. Public & Read-only
val counter: StateFlow<Int>
get() = _counter.asStateFlow()
fun onButtonClicked() {
// 3. Use .update() to safely change the value (thread-safe)
_counter.update { currentCount ->
currentCount + 1
}
}
}
Step 2: Update UI (Jetpack Compose)
Collecting a StateFlow in Compose is the most common pattern. You use the collectAsStateWithLifecycle() extension function (you need the lifecycle-runtime-compose dependency).
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun MyCounterScreen(
viewModel: MyViewModel = viewModel() // Get the ViewModel
) {
// 1. Collect the flow. This is lifecycle-safe.
val count by viewModel.counter.collectAsStateWithLifecycle()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// 2. Text observes 'count' and will auto-recompose
Text(text = "You clicked $count times")
// 3. Button sends event up to ViewModel
Button(onClick = { viewModel.onButtonClicked() }) {
Text("Click Me")
}
}
}
Conclusion: For new apps, especially with Compose, use `StateFlow`.**
Read More about StateFlow vs. LiveData →Pillar 3: The Repository Pattern
This is the next layer down. The `ViewModel` should *not* know *how* to get data. It shouldn't know if the data is coming from Retrofit (network) or Room (database). It just wants data.
The **Repository** is a class that *abstracts* the data source. Its job is to be the single "source of truth" for a certain type of data (e.g., UserRepository, PostsRepository).
The ViewModel asks the Repository, "Hey, I need the user's profile." The Repository then decides, "Okay, first I'll check my **Local Data Source (Room)**. Do I have it cached? If yes, I'll send it. If not, I'll ask my **Remote Data Source (Retrofit)**, get the data, save it to Room, and *then* send it to the ViewModel."
This makes your app:
- Offline-First: Your app can work without an internet connection if the data is already in the Room database.
- Testable: You can easily test your ViewModel by giving it a "Fake" Repository.
- Organized: All your data logic is in one place, not spread out in your ViewModels.
Example: Building a `NotesRepository`
Let's use the NoteDao we built in Chapter 5 and the ApiService from Chapter 6.
import kotlinx.coroutines.flow.Flow
import com.codewithmsmaxpro.myapp.data.db.NoteDao
import com.codewithmsmaxpro.myapp.data.network.ApiService
import com.codewithmsmaxpro.myapp.data.models.Note
// The Repository's job is to be the single source of truth.
class NotesRepository(
private val noteDao: NoteDao,
private val apiService: ApiService
) {
// The "Single Source of Truth" is always the database (Room).
// The UI will observe this Flow.
val allNotes: Flow<List<Note>> = noteDao.getAllNotes()
// This function fetches new data from the API
// and saves it to the database (which triggers the Flow)
suspend fun refreshNotes() {
try {
val networkNotes = apiService.getNotes() // Fake API call
noteDao.insertAll(networkNotes) // Save new notes to Room
} catch (e: Exception) {
// Handle network error
}
}
// This function just inserts a new note locally
suspend fun addNewNote(note: Note) {
noteDao.insert(note)
// (In a real app, you would also POST this to your API)
}
}
Now, your ViewModel becomes *much* simpler. It doesn't know Retrofit or Room exists. It only knows the Repository.
ui/NotesViewModel.ktclass NotesViewModel(private val repository: NotesRepository) : ViewModel() {
// The UI just observes this data from the Repository
val allNotes = repository.allNotes
fun onRefreshClicked() {
viewModelScope.launch(Dispatchers.IO) {
repository.refreshNotes()
}
}
fun onSaveClicked(title: String, content: String) {
viewModelScope.launch(Dispatchers.IO) {
repository.addNewNote(Note(title = title, content = content))
}
}
}
This is clean, testable, and robust. But one question remains: **Who creates the `NotesRepository`?** And who creates the `NoteDao` and `ApiService` that the repository needs? And who passes the `NotesRepository` to the `NotesViewModel`?
The answer is **Dependency Injection**.
Pillar 4: Dependency Injection (DI)
This is the final, most advanced piece of the puzzle. It's what connects all the other pillars together.
What is Dependency Injection?
Look at our NotesViewModel:
class NotesViewModel(private val repository: NotesRepository) : ViewModel()
The NotesViewModel **depends on** a NotesRepository. It *needs* one to do its job.
Look at our NotesRepository:
class NotesRepository(private val noteDao: NoteDao, private val apiService: ApiService)
The NotesRepository **depends on** a NoteDao and an ApiService.
So, to create a ViewModel, you first need to create a Repository. To create a Repository, you first need to create a DAO and an ApiService. To create a DAO, you need a Database. To create an ApiService, you need a Retrofit instance. This is a "chain of dependencies."
The "Bad" Way (Manual DI)
Without a DI framework, you'd have to do all this yourself in your Activity. This is called **Manual Dependency Injection**. It's terrible.
// In MainActivity.kt (BAD, DO NOT DO THIS)
class MainActivity : AppCompatActivity() {
private val viewModel: NotesViewModel
init {
// 1. Create ApiService
val apiService = RetrofitInstance.api
// 2. Create Database & DAO
val db = AppDatabase.getDatabase(this)
val noteDao = db.noteDao()
// 3. Create Repository
val repository = NotesRepository(noteDao, apiService)
// 4. Create ViewModel
val viewModelFactory = NotesViewModelFactory(repository)
viewModel = ViewModelProvider(this, viewModelFactory).get(NotesViewModel::class.java)
}
}
This is a nightmare. Your MainActivity is doing 100 things it shouldn't. It's tightly coupled to every part of your data layer. This is where a **Dependency Injection framework** comes in.
Hilt: The Modern Standard
Hilt is the Jetpack-recommended DI framework for Android (built on top of a more complex Java library called Dagger). Hilt uses annotations and code generation to do all that work *for you*.
Your goal is to "teach" Hilt how to create each object (ApiService, Dao, Repository, ViewModel). Then, you just "ask" for what you need, and Hilt builds the entire chain for you automatically.
Deep Dive: How to use Hilt
Step 1: Add Hilt Dependencies
This is the most complex setup. You need to modify *three* files.
build.gradle.kts (Project-level)plugins {
// ...
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
// 1. Add Hilt plugin
alias(libs.plugins.hilt) apply false
}
build.gradle.kts (Module-level: :app)
plugins {
// ...
id("com.google.devtools.ksp")
// 2. Apply Hilt plugin
alias(libs.plugins.hilt)
}
dependencies {
// ...
// 3. Add Hilt libraries
implementation("com.google.dagger:hilt-android:2.48")
ksp("com.google.dagger:hilt-compiler:2.48")
// For ViewModel injection
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
}
And your libs.versions.toml will need the `hilt` plugin alias.
Step 2: Set up the Application Class
You must create a custom `Application` class and tell Hilt this is the main container for your app.
Create MyApplication.kt:
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApplication : Application() {
// This class can be empty
}
Now, you *must* tell Android to use this class. In your AndroidManifest.xml:
<application
android:name=".MyApplication" <-- ADD THIS LINE
android:icon="@mipmap/ic_launcher"
...
>
Step 3: "Teach" Hilt How to Create (Provide) Dependencies
We need to tell Hilt how to create ApiService, NoteDao, and NotesRepository. We do this by creating a **Hilt Module**.
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class) // These dependencies will live as long as the app
object AppModule {
// How to provide the AppDatabase
@Provides
@Singleton // We only want ONE instance of the database
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return AppDatabase.getDatabase(context)
}
// How to provide the NoteDao (it depends on the database)
@Provides
@Singleton
fun provideNoteDao(db: AppDatabase): NoteDao {
return db.noteDao() // Hilt will get AppDatabase from the function above
}
// How to provide the ApiService
@Provides
@Singleton
fun provideApiService(): ApiService {
return RetrofitInstance.api
}
// How to provide the NotesRepository (it depends on Dao and ApiService)
@Provides
@Singleton
fun provideNotesRepository(noteDao: NoteDao, apiService: ApiService): NotesRepository {
return NotesRepository(noteDao, apiService)
}
}
Step 4: "Inject" Dependencies into ViewModels and Activities
Now that Hilt *knows how to create* everything, we can just *ask* for them.
First, we update our NotesViewModel to tell Hilt how to create *it*.
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
// 1. Add @HiltViewModel annotation
@HiltViewModel
class NotesViewModel @Inject constructor( // 2. Add @Inject to the constructor
private val repository: NotesRepository // Hilt will get this from AppModule
) : ViewModel() {
// ... all your other ViewModel code ...
val allNotes = repository.allNotes
fun onRefreshClicked() {
viewModelScope.launch(Dispatchers.IO) {
repository.refreshNotes()
}
}
}
Finally, we tell our MainActivity that it needs injection, and ask for the ViewModel. Notice how we **deleted all the manual setup**.
import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint
// 1. Add @AndroidEntryPoint annotation
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// 2. Ask for the ViewModel. Hilt will create it,
// create the Repository, create the Dao, create the ApiService,
// and pass them all in automatically.
private val viewModel: NotesViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
// 3. Use the ViewModel. It just works.
viewModel.allNotes.observe(this) { notes ->
// Update your UI
}
myRefreshButton.setOnClickListener {
viewModel.onRefreshClicked()
}
}
}
Dependency Injection is the most complex concept in this roadmap, but it is the **key** that unlocks a truly professional, testable, and maintainable application. With Hilt, your UI layer is completely "dumb" and your data layer is completely independent, just as the architecture intended.
Read the Official Guide to App Architecture → Read More about Hilt →