💾 Data, Network & Architecture · Lesson 31
MVVM: ViewModel, LiveData & Flow
The problem MVVM solves
Everything in the Activity
- Data lost on rotation
- Database code mixed with UI code
- Hard to test
- Huge 1000-line files
MVVM
- ViewModel survives rotation
- Clear layers
- Logic testable without a phone
- Small focused classes
The MVVM layers
| Layer | Does | Never does |
|---|---|---|
| View (Fragment) | Shows data, sends clicks to the ViewModel | SQL, network calls |
| ViewModel | Holds UI state, runs work in viewModelScope | Touches Views or Context of an Activity |
| Repository / Model | Reads and writes data | Knows about screens |
A first ViewModel
Kotlin
// CounterViewModel.kt
class CounterViewModel : ViewModel() {
private val _count = MutableLiveData(0) // private, changeable
val count: LiveData<Int> = _count // public, read-only
fun increment() {
_count.value = (_count.value ?: 0) + 1
}
}
// MainActivity.kt
class MainActivity : AppCompatActivity() {
private val viewModel: CounterViewModel by viewModels() // activity-ktx
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
viewModel.count.observe(this) { binding.tvCount.text = it.toString() }
binding.btnPlus.setOnClickListener { viewModel.increment() }
}
}
// Rotate the phone: the count stays! (no onSaveInstanceState needed)LiveData
LiveData is an observable value that knows about lifecycles: it only sends updates to observers that are started, and removes them automatically when destroyed. Expose LiveData, keep MutableLiveData private. Convert a Room Flow with .asLiveData().
StateFlow and Flow
Kotlin
class SearchViewModel(private val repo: NoteRepository) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query
// a Flow that always shows the notes matching the latest query
@OptIn(ExperimentalCoroutinesApi::class)
val results: StateFlow<List<Note>> = _query
.debounce(300) // wait until the user stops typing
.flatMapLatest { q -> if (q.isBlank()) repo.allNotes else repo.search(q) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChanged(q: String) { _query.value = q }
}
// Fragment – collect safely
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.results.collect { adapter.submitList(it) }
}
}UI state with a sealed class
Kotlin
sealed class TipState {
data object Loading : TipState()
data class Success(val tip: Tip) : TipState()
data class Error(val message: String) : TipState()
}
// Fragment: one observer draws every state
viewModel.tip.observe(viewLifecycleOwner) { state ->
binding.tipProgress.isVisible = state is TipState.Loading
binding.tipContent.isVisible = state is TipState.Success
binding.tipError.isVisible = state is TipState.Error
if (state is TipState.Success) binding.tvTipTitle.text = state.tip.title
}The Student Diary ViewModel
One shared ViewModel serves all screens: lists with search, counts for the Progress screen, save/delete with reminders, and the network tip.
Kotlin – ui/NoteViewModel.kt (complete)
package com.codingeasily.studentdiary.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.codingeasily.studentdiary.data.AppDatabase
import com.codingeasily.studentdiary.data.Note
import com.codingeasily.studentdiary.data.NoteRepository
import com.codingeasily.studentdiary.data.SubjectCount
import com.codingeasily.studentdiary.network.Tip
import com.codingeasily.studentdiary.network.TipsApi
import com.codingeasily.studentdiary.reminder.ReminderScheduler
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/** Which notes a list screen shows. */
enum class NoteFilter { ALL, PENDING, DONE, FAVORITES, SUBJECT }
/** State of the "Tip of the day" card. */
sealed class TipState {
data object Loading : TipState()
data class Success(val tip: Tip) : TipState()
data class Error(val message: String) : TipState()
}
/**
* Shared by all screens (activityViewModels). Survives screen rotation.
* The UI observes LiveData; all database work runs in viewModelScope.
*/
class NoteViewModel(application: Application) : AndroidViewModel(application) {
private val repo = NoteRepository(AppDatabase.get(application).noteDao())
// ---------- lists ----------
private val searchQuery = MutableStateFlow("")
fun setSearchQuery(q: String) { searchQuery.value = q.trim() }
@OptIn(ExperimentalCoroutinesApi::class)
fun notes(filter: NoteFilter, subject: String? = null): LiveData<List<Note>> {
val source: Flow<List<Note>> = when (filter) {
NoteFilter.ALL -> repo.allNotes
NoteFilter.PENDING -> repo.pendingNotes
NoteFilter.DONE -> repo.doneNotes
NoteFilter.FAVORITES -> repo.favorites
NoteFilter.SUBJECT -> repo.bySubject(subject ?: "")
}
return searchQuery.flatMapLatest { q ->
if (q.isEmpty()) source
else source.map { list ->
list.filter { it.title.contains(q, ignoreCase = true) || it.content.contains(q, ignoreCase = true) }
}
}.asLiveData()
}
val subjectCounts: LiveData<List<SubjectCount>> = repo.subjectCounts.asLiveData()
val countAll: LiveData<Int> = repo.countAll.asLiveData()
val countDone: LiveData<Int> = repo.countDone.asLiveData()
// ---------- single note ----------
suspend fun getNote(id: Long): Note? = repo.get(id)
fun save(note: Note, onSaved: (Long) -> Unit) {
viewModelScope.launch {
val id = repo.save(note)
val due = note.dueAt
if (due != null && !note.isDone) ReminderScheduler.schedule(getApplication(), id, due)
else ReminderScheduler.cancel(getApplication(), id)
onSaved(id)
}
}
fun toggleDone(note: Note) = viewModelScope.launch {
repo.save(note.copy(isDone = !note.isDone))
if (!note.isDone) ReminderScheduler.cancel(getApplication(), note.id)
}
fun toggleFavorite(note: Note) = viewModelScope.launch {
repo.save(note.copy(isFavorite = !note.isFavorite))
}
fun delete(note: Note) = viewModelScope.launch {
repo.delete(note)
ReminderScheduler.cancel(getApplication(), note.id)
}
/** Used by the "Undo" button of the Snackbar. */
fun restore(note: Note) = viewModelScope.launch {
repo.save(note.copy(id = 0))
}
fun deleteDone(onDone: (Int) -> Unit) = viewModelScope.launch {
onDone(repo.deleteDone())
}
// ---------- tip of the day (Retrofit) ----------
private val _tip = MutableLiveData<TipState>()
val tip: LiveData<TipState> = _tip
fun loadTip(force: Boolean = false) {
if (!force && _tip.value is TipState.Success) return
_tip.value = TipState.Loading
viewModelScope.launch {
_tip.value = try {
val tips = TipsApi.service.getTips().tips
if (tips.isEmpty()) TipState.Error("No tips yet")
else TipState.Success(tips[(System.currentTimeMillis() / 86_400_000L % tips.size).toInt()])
} catch (e: Exception) {
TipState.Error(e.message ?: "Network error")
}
}
}
}viewModels() vs activityViewModels()
Kotlin
// Each fragment gets its own instance private val vm: EditorViewModel by viewModels() // All fragments of the Activity share ONE instance (Student Diary uses this) private val viewModel: NoteViewModel by activityViewModels()
Common mistakes
- Creating a ViewModel with
NoteViewModel()→ a new instance every time; always useby viewModels(). - Keeping a Fragment's binding or Context in the ViewModel → memory leak.
- Exposing
MutableLiveDatapublicly → any screen can change state; expose read-only types. - Collecting a Flow in
lifecycleScope.launchwithoutrepeatOnLifecycle→ keeps running in the background.
Summary
- MVVM = View ↔ ViewModel ↔ Repository ↔ Model.
- ViewModel survives rotation; work runs in
viewModelScope. - Expose LiveData/StateFlow; model screen states with a sealed class.
✅ Practice
Move your Expense list logic into an ExpenseViewModel with a total that updates live, and add a sealed UiState for Loading/Empty/Data.
Frequently Asked Questions
Should a ViewModel hold a reference to a View or Activity?
Never. The ViewModel outlives views; holding them causes memory leaks. Expose data and let the UI observe it. Use AndroidViewModel if you need the Application context.
LiveData or StateFlow?
Both work. LiveData is simple and lifecycle-aware out of the box. StateFlow is pure Kotlin, has more operators and fits well with Room Flow; collect it with repeatOnLifecycle.
When is a ViewModel destroyed?
When its owner finishes for good: the Activity is finished or the Fragment is removed — not on rotation. onCleared() is called and viewModelScope is cancelled.
