Aplikasi yang baik tidak kehilangan data saat ditutup. Pada Bagian 3 kita menghubungkan TransaksiViewModel ke database lokal via Room, abstraksi SQLite yang menjadi standar Android. Room menghasilkan implementasi DAO otomatis dari annotation, sehingga kita hanya perlu mendeklarasikan query SQL dan Kotlin menangani selebihnya. Untuk demo yang dapat dijalankan di terminal, kita menulis pola DAO yang sama dengan JDBC langsung — apa yang Room hasilkan otomatis di Android, kita tulis manual di sini.
Tiga Komponen Room #
Room terdiri dari tiga komponen utama:
- Entity —
data classdengan annotation@Entity(tableName = "...")yang memetakan ke tabel SQL. Setiap property menjadi kolom. - Dao — interface dengan annotation
@Daoberisi method@Insert,@Update,@Delete, atau@Query("SELECT ..."). Room menghasilkan implementasi otomatis. - Database — class abstract dengan annotation
@Database(entities = [...], version = 1)yang menjadi titik masuk. Memiliki method abstract untuk mengakses masing-masing Dao.
Karena kode contoh berjalan di terminal tanpa plugin Room, kita menulis implementasi Dao secara manual dengan JDBC. Pola strukturnya identik — perbedaan hanya pada annotation yang tidak kita pakai.
Skema Tabel transaksi #
Tabel transaksi memiliki enam kolom: id (primary key auto-increment), jenis (string “PEMASUKAN” atau “PENGELUARAN”), jumlah, kategori, catatan, dan tanggal ISO. Index pada tanggal mempercepat query rentang tanggal yang sering dipakai di laporan bulanan.
CREATE TABLE IF NOT EXISTS transaksi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jenis TEXT NOT NULL,
jumlah INTEGER NOT NULL,
kategori TEXT NOT NULL,
catatan TEXT NOT NULL,
tanggal TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transaksi_tanggal ON transaksi(tanggal);AUTOINCREMENT membuat SQLite otomatis memberi ID berurutan. Index pada tanggal membantu query WHERE tanggal BETWEEN ? AND ? yang sering dipakai untuk laporan bulanan.
Implementasi Dao dan Database #
Kode berikut menunjukkan implementasi TransaksiDao dengan operasi insert, semua, byTanggal, dan dua fungsi agregat totalPemasukan/totalPengeluaran:
// Bagian 3: Database lokal dengan Room.
// Mendemonstrasikan pola DAO Room dengan SQLite JDBC: Entity, Dao, dan
// Database. Pada Android asli, Room menghasilkan implementasi DAO otomatis
// dari annotation @Entity, @Dao, @Database. Di sini kita tulis manual
// dengan JDBC untuk memahami apa yang Room lakukan di balik layar.
import java.sql.Connection
import java.sql.DriverManager
// === Entity: tabel transaksi ===
data class TransaksiEntity(
val id: Long,
val jenis: String, // "PEMASUKAN" atau "PENGELUARAN"
val jumlah: Long,
val kategori: String,
val catatan: String,
val tanggal: String // ISO yyyy-MM-dd
)
// === Dao: akses database ===
class TransaksiDao(private val conn: Connection) {
init {
conn.createStatement().use { stmt ->
stmt.executeUpdate("""
CREATE TABLE IF NOT EXISTS transaksi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jenis TEXT NOT NULL,
jumlah INTEGER NOT NULL,
kategori TEXT NOT NULL,
catatan TEXT NOT NULL,
tanggal TEXT NOT NULL
)
""".trimIndent())
stmt.executeUpdate("CREATE INDEX IF NOT EXISTS idx_transaksi_tanggal ON transaksi(tanggal)")
}
}
// Pada Room asli: @Insert suspend fun insert(t: TransaksiEntity): Long
fun insert(t: TransaksiEntity): Long {
conn.prepareStatement("""
INSERT INTO transaksi (jenis, jumlah, kategori, catatan, tanggal)
VALUES (?, ?, ?, ?, ?)
""".trimIndent(), java.sql.Statement.RETURN_GENERATED_KEYS).use { ps ->
ps.setString(1, t.jenis)
ps.setLong(2, t.jumlah)
ps.setString(3, t.kategori)
ps.setString(4, t.catatan)
ps.setString(5, t.tanggal)
ps.executeUpdate()
ps.generatedKeys.use { rs -> if (rs.next()) return rs.getLong(1) }
}
return -1
}
// Pada Room asli: @Query("SELECT * FROM transaksi ORDER BY id DESC")
fun semua(): List<TransaksiEntity> {
val hasil = mutableListOf<TransaksiEntity>()
conn.prepareStatement("SELECT id, jenis, jumlah, kategori, catatan, tanggal FROM transaksi ORDER BY id DESC").use { ps ->
ps.executeQuery().use { rs ->
while (rs.next()) {
hasil.add(TransaksiEntity(
rs.getLong("id"), rs.getString("jenis"),
rs.getLong("jumlah"), rs.getString("kategori"),
rs.getString("catatan"), rs.getString("tanggal")
))
}
}
}
return hasil
}
// Pada Room asli: @Query("SELECT * FROM transaksi WHERE tanggal BETWEEN :awal AND :akhir")
fun byTanggal(awal: String, akhir: String): List<TransaksiEntity> {
val hasil = mutableListOf<TransaksiEntity>()
conn.prepareStatement("SELECT id, jenis, jumlah, kategori, catatan, tanggal FROM transaksi WHERE tanggal BETWEEN ? AND ? ORDER BY tanggal").use { ps ->
ps.setString(1, awal)
ps.setString(2, akhir)
ps.executeQuery().use { rs ->
while (rs.next()) {
hasil.add(TransaksiEntity(
rs.getLong("id"), rs.getString("jenis"),
rs.getLong("jumlah"), rs.getString("kategori"),
rs.getString("catatan"), rs.getString("tanggal")
))
}
}
}
return hasil
}
fun totalPemasukan(): Long =
conn.prepareStatement("SELECT COALESCE(SUM(jumlah), 0) FROM transaksi WHERE jenis = 'PEMASUKAN'")
.use { ps -> ps.executeQuery().use { rs -> if (rs.next()) rs.getLong(1) else 0 } }
fun totalPengeluaran(): Long =
conn.prepareStatement("SELECT COALESCE(SUM(jumlah), 0) FROM transaksi WHERE jenis = 'PENGELUARAN'")
.use { ps -> ps.executeQuery().use { rs -> if (rs.next()) rs.getLong(1) else 0 } }
}
// === Database: holder untuk koneksi + Dao ===
class AppDatabase(private val conn: Connection) {
val transaksiDao = TransaksiDao(conn)
fun tutup() = conn.close()
}
fun formatRupiah(nilai: Long): String {
val str = nilai.toString()
return "Rp" + str.reversed().chunked(3).joinToString(".").reversed()
}
fun main() {
// Pada Android: Room.databaseBuilder(...).build() -> AppDatabase
// Untuk demo terminal: SQLite in-memory.
val db = AppDatabase(DriverManager.getConnection("jdbc:sqlite::memory:"))
val dao = db.transaksiDao
println("=== Insert transaksi ===")
dao.insert(TransaksiEntity(0, "PEMASUKAN", 5_000_000, "Gaji", "Gaji Januari", "2026-01-05"))
dao.insert(TransaksiEntity(0, "PENGELUARAN", 750_000, "Makan", "Makan siang", "2026-01-08"))
dao.insert(TransaksiEntity(0, "PENGELUARAN", 1_200_000, "Transport", "Bensin", "2026-01-12"))
dao.insert(TransaksiEntity(0, "PEMASUKAN", 1_000_000, "Bonus", "Bonus proyek", "2026-01-15"))
dao.insert(TransaksiEntity(0, "PENGELUARAN", 500_000, "Makan", "Makan malam", "2026-01-20"))
println("Total: ${dao.semua().size} transaksi tersimpan")
println()
println("=== Daftar semua (urut id DESC) ===")
dao.semua().forEach { t ->
val arah = if (t.jenis == "PEMASUKAN") "+" else "-"
println(" [${t.id}] $arah ${formatRupiah(t.jumlah)} | ${t.tanggal} ${t.kategori} - ${t.catatan}")
}
println()
println("=== Filter 2026-01-10 s/d 2026-01-31 ===")
dao.byTanggal("2026-01-10", "2026-01-31").forEach { t ->
println(" ${t.tanggal} ${t.kategori} ${formatRupiah(t.jumlah)}")
}
println()
println("=== Ringkasan ===")
val masuk = dao.totalPemasukan()
val keluar = dao.totalPengeluaran()
println("Pemasukan : ${formatRupiah(masuk)}")
println("Pengeluaran: ${formatRupiah(keluar)}")
println("Saldo : ${formatRupiah(masuk - keluar)}")
db.tutup()
}Output yang Dihasilkan:
=== Insert transaksi ===
Total: 5 transaksi tersimpan
=== Daftar semua (urut id DESC) ===
[5] - Rp500.000 | 2026-01-20 Makan - Makan malam
[4] + Rp1.000.000 | 2026-01-15 Bonus - Bonus proyek
[3] - Rp1.200.000 | 2026-01-12 Transport - Bensin
[2] - Rp750.000 | 2026-01-08 Makan - Makan siang
[1] + Rp5.000.000 | 2026-01-05 Gaji - Gaji Januari
=== Filter 2026-01-10 s/d 2026-01-31 ===
2026-01-12 Transport Rp1.200.000
2026-01-15 Bonus Rp1.000.000
2026-01-20 Makan Rp500.000
=== Ringkasan ===
Pemasukan : Rp6.000.000
Pengeluaran: Rp2.450.000
Saldo : Rp3.550.000Mari kita bedah komponen utama:
conn.prepareStatement(..., RETURN_GENERATED_KEYS): parameter kedua meminta JDBC mengembalikan kunci yang dibuat otomatis (kolomidAUTOINCREMENT). SetelahexecuteUpdate(),ps.generatedKeysberisi result set satu baris dengan ID baru.ps.use { ... }: extension function dari Kotlin stdlib yang memanggilclose()otomatis setelah blok selesai, bahkan jika ada exception. Sama dengantry-with-resourcesJava. Tanpa ini, koneksi dapat bocor dan akhirnya habis.COALESCE(SUM(jumlah), 0):SUMmengembalikanNULLjika tabel kosong.COALESCEmenggantiNULLdengan0, sehingga tipe kembalian selaluLongnon-null. TanpaCOALESCE,rs.getLong(1)mengembalikan0tetapirs.wasNull()tetaptrue.RETURN_GENERATED_KEYS: fitur JDBC standar. Room menyembunyikan detail ini; pemanggil cukup menulis@Insert suspend fun insert(t: TransaksiEntity): Longdan Room menangani generated keys.AppDatabasesebagai holder Dao: di Android,AppDatabaseadalah abstract class dengan@Databaseannotation. Method abstracttransaksiDao()di-override oleh Room dengan implementasi yang memanggilcompileStatementdi balik layar.
Suspend Function dan Coroutines #
Pada Room asli, method Dao ditandai suspend dan dipanggil dari dalam coroutine. Room otomatis menjalankan query di dispatcher Dispatchers.IO:
@Dao
interface TransaksiDao {
@Insert suspend fun insert(t: TransaksiEntity): Long
@Query("SELECT * FROM transaksi ORDER BY id DESC") fun semua(): Flow<List<TransaksiEntity>>
@Query("SELECT COALESCE(SUM(jumlah),0) FROM transaksi WHERE jenis='PEMASUKAN'") suspend fun totalPemasukan(): Long
}suspend menandai bahwa function hanya boleh dipanggil dari coroutine atau suspend function lain. Flow<List<TransaksiEntity>> adalah cold stream yang memancarkan daftar baru setiap kali tabel berubah. ViewModel mengamati Flow ini dan mengupdate state.
Mengapa ViewModel Tidak Bicara Langsung dengan Dao #
Pola yang direkomendasikan Google: ViewModel memanggil Repository, Repository memanggil Dao. Lapisan Repository memungkinkan:
- Cache in-memory untuk data yang sering diakses,
- Switch antara sumber data (remote API, local cache),
- Logika bisnis yang melibatkan banyak Dao sekaligus.
Untuk aplikasi sederhana, ViewModel boleh langsung memanggil Dao. Saat aplikasi tumbuh, pindahkan akses Dao ke Repository.
Referensi Lanjutan #
- Kotlin Multiplatform Mobile getting started — panduan KMP Mobile untuk integrasi database lokal di aplikasi Android
Ringkasan Bagian 3 #
Database lokal kini terhubung. TransaksiDao menangani insert, query, dan agregasi dengan parameterized SQL yang aman dari SQL injection. AppDatabase menjadi holder tunggal untuk Dao. Pada Bagian 4 kita menulis pengujian unit untuk logika ViewModel dan Dao, lalu membangun APK release untuk distribusi.