Aplikasi yang terus membesar membutuhkan pembagian tanggung jawab yang jelas antara tampilan, keadaan siap tampil, dan sumber data. Pola MVVM (Model-View-ViewModel) membaginya menjadi tiga peran tersebut, sedangkan dependency injection menyambungkan ketiganya lewat abstraksi, bukan lewat kelas konkret. Topik ini membangun MVVM versi kecil yang berjalan penuh di JVM tanpa framework, menampilkan alur datanya pada keluaran nyata, lalu mengganti implementasi sumber datanya melalui dependency injection berbasis konstruktor.
MVVM dalam bentuk kecil#
Program berikut memakai ranah rapor kelas. Data modelnya adalah NilaiSiswa, penyimpanannya disembunyikan di balik interface RepositoriNilai, dan RaporViewModel menampung keadaan siap tampil berupa daftar nilai beserta rata-ratanya. ViewModel menerima pendaftaran callback melalui fungsi langganan, lalu mengumumkan setiap perubahan keadaan kepada seluruh pendengarnya. Fungsi main berperan sebagai View yang hanya mencetak setiap pemberitahuan.
data class NilaiSiswa(val nama: String, val skor: Int)
interface RepositoriNilai {
fun muat(): List<NilaiSiswa>
fun simpan(nilai: NilaiSiswa)
}
class RepositoriMemori(private val isiAwal: List<NilaiSiswa> = emptyList()) : RepositoriNilai {
private val data = isiAwal.toMutableList()
override fun muat(): List<NilaiSiswa> = data.toList()
override fun simpan(nilai: NilaiSiswa) {
data.add(nilai)
}
}
// implementasi lain untuk pengujian, tanpa menyimpan apa pun
class RepositoriUji : RepositoriNilai {
private val data = listOf(NilaiSiswa("Ali", 80), NilaiSiswa("Budi", 90))
override fun muat(): List<NilaiSiswa> = data
override fun simpan(nilai: NilaiSiswa) {
// sengaja tidak menyimpan
}
}
class RaporViewModel(private val repo: RepositoriNilai) {
private val daftarPendengar = mutableListOf<(String) -> Unit>()
private val nilai = repo.muat().toMutableList()
fun langganan(pendengar: (String) -> Unit) {
daftarPendengar.add(pendengar)
}
fun tambah(nama: String, skor: Int) {
val baru = NilaiSiswa(nama, skor)
repo.simpan(baru)
nilai.add(baru)
umumkan("nilai $nama = $skor, rata-rata kelas ${rataRata()}")
}
private fun rataRata(): Double =
if (nilai.isEmpty()) 0.0 else nilai.sumOf { it.skor }.toDouble() / nilai.size
private fun umumkan(pesan: String) {
for (p in daftarPendengar) p(pesan)
}
}
fun main() {
val viewModel = RaporViewModel(RepositoriMemori(listOf(NilaiSiswa("Ali", 80))))
viewModel.langganan { pesan -> println("View : $pesan") }
viewModel.tambah("Budi", 90)
viewModel.tambah("Citra", 70)
val viewModelUji = RaporViewModel(RepositoriUji())
viewModelUji.langganan { pesan -> println("Uji : $pesan") }
viewModelUji.tambah("Citra", 70)
}# compiler Kotlin dan pustaka standar yang dipakai jalur ini
export PATH=/home/kal/my-project/tools/kotlinc/bin:/home/kal/my-project/tools/jdk-merged/bin:$PATH
export STDLIB=/home/kal/my-project/tools/kotlinc/lib/kotlin-stdlib.jar
# kompilasi main.kt menjadi out.jar, lalu jalankan programnya
kotlinc main.kt -d out.jar && java -cp "out.jar:$STDLIB" MainKtView : nilai Budi = 90, rata-rata kelas 85.0
View : nilai Citra = 70, rata-rata kelas 80.0
Uji : nilai Citra = 70, rata-rata kelas 80.0Tiga baris keluaran menceritakan dua alur. Dua baris pertama berasal dari ViewModel yang dipasangkan dengan RepositoriMemori: setiap pemanggilan tambah memperbarui daftar nilai, menghitung ulang rata-rata kelas, lalu mengumumkan keadaan barunya, dan View mencetak pemberitahuannya tanpa mengetahui dari mana data berasal. Baris ketiga berasal dari ViewModel kedua yang dipasangkan dengan RepositoriUji. Kelas ViewModel-nya sama persis, tidak ada satu baris pun yang diubah, tetapi sumber datanya berbeda karena disuntikkan lewat konstruktor.
Isi ViewModel-nya pun tetap sederhana, tetapi setiap bagiannya punya peran yang jelas. Daftar pendengar menyimpan fungsi yang dikirim View melalui langganan, sedangkan fungsi privat umumkan menyalurkan pesan ke seluruh pendengar pada setiap perubahan keadaan. Dengan bentuk ini, penambahan View kedua hanya berarti satu pendaftaran lagi, tanpa mengubah sisa programnya.
Peran View, ViewModel, dan Model#
Pembagian tugas pada program di atas mengikuti pola MVVM secara utuh. Model berisi data dan aturan penyimpanannya, yaitu NilaiSiswa serta RepositoriNilai beserta implementasinya, dan bagian ini tidak tahu siapa yang menampilkannya. ViewModel menampung keadaan yang siap ditampilkan, yaitu daftar nilai dan rata-rata kelas, menawarkan aksi seperti tambah, dan mengumumkan perubahan lewat daftar pendengarnya. View berada pada sisi paling tipis, yaitu menerima pemberitahuan lalu menampilkannya, dan pada program ini perannya dipegang oleh beberapa pemanggilan println.
Arah alurnya satu arah dan dapat ditelusuri dengan mudah. View memanggil aksi ViewModel, ViewModel memperbarui keadaannya dan menyimpan lewat repositori, kemudian ViewModel mengumumkan keadaan baru kepada View. View tidak pernah membaca repositori secara langsung, dan repositori tidak pernah memanggil View. Pemisahan ini yang membuat ViewModel dapat diuji tanpa tampilan apa pun, sebagaimana ditunjukkan oleh baris ketiga keluaran yang berjalan dengan View berbeda.
Dependency injection lewat konstruktor#
Konstruktor RaporViewModel menerima satu parameter bertipe interface, yaitu repo: RepositoriNilai, dan kelasnya tidak pernah membuat sendiri implementasinya. Kebutuhan itu disediakan dari luar pada saat pembuatan objek, dan cara kerja inilah yang disebut dependency injection. Akibatnya, ViewModel hanya bergantung pada kontrak muat dan simpan, sehingga RepositoriMemori dan RepositoriUji dapat dipertukarkan tanpa menyentuh satu baris pun di dalam ViewModel.
Manfaatnya paling terasa saat pengujian. RepositoriUji mengembalikan data tetap dan tidak menyimpan apa pun, sehingga ViewModel dapat dijalankan berulang kali dengan keadaan yang pasti, tanpa penyimpanan nyata dan tanpa persiapan apa pun. Bila suatu saat penyimpanannya berpindah ke berkas atau basis data, implementasi interface barulah ditulis, dan seluruh logika ViewModel tetap utuh. Pada proyek yang lebih besar, framework dependency injection seperti Hilt atau Koin mengotomatiskan perakitan semacam ini, sedangkan pola dasarnya tetap sama dengan contoh di sini.
Batas contoh ini#
Contoh di atas sengaja disederhanakan agar seluruh mekanismenya terbaca. Programnya berjalan pada satu thread, pemberitahuannya berupa callback yang dipanggil langsung, dan tidak ada siklus hidup yang perlu dijaga. Tidak ada jaringan dan tidak ada basis data yang terlibat, sehingga perilakunya pasti dan keluarannya dapat direproduksi. Pada aplikasi nyata, aliran data yang datang secara asinkron diantar oleh coroutine dan Flow, ViewModel pada Android menambah penanganan pergantian konfigurasi, dan pendengarnya dihapus saat tampilan hancur agar tidak menumpuk.
Rata-rata kelas juga dihitung ulang secara penuh pada setiap perubahan, yang wajar untuk puluhan data tetapi perlu dihitung secara bertahap bila datanya ratusan ribu. Cara menyebarkan keadaannya pun masih sederhana, yaitu pesan yang disiarkan kepada pendengar, sedangkan ViewModel yang matang biasanya mengekspos keadaannya sebagai aliran yang dapat diamati, misalnya melalui StateFlow. Kesadaran akan batas-batas ini membuat pola yang sama dapat dipindahkan ke aplikasi sungguhan tanpa salah paham.
Ringkasan#
- MVVM membagi aplikasi menjadi Model untuk data dan aturannya, ViewModel untuk keadaan siap tampil, dan View untuk menampilkan.
- ViewModel mengumumkan perubahan keadaan lewat callback, sehingga View tidak perlu menanyakan keadaan berulang-ulang.
- Dependency injection lewat konstruktor membuat ViewModel bergantung pada interface, sehingga implementasinya dapat diganti tanpa menyentuh ViewModel.
- Pengujian dapat memakai repositori uji dengan data tetap, sehingga perilaku ViewModel diperiksa tanpa penyimpanan nyata.
Referensi#
- ViewModel Overview — Google — Dokumentasi pola ViewModel pada Android, termasuk perannya sebagai penampung state yang sadar siklus hidup.
- Dependency injection — Google — Panduan dependency injection beserta manfaatnya untuk pengujian dan pergantian implementasi.