Aplikasi Android yang menyimpan data persisten biasanya memakai SQLite. Menulis SQL mentah melalui SQLiteOpenHelper berisiko: typo pada nama kolom tidak terdeteksi sampai runtime, dan mapping Cursor ke objek Kotlin sangat membosankan. Room, library Jetpack, menambahkan lapisan abstraksi di atas SQLite dengan anotasi @Database, @Entity, dan @Dao. Kompilator memverifikasi SQL saat build, sehingga kesalahan ketik terdeteksi sebelum APK dipublikasikan. Bab ini menjelaskan ketiga anotasi inti Room, pola migrasi skema, dan integrasi dengan coroutines.

Tiga Anotasi Inti Room #

Room bekerja dengan tiga anotasi: @Database menandai kelas abstract yang menjadi titik masuk, @Entity menandai class data yang dipetakan ke tabel, dan @Dao menandai interface yang berisi query SQL.

kotlin Entitas dan DAO (Compose-friendly)
@Entity(tableName = "tugas")
data class TugasEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    @ColumnInfo(name = "judul") val judul: String,
    @ColumnInfo(name = "selesai") val selesai: Boolean = false
)

@Dao
interface TugasDao {
    @Query("SELECT * FROM tugas ORDER BY id")
    fun getAll(): Flow<List<TugasEntity>>

    @Query("SELECT * FROM tugas WHERE id = :id")
    suspend fun findById(id: Int): TugasEntity?

    @Insert
    suspend fun insert(tugas: TugasEntity)

    @Update
    suspend fun update(tugas: TugasEntity)

    @Delete
    suspend fun delete(tugas: TugasEntity)
}

@Database(entities = [TugasEntity::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
    abstract fun tugasDao(): TugasDao
}

Saat kompilator memproses kode di atas, ia memeriksa bahwa setiap @Query valid: nama tabel, kolom, dan parameter sesuai. Bila ada typo (mis. judu alih-alih judul), build gagal dengan pesan yang jelas.

Verifikasi SQL Saat Kompilasi #

Kompilator Room memeriksa SQL pada anotasi @Query, @Insert, @Update, dan @Delete. Setiap parameter :id harus cocok dengan parameter method. Setiap nama kolom harus ada di entitas. Verifikasi ini mencegah bug runtime yang sulit dilacak.

KondisiHasil build
@Query("SELECT * FROM tugas WHERE id = :id") dengan fun findById(id: Int)OK
@Query("SELECT * FROM tugas WHERE id = :id") tanpa parameter methodFail
@Query("SELECT judu FROM tugas") (kolom typo)Fail
@Insert pada entitas yang tidak terdaftar di @DatabaseFail

Migrasi Skema #

Saat kita mengubah entitas (tambah kolom, ubah tipe, hapus tabel), versi database harus dinaikkan dan Migration harus disediakan. Tanpa migration, Room akan IllegalStateException karena data pengguna akan hilang.

kotlin Migration 1 -> 2: tambah kolom
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE tugas ADD COLUMN prioritas INTEGER NOT NULL DEFAULT 0")
    }
}

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(MIGRATION_1_2)
    .build()

Untuk skenario dev, kita dapat memakai fallbackToDestructiveMigration() yang menghapus seluruh data dan membuat ulang skema. Opsi ini tidak boleh dipakai di production karena menghapus data pengguna.

Simulasi Room sebagai Struktur Data #

Karena Room memerlukan Android SDK, cuplikan berikut memodelkan @Entity, @Dao, @Database, dan Migration sebagai struktur data ringkas. Operasi DAO (getAll, insert, update, delete) ditampilkan dengan komentar SQL yang setara:

kotlin
// room_database.kt
// Mensimulasikan konsep Room: @Database, @Entity, @Dao, dan migration
// sebagai struktur data ringkas yang dapat dijalankan tanpa Android SDK.
// Pada aplikasi nyata, anotasi diproses oleh KSP kompiler plugin Room.

data class TugasEntity(
    val id: Int,
    val judul: String,
    val selesai: Boolean = false,
    val versiSkema: Int = 1
)

// Mensimulasikan @Dao — data access object yang berisi query SQL
class TugasDao(private val table: MutableList<TugasEntity>) {
    // @Query("SELECT * FROM tugas ORDER BY id")
    fun getAll(): List<TugasEntity> = table.sortedBy { it.id }

    // @Query("SELECT * FROM tugas WHERE id = :id")
    fun findById(id: Int): TugasEntity? = table.firstOrNull { it.id == id }

    // @Insert
    fun insert(tugas: TugasEntity) { table.add(tugas) }

    // @Update
    fun update(tugas: TugasEntity) {
        val idx = table.indexOfFirst { it.id == tugas.id }
        if (idx >= 0) table[idx] = tugas
    }

    // @Delete
    fun delete(tugas: TugasEntity) { table.remove(tugas) }
}

// Mensimulasikan RoomDatabase dengan migrasi
class RoomDatabase private constructor(
    private val dao: TugasDao,
    var versi: Int
) {
    companion object {
        fun build(initial: List<TugasEntity> = emptyList(), versi: Int = 1): RoomDatabase {
            return RoomDatabase(TugasDao(initial.toMutableList()), versi)
        }
    }

    fun tugasDao(): TugasDao = dao
}

// Migrasi dari versi 1 -> 2: tambah kolom "versiSkema"
object Migration1To2 {
    fun migrate(db: RoomDatabase) {
        val dao = db.tugasDao()
        dao.getAll().forEach { tugas ->
            if (tugas.versiSkema < 2) {
                dao.update(tugas.copy(versiSkema = 2))
            }
        }
        db.versi = 2
    }
}

fun main() {
    val db = RoomDatabase.build(
        initial = listOf(
            TugasEntity(1, "Belanja", false, versiSkema = 1),
            TugasEntity(2, "Olahraga", true, versiSkema = 1)
        ),
        versi = 1
    )
    val dao = db.tugasDao()

    println("=== Versi 1 (sebelum migrasi) ===")
    dao.getAll().forEach { println("  $it") }

    println("\n=== Insert tugas baru ===")
    dao.insert(TugasEntity(3, "Belajar Kotlin", false, versiSkema = 1))
    dao.getAll().forEach { println("  $it") }

    println("\n=== Update status tugas id=1 ===")
    dao.update(dao.findById(1)!!.copy(selesai = true))
    dao.getAll().forEach { println("  $it") }

    println("\n=== Migrasi ke versi 2 ===")
    Migration1To2.migrate(db)
    println("Versi DB: ${db.versi}")
    dao.getAll().forEach { println("  $it") }

    println("\n=== Delete tugas id=2 ===")
    dao.delete(dao.findById(2)!!)
    dao.getAll().forEach { println("  $it") }
}

Output yang Dihasilkan:

text
=== Versi 1 (sebelum migrasi) ===
  TugasEntity(id=1, judul=Belanja, selesai=false, versiSkema=1)
  TugasEntity(id=2, judul=Olahraga, selesai=true, versiSkema=1)

=== Insert tugas baru ===
  TugasEntity(id=1, judul=Belanja, selesai=false, versiSkema=1)
  TugasEntity(id=2, judul=Olahraga, selesai=true, versiSkema=1)
  TugasEntity(id=3, judul=Belajar Kotlin, selesai=false, versiSkema=1)

=== Update status tugas id=1 ===
  TugasEntity(id=1, judul=Belanja, selesai=true, versiSkema=1)
  TugasEntity(id=2, judul=Olahraga, selesai=true, versiSkema=1)
  TugasEntity(id=3, judul=Belajar Kotlin, selesai=false, versiSkema=1)

=== Migrasi ke versi 2 ===
Versi DB: 2
  TugasEntity(id=1, judul=Belanja, selesai=true, versiSkema=2)
  TugasEntity(id=2, judul=Olahraga, selesai=true, versiSkema=2)
  TugasEntity(id=3, judul=Belajar Kotlin, selesai=false, versiSkema=2)

=== Delete tugas id=2 ===
  TugasEntity(id=1, judul=Belanja, selesai=true, versiSkema=2)
  TugasEntity(id=3, judul=Belajar Kotlin, selesai=false, versiSkema=2)

Perhatikan bahwa setelah migrasi, semua entitas memiliki versiSkema = 2. Pola ini mencerminkan bagaimana Room memigrasi skema: operasi dijalankan satu per satu pada data yang ada, lalu versi database dinaikkan.

Referensi Lanjutan #

Selanjutnya #

Room cocok untuk data terstruktur, namun untuk key-value sederhana (preference, flag), DataStore lebih ringan. Bab DataStore akan membahas pengganti SharedPreferences dengan API async.