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
└──────────────────────────────────────────┘
KomponenTanggung jawabUmur
ComposableMenggambar state, meneruskan eventselama tampil di layar
ViewModelMenyusun state layar, menerima eventbertahan saat rotasi, mati saat layar ditutup
RepositorySatu pintu ke data; memutuskan sumber (lokal/remote)singleton aplikasi
Room / DataStorePenyimpanan persistendi disk
WorkManagerPekerjaan yang harus selesai walau app ditutupdijamin 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.
  • StateFlow untuk state (selalu punya nilai terakhir); SharedFlow untuk 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 dan SQLiteDriver, 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()
    }
}
PilihanUntuk
Preferences DataStorePengaturan kecil: URL, toggle, daftar kata kunci
Proto/typed DataStoreObjek terstruktur dengan skema
RoomData 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 doWorkArti
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 DIHilt
Setupnol dependensi, Kotlin biasaplugin Gradle + anotasi + KSP
Skalanyaman sampai belasan dependensinyaman untuk banyak modul/fitur
Errorruntime (lupa wiring)compile time
Scope (per layar, per activity)ditulis sendiribawaan

Kesalahan Umum

KesalahanAkibatPerbaikan
Mengekspos MutableStateFlow ke UIUI bisa mengubah state sembaranganEkspos StateFlow read-only / asStateFlow()
Menyimpan Context/View di ViewModelMemory leakTerima dependensi lewat konstruktor; butuh context → di data layer
Membuat Room.databaseBuilder berkali-kaliBanyak koneksi, data tidak konsistenSatu instance (container/singleton)
Menaikkan version Room tanpa migrasiCrash IllegalStateException saat buka DBTulis Migration/@AutoMigration; fallbackToDestructiveMigration hanya untuk dev
Membuat beberapa DataStore untuk file yang samaIllegalStateException multiple DataStoresDeklarasi preferencesDataStore sekali, top-level
Kirim jaringan langsung dari viewModelScope untuk data pentingHilang saat app ditutupSimpan ke Room dulu, lalu serahkan ke WorkManager
enqueue tanpa unique nameWorker ganda untuk item yang samaenqueueUniqueWork + policy yang tepat
Retry tanpa batasItem berputar selamanya ke server matiBatasi 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.

Comments