Activity dapat di-recreate kapan saja oleh sistem operasi — rotasi layar, perubahan bahasa, atau tekanan memori. Tanpa pemisahan state, semua data UI akan hilang dan aplikasi harus memuat ulang dari awal. ViewModel adalah komponen Jetpack yang dirancang khusus untuk menahan state UI lintas recreate Activity. Pada chapter ini kita memahami cara kerja ViewModel, kapan memakai viewModelScope, dan bagaimana mengekspos state ke Compose melalui StateFlow.

Karakteristik ViewModel #

ViewModel tidak menggantikan Activity untuk menahan semua state, melainkan hanya state UI yang relevan lintas recreate. ViewModel tetap hidup selama Activity masih dalam stack navigasi; baru dibersihkan saat Activity benar-benar di-destroy (mis. pengguna menekan Back). Perbedaan utama ViewModel dibanding class biasa adalah pemasangan ke ViewModelStore yang dikelola Activity.

kotlin ViewModel minimal (Compose)
class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() { _count.value++ }
}

Di Compose, ViewModel diakses lewat viewModel<MyViewModel>() atau viewModel<MyViewModel>(factory = ...). Compose meng-cache instance berdasarkan ViewModelStoreOwner terdekat (biasanya Activity atau Fragment), sehingga rotasi tidak membuat instance baru.

viewModelScope #

Setiap ViewModel memiliki viewModelScope — CoroutineScope yang otomatis dibatalkan saat ViewModel di-onCleared. Scope ini menjadi tempat aman untuk menjalankan operasi asinkron (network, database) tanpa khawatir kebocoran coroutine setelah Activity selesai.

kotlin Load data dengan viewModelScope
class TugasViewModel(private val repo: TugasRepository) : ViewModel() {
    private val _state = MutableStateFlow(TugasUiState(isLoading = true))
    val state: StateFlow<TugasUiState> = _state.asStateFlow()

    init {
        viewModelScope.launch {
            val items = repo.muatTugas()
            _state.value = TugasUiState(items = items, isLoading = false)
        }
    }
}

Saat ViewModel di-onCleared, viewModelScope otomatis dibatalkan. Kita tidak perlu membatalkan job manual — sebuah keuntungan besar dibanding menggunakan GlobalScope yang berbahaya.

Ekspos StateFlow ke Compose #

StateFlow adalah observable yang cocok untuk state UI karena selalu memiliki nilai terakhir dan menggabungkan (conflate) emisi cepat menjadi satu. Compose mengkonsumsi StateFlow lewat collectAsState():

kotlin Konsumsi StateFlow di Compose
@Composable
fun TugasScreen(viewModel: TugasViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()
    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        LazyColumn { items(state.items) { Text(it) } }
    }
}

Konvensi yang dianjurkan: ViewModel hanya mengekspos StateFlow<T> read-only, sementara MutableStateFlow<T> tetap private. Perubahan state hanya boleh terjadi lewat method ViewModel — pola ini menjaga state tetap traceable.

Simulasi ViewModel Lintas Recreate #

Cuplikan berikut memodelkan ViewModel dan ViewModelStore yang menyimpan instance lintas recreate. StateFlow diisi dengan data awal dan diperbarui lewat method:

kotlin
// viewmodel.kt
// Mendemonstrasikan konsep ViewModel: instance yang bertahan lintas
// perubahan konfigurasi (rotasi), dengan StateFlow sebagai state observable.

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

// State UI yang dikeluarkan ViewModel
data class TugasUiState(
    val items: List<String> = emptyList(),
    val isLoading: Boolean = false
)

// ViewModel — bertahan hidup lintas recreate Activity
class TugasViewModel {
    private val _state = MutableStateFlow(TugasUiState(isLoading = true))
    val state: StateFlow<TugasUiState> = _state.asStateFlow()

    init {
        // Simulasi load data (di aplikasi nyata, ini pemanggilan repository)
        _state.value = TugasUiState(items = listOf("Belanja", "Olahraga", "Belajar Kotlin"), isLoading = false)
    }

    fun tambahTugas(nama: String) {
        val baru = _state.value.items + nama
        _state.value = _state.value.copy(items = baru)
    }

    fun hapusTugas(index: Int) {
        val baru = _state.value.items.toMutableList().apply { removeAt(index) }
        _state.value = _state.value.copy(items = baru)
    }
}

// Skenario: Activity direcreate (rotasi) tetapi ViewModel tetap
object ViewModelStore {
    private val store = mutableMapOf<String, Any>()

    @Suppress("UNCHECKED_CAST")
    fun <T> getOrCreate(key: String, factory: () -> T): T {
        return (store[key] as? T) ?: factory().also { store[key] = it as Any }
    }

    fun clear(key: String) { store.remove(key) }
}

fun main() {
    // Render pertama: ViewModel baru dibuat
    val vm1: TugasViewModel = ViewModelStore.getOrCreate("TUGAS_VM") { TugasViewModel() }
    println("State awal: ${vm1.state.value}")
    vm1.tambahTugas("Kerja")
    println("Setelah tambah: ${vm1.state.value.items}")

    // Simulasi rotasi: Activity direcreate, ViewModel tetap
    println("\n=== Activity direcreate (rotasi) ===")
    val vm2: TugasViewModel = ViewModelStore.getOrCreate("TUGAS_VM") { TugasViewModel() }
    println("State setelah recreate: ${vm2.state.value}")
    println("ViewModel sama? ${vm1 === vm2}")

    println("\n=== Perubahan state dari instance kedua ===")
    vm2.hapusTugas(0)
    println("State setelah hapus index 0: ${vm2.state.value.items}")
    println("Instance pertama melihat perubahan: ${vm1.state.value.items}")
}

Output yang Dihasilkan:

text
State awal: TugasUiState(items=[Belanja, Olahraga, Belajar Kotlin], isLoading=false)
Setelah tambah: [Belanja, Olahraga, Belajar Kotlin, Kerja]

=== Activity direcreate (rotasi) ===
State setelah recreate: TugasUiState(items=[Belanja, Olahraga, Belajar Kotlin, Kerja], isLoading=false)
ViewModel sama? true

=== Perubahan state dari instance kedua ===
State setelah hapus index 0: [Olahraga, Belajar Kotlin, Kerja]
Instance pertama melihat perubahan: [Olahraga, Belajar Kotlin, Kerja]

Perhatikan bahwa setelah “Activity direcreate”, ViewModel kedua yang didapat dari ViewModelStore.getOrCreate adalah instance yang sama dengan yang pertama. Perubahan tambahTugas dari instance pertama tetap terlihat di instance kedua — inilah inti ViewModel sebagai penahan state UI.

Kapan Tidak Memakai ViewModel #

SkenarioPendekatan alternatif
State lokal satu composableremember { mutableStateOf(...) }
State lintas composable dalam satu layarrememberSaveable atau hoisted state
State perlu bertahan proses dimatikanDataStore atau Room (persistent)
State global aplikasi (login, theme)Singleton + StateFlow atau DI

Referensi Lanjutan #

  • ViewModel — dokumentasi resmi komponen ViewModel Jetpack untuk menyimpan state UI lintas perubahan konfigurasi
  • Guide to app architecture — panduan arsitektur Android yang menjelaskan posisi ViewModel di layer UI

Selanjutnya #

ViewModel membantu, namun beberapa operasi tetap perlu terikat ke siklus hidup Activity (mis. mulai/berhenti polling saat UI terlihat). Chapter Lifecycle-Aware Components akan membahas LifecycleObserver dan @OnLifecycleEvent.