September 28, 2026
Arsitektur Android Modern.
Menyusun aplikasi Android yang rapi: lapisan UI–data, ViewModel + StateFlow, Room, DataStore, WorkManager, dan manual dependency injection — berdasarkan aplikasi NotiFly.
Compose menjawab bagaimana menggambar UI; arsitektur menjawab di mana data dan logika tinggal agar aplikasi tetap benar saat layar diputar, proses dibunuh sistem, atau jaringan putus. Catatan ini mengikuti panduan arsitektur resmi Android dengan contoh nyata dari NotiFly : aplikasi yang menangkap notifikasi bank, menyimpannya di database lokal, lalu mengirimnya ke server dengan retry otomatis. Prasyarat: Dasar Kotlin dan Jetpack Compose Dasar .
1. Gambaran Lapisan
┌──────────── UI layer ────────────┐
│ Composable ◀── StateFlow ── ViewModel
│ │ event (onRetry, onQuery) │
└──────┼───────────────────────────────┼──┘
▼ ▼
┌──────────── Data layer ──────────────────┐
│ NotificationRepository │
│ Room (riwayat) DataStore (config) Retrofit (API)
│ SendScheduler → WorkManager
└──────────────────────────────────────────┘
| Komponen | Tanggung jawab | Umur |
|---|---|---|
| Composable | Menggambar state, meneruskan event | selama tampil di layar |
| ViewModel | Menyusun state layar, menerima event | bertahan saat rotasi, mati saat layar ditutup |
| Repository | Satu pintu ke data; memutuskan sumber (lokal/remote) | singleton aplikasi |
| Room / DataStore | Penyimpanan persisten | di disk |
| WorkManager | Pekerjaan yang harus selesai walau app ditutup | dijamin sistem |
Prinsipnya: aliran data satu arah (state turun, event naik) dan satu sumber kebenaran (database lokal), sehingga UI selalu merender apa yang ada di Room.
2. ViewModel + StateFlow
ViewModel mengekspos satu StateFlow<UiState> yang read-only. State dibangun dengan menggabungkan beberapa Flow:
data class HistoryUiState(
val items: List<NotificationEntity> = emptyList(),
val query: String = "",
val isLoading: Boolean = true,
)
class HistoryViewModel(private val repository: NotificationRepository) : ViewModel() {
private val query = MutableStateFlow("")
val uiState: StateFlow<HistoryUiState> =
combine(repository.notifications, query) { all, q ->
HistoryUiState(
items = all.filter { q.isBlank() || it.title.orEmpty().contains(q, ignoreCase = true) },
query = q,
isLoading = false,
)
}
.flowOn(Dispatchers.Default) // filter/grouping di luar main thread
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HistoryUiState(),
)
private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 4)
val messages = _messages.asSharedFlow() // event sekali jalan (snackbar)
fun onQueryChange(value: String) { query.value = value }
fun retry(id: Long) = viewModelScope.launch {
repository.retry(id)
_messages.tryEmit("Dimasukkan ke antrean kirim")
}
}
WhileSubscribed(5_000)menghentikan upstream 5 detik setelah UI berhenti mengamati (app ke background), tetapi tetap hidup saat rotasi layar.StateFlowuntuk state (selalu punya nilai terakhir);SharedFlowuntuk event sekali jalan seperti snackbar.- Di UI:
val state by viewModel.uiState.collectAsStateWithLifecycle().
3. Room: Database Lokal
Room adalah lapisan di atas SQLite dengan query yang dicek saat compile. Ada tiga bagian: Entity, DAO, Database.
@Entity(tableName = "notifications", indices = [Index("status"), Index("postTime")])
data class NotificationEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val packageName: String,
val title: String?,
val text: String?,
val postTime: Long,
@ColumnInfo(defaultValue = "PENDING") val status: String = "PENDING",
val retryCount: Int = 0,
)
@Dao
interface NotificationDao {
@Insert suspend fun insert(entity: NotificationEntity): Long
@Query("SELECT * FROM notifications ORDER BY postTime DESC")
fun observeAll(): Flow<List<NotificationEntity>> // otomatis emit saat tabel berubah
@Query("SELECT * FROM notifications WHERE status IN ('PENDING', 'FAILED')")
suspend fun getRetryable(): List<NotificationEntity>
@Query("UPDATE notifications SET status = :status WHERE id = :id")
suspend fun updateStatus(id: Long, status: String)
}
@Database(entities = [NotificationEntity::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun notificationDao(): NotificationDao
}
// satu instance untuk seluruh aplikasi
val db = Room.databaseBuilder(appContext, AppDatabase::class.java, "notifly.db").build()
Dependensi memakai KSP (ksp("androidx.room:room-compiler:<versi>")), bukan kapt. Fungsi suspend dan Flow di DAO otomatis dijalankan di thread background.
Room 2.x vs Room 3.0: Room 3 sudah stabil dengan package baru
androidx.room3— Kotlin-only, KSP-only, API berbasis coroutine danSQLiteDriver, serta mendukung Kotlin Multiplatform. NotiFly masih memakai Room 2.x; konsep Entity/DAO/Database di atas tetap sama.
Setiap perubahan skema menaikkan version dan butuh migrasi (@AutoMigration atau Migration manual) — prinsipnya sama dengan Migrasi Database
di backend. exportSchema = true menyimpan JSON skema per versi untuk dites dan di-commit.
4. DataStore: Preferensi & Konfigurasi
DataStore menggantikan SharedPreferences: asinkron (Flow + suspend), transaksional, dan aman dari race. Untuk key-value sederhana gunakan Preferences DataStore:
private val Context.dataStore by preferencesDataStore(name = "notifly_config") // top-level, sekali
data class AppConfig(val serverUrl: String = "", val keywords: Set<String> = emptySet(), val retentionDays: Int = 30)
class ConfigDataStore(private val context: Context) {
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
val KEYWORDS = stringSetPreferencesKey("keywords")
val RETENTION_DAYS = intPreferencesKey("retention_days")
}
val config: Flow<AppConfig> = context.dataStore.data.map { p ->
AppConfig(
serverUrl = p[Keys.SERVER_URL].orEmpty(),
keywords = p[Keys.KEYWORDS].orEmpty(),
retentionDays = p[Keys.RETENTION_DAYS] ?: 30,
)
}
suspend fun current(): AppConfig = config.first()
suspend fun setServerUrl(url: String) = context.dataStore.edit { it[Keys.SERVER_URL] = url.trim() }
suspend fun addKeyword(k: String) = context.dataStore.edit {
it[Keys.KEYWORDS] = it[Keys.KEYWORDS].orEmpty() + k.trim().lowercase()
}
}
| Pilihan | Untuk |
|---|---|
| Preferences DataStore | Pengaturan kecil: URL, toggle, daftar kata kunci |
| Proto/typed DataStore | Objek terstruktur dengan skema |
| Room | Data banyak, perlu query/filter/relasi |
DataStore tidak mengenkripsi isinya. Di NotiFly, API key dienkripsi dulu dengan kunci di Android Keystore sebelum ditulis — jangan simpan rahasia sebagai plaintext.
5. WorkManager: Pekerjaan yang Harus Selesai
WorkManager menjalankan pekerjaan yang dapat ditunda tapi wajib selesai, bahkan setelah app ditutup atau HP restart — cocok untuk “kirim notifikasi ini ke server, ulangi kalau gagal”. Konsepnya sama dengan antrean job di backend; perbandingan lengkapnya ada di Background Job & Queue .
class SendNotificationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val id = inputData.getLong(KEY_ID, -1L)
if (id <= 0L) return Result.failure()
val repo = (applicationContext as NotiFlyApplication).container.notificationRepository
val entity = repo.getById(id) ?: return Result.success() // sudah dihapus
return when (val r = repo.send(entity)) {
is SendResult.Success -> { repo.markSent(id); Result.success() }
is SendResult.Permanent -> { repo.markFailed(id, r.message); Result.failure() }
is SendResult.Retryable ->
if (runAttemptCount + 1 >= MAX_ATTEMPTS) { repo.markFailed(id, r.message); Result.failure() }
else Result.retry() // backoff diatur WorkManager
}
}
companion object { const val KEY_ID = "notification_id"; const val MAX_ATTEMPTS = 3 }
}
fun enqueueSend(workManager: WorkManager, id: Long) {
val request = OneTimeWorkRequestBuilder<SendNotificationWorker>()
.setInputData(workDataOf(SendNotificationWorker.KEY_ID to id))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
// unik per item; REPLACE agar retry manual menggantikan antrean lama
workManager.enqueueUniqueWork("send-$id", ExistingWorkPolicy.REPLACE, request)
}
// pembersihan riwayat harian; KEEP agar jadwal tidak ter-reset setiap app dibuka
fun schedulePruning(workManager: WorkManager) =
workManager.enqueueUniquePeriodicWork(
"prune-history", ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<PruneHistoryWorker>(1, TimeUnit.DAYS).build(),
)
Hasil doWork | Arti |
|---|---|
Result.success() | selesai, jangan ulang |
Result.retry() | ulang sesuai backoff (minimal 10 detik) |
Result.failure() | gagal permanen, jangan ulang |
Periodic work punya interval minimum 15 menit. Untuk tugas yang harus jalan sekarang juga dan terlihat user (misal upload besar), pertimbangkan expedited work atau foreground service.
6. Manual Dependency Injection
Untuk aplikasi kecil–menengah, DI tidak harus Hilt. NotiFly memakai satu container berisi singleton yang dibuat secara lazy:
class AppContainer(context: Context) {
private val appContext = context.applicationContext
val configDataStore by lazy { ConfigDataStore(appContext) }
private val database by lazy {
Room.databaseBuilder(appContext, AppDatabase::class.java, "notifly.db").build()
}
private val apiService by lazy { ApiClient.create() }
private val workManager by lazy { WorkManager.getInstance(appContext) }
val notificationRepository by lazy {
NotificationRepository(database.notificationDao(), configDataStore, apiService, workManager)
}
}
class NotiFlyApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
// Satu factory untuk semua ViewModel
object ViewModelFactory {
val Factory = viewModelFactory {
initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NotiFlyApplication
HistoryViewModel(app.container.notificationRepository)
}
}
}
// di Compose
val vm: HistoryViewModel = viewModel(factory = ViewModelFactory.Factory)
lazy penting: NotificationListenerService di NotiFly ikut memakai container yang sama, dan tidak perlu membayar biaya inisialisasi dependensi yang hanya dipakai UI.
| Manual DI | Hilt | |
|---|---|---|
| Setup | nol dependensi, Kotlin biasa | plugin Gradle + anotasi + KSP |
| Skala | nyaman sampai belasan dependensi | nyaman untuk banyak modul/fitur |
| Error | runtime (lupa wiring) | compile time |
| Scope (per layar, per activity) | ditulis sendiri | bawaan |
Kesalahan Umum
| Kesalahan | Akibat | Perbaikan |
|---|---|---|
Mengekspos MutableStateFlow ke UI | UI bisa mengubah state sembarangan | Ekspos StateFlow read-only / asStateFlow() |
Menyimpan Context/View di ViewModel | Memory leak | Terima dependensi lewat konstruktor; butuh context → di data layer |
Membuat Room.databaseBuilder berkali-kali | Banyak koneksi, data tidak konsisten | Satu instance (container/singleton) |
Menaikkan version Room tanpa migrasi | Crash IllegalStateException saat buka DB | Tulis Migration/@AutoMigration; fallbackToDestructiveMigration hanya untuk dev |
Membuat beberapa DataStore untuk file yang sama | IllegalStateException multiple DataStores | Deklarasi preferencesDataStore sekali, top-level |
Kirim jaringan langsung dari viewModelScope untuk data penting | Hilang saat app ditutup | Simpan ke Room dulu, lalu serahkan ke WorkManager |
enqueue tanpa unique name | Worker ganda untuk item yang sama | enqueueUniqueWork + policy yang tepat |
| Retry tanpa batas | Item berputar selamanya ke server mati | Batasi dengan runAttemptCount atau kolom retryCount |

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.