Android Development

Kotlin Flow: Reactive Streams untuk Android Modern

Tutorial lengkap Kotlin Flow — StateFlow, SharedFlow, Flow operators, cold vs hot streams, testing, dan pola reactive programming untuk aplikasi Android production-ready

1. Pengenalan Kotlin Flow

Kotlin Flow adalah API untuk mengelola asynchronous data streams secara deklaratif. Flow memungkinkan Anda menghasilkan sekuens nilai dari waktu ke waktu — seperti data dari database, network response, sensor, atau user input — dengan cara yang structured concurrency dan lifecycle-aware.

Flow merupakan bagian dari ekosistem Kotlin Coroutines dan menjadi pengganti modern untuk LiveData, RxJava, dan callback-based patterns di Android.

Mengapa Flow?

Aspek Flow LiveData RxJava
Kotlin-native✅ Ya — built-in di Kotlin stdlib❌ Android-only❌ Library terpisah
Multiplatform✅ Kotlin Multiplatform❌ Android only✅ JVM
Operator Power✅ map, filter, combine, flatMap, dll⚠️ Terbatas✅ Sangat banyak
Cold Stream✅ Cold by default❌ Hot✅ Cold & Hot
Structured Concurrency✅ Coroutine scope✅ Lifecycle-aware❌ Manual disposal
Learning Curve🟡 Sedang🟢 Mudah🔴 Tinggi
Backpressure✅ Supported❌ Tidak✅ Supported
Diagram: Flow Data Stream
┌──────────────┐     ┌─────────────┐     ┌─────────────┐
│   SOURCE     │     │  OPERATORS  │     │   COLLECTOR │
│              │     │             │     │             │
│  flow {      │     │  .map {}    │     │  .collect { │
│    emit(1)   │────→│  .filter {} │────→│    // use   │
│    emit(2)   │     │  .combine() │     │    // value │
│    emit(3)   │     │  .debounce()│     │  }          │
│  }           │     │  .catch {}  │     │             │
└──────────────┘     └─────────────┘     └─────────────┘
     │                      │                     │
     └──── COLD ────────────┘─────────────────────┘
         (Mulai saat dikoleksi)

2. Cold Flow vs Hot Flow

2.1 Cold Flow

Cold Flow baru mulai menghasilkan nilai ketika ada collector. Setiap collector mendapatkan stream yang terpisah — seperti menonton film dari awal setiap kali Anda tekan play.

Kotlin — Cold Flow
// Cold Flow: tidak melakukan apa-apa sampai ada collector
fun countDown(from: Int): Flow<Int> = flow {
    for (i in from downTo 0) {
        delay(1000) // Delay 1 detik
        emit(i)
        println("Emitted: $i")
    }
}

// Collector pertama → mulai dari awal
// Collector kedua → juga mulai dari awal (independent!)
fun main() = runBlocking {
    val countdown = countDown(5)

    launch {
        countdown.collect { value ->
            println("Collector 1: $value")
        }
    }

    launch {
        delay(2000) // Mulai 2 detik kemudian
        countdown.collect { value ->
            println("Collector 2: $value")
        }
    }
}

// Output:
// Emitted: 4
// Collector 1: 4
// Emitted: 3
// Collector 1: 3
// Emitted: 2
// Collector 1: 2
// Collector 2: 4  ← Collector 2 juga mulai dari 5!
// ...

2.2 Hot Flow

Hot Flow menghasilkan nilai terus-menerus, terlepas dari apakah ada collector atau tidak. Collector yang terlambat hanya mendapatkan nilai-nilai yang diproduksi setelah ia mulai mengoleksi (kecuali StateFlow yang selalu punya nilai terakhir).

Kotlin — Hot Flow (SharedFlow)
fun main() = runBlocking {
    val hotFlow = MutableSharedFlow<String>()

    // Collector 1: mendengarkan
    launch {
        hotFlow.collect { value ->
            println("Collector 1: $value")
        }
    }

    delay(1000) // Tunggu collector siap

    // Emit nilai
    hotFlow.emit("Hello")
    hotFlow.emit("World")

    // Collector 2: mulai mendengarkan
    launch {
        hotFlow.collect { value ->
            println("Collector 2: $value")
        }
    }

    delay(500)
    hotFlow.emit("Kotlin Flow") // Kedua collector menerima

    delay(1000)
}

// Output:
// Collector 1: Hello
// Collector 1: World
// Collector 1: Kotlin Flow
// Collector 2: Kotlin Flow  ← Hanya menerima setelah mulai collect

Perbandingan Cold vs Hot

Aspek Cold Flow Hot Flow (StateFlow/SharedFlow)
Kapan mulaiSaat collect dipanggilSeketika saat dibuat
NilaiSetiap collector dapat semuaCollector terlambat bisa miss nilai
Collector1:1 per collector1:N — satu source, banyak collector
Use caseAPI call, DB query, one-shot dataState UI, events, real-time data

3. StateFlow

StateFlow adalah hot flow yang selalu memiliki satu nilai (state) terbaru. Sangat cocok untuk mengelola state di ViewModel — seperti pengganti LiveData.

3.1 Dasar StateFlow

Kotlin — StateFlow Basics
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

// ───── Data class untuk UI State ─────
data class UserUiState(
    val isLoading: Boolean = true,
    val userName: String = "",
    val email: String = "",
    val error: String? = null,
    val isLoggedIn: Boolean = false,
)

// ───── ViewModel ─────
class UserViewModel(
    private val userRepository: UserRepository,
) : ViewModel() {

    // Private mutable — hanya bisa diubah dari dalam ViewModel
    private val _uiState = MutableStateFlow(UserUiState())

    // Public immutable — bisa di-collect dari UI
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    init {
        loadUser()
    }

    fun loadUser() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, error = null)

            try {
                val user = userRepository.getCurrentUser()
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    userName = user.name,
                    email = user.email,
                    isLoggedIn = true,
                )
            } catch (e: Exception) {
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    error = e.message ?: "Gagal memuat data",
                )
            }
        }
    }

    fun logout() {
        viewModelScope.launch {
            userRepository.logout()
            _uiState.value = UserUiState() // Reset ke default
        }
    }

    fun updateName(newName: String) {
        _uiState.value = _uiState.value.copy(userName = newName)
    }
}

3.2 Collect StateFlow di Compose

Kotlin — Compose UI dengan StateFlow
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
    // collectAsState() → otomatis recompose saat state berubah
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when {
        uiState.isLoading -> {
            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }
        uiState.error != null -> {
            Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
                Text("Error: ${uiState.error}", color = MaterialTheme.colorScheme.error)
                Spacer(Modifier.height(16.dp))
                Button(onClick = { viewModel.loadUser() }) {
                    Text("Coba Lagi")
                }
            }
        }
        else -> {
            Column(Modifier.padding(16.dp)) {
                Text("Halo, ${uiState.userName}!",
                    style = MaterialTheme.typography.headlineMedium)
                Text(uiState.email,
                    style = MaterialTheme.typography.bodyLarge)
                Spacer(Modifier.height(16.dp))
                OutlinedTextField(
                    value = uiState.userName,
                    onValueChange = { viewModel.updateName(it) },
                    label = { Text("Nama") },
                )
                Spacer(Modifier.height(8.dp))
                Button(onClick = { viewModel.logout() }) {
                    Text("Logout")
                }
            }
        }
    }
}

3.3 StateFlow vs LiveData

Kotlin — StateFlow vs LiveData
// ── DENGAN LIVEDATA (Lama) ──
class UserViewModel : ViewModel() {
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> = _user

    fun loadUser() {
        viewModelScope.launch {
            _user.value = repository.getUser()
        }
    }
}

// ── DENGAN STATEFLOW (Modern) ──
class UserViewModel : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()

    fun loadUser() {
        viewModelScope.launch {
            _user.value = repository.getUser()
        }
    }
}

// ── Kapan pakai StateFlow vs LiveData ──
// ✅ StateFlow:
//   - Kotlin Multiplatform project
//   - Butuh operator (map, filter, combine)
//   - Butuh distinctUntilChanged otomatis
//   - Unit testing lebih mudah
//
// ✅ LiveData:
//   - Legacy project yang sudah banyak LiveData
//   - Ingin lifecycle-aware tanpa collectAsStateWithLifecycle()
//   - Tim belum familiar dengan Flow

4. SharedFlow

SharedFlow adalah hot flow yang bisa memiliki banyak subscriber. Berbeda dengan StateFlow, SharedFlow tidak wajib memiliki nilai — cocok untuk one-shot events (navigasi, snackbar, toast).

4.1 Dasar SharedFlow

Kotlin — SharedFlow Events
// ── Event types untuk one-shot events ──
sealed class UserEvent {
    data class NavigateTo(val route: String) : UserEvent()
    data class ShowSnackbar(val message: String) : UserEvent()
    data class ShowToast(val message: String) : UserEvent()
    object NavigateBack : UserEvent()
}

class UserViewModel : ViewModel() {

    // SharedFlow untuk events (one-shot)
    private val _events = MutableSharedFlow<UserEvent>(
        replay = 0,              // Tidak replay event lama
        extraBufferCapacity = 1,  // Buffer 1 event
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val events: SharedFlow<UserEvent> = _events.asSharedFlow()

    fun saveUser(name: String) {
        viewModelScope.launch {
            try {
                repository.saveUser(name)
                // Emit event sukses
                _events.emit(UserEvent.ShowSnackbar("Data tersimpan!"))
                _events.emit(UserEvent.NavigateBack)
            } catch (e: Exception) {
                _events.emit(UserEvent.ShowSnackbar("Error: ${e.message}"))
            }
        }
    }
}

4.2 Collect Events di Compose

Kotlin — Collect Events dengan LaunchedEffect
@Composable
fun UserEditScreen(
    viewModel: UserViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit,
    onNavigate: (String) -> Unit,
) {
    val snackbarHostState = remember { SnackbarHostState() }

    // Collect events (one-shot) dengan LaunchedEffect
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is UserEvent.NavigateTo -> onNavigate(event.route)
                is UserEvent.ShowSnackbar -> {
                    snackbarHostState.showSnackbar(
                        message = event.message,
                        duration = SnackbarDuration.Short,
                    )
                }
                is UserEvent.ShowToast -> {
                    // Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
                }
                is UserEvent.NavigateBack -> onNavigateBack()
            }
        }
    }

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) },
    ) { padding ->
        // ... your UI content
    }
}

4.3 StateFlow vs SharedFlow

Aspek StateFlow SharedFlow
Nilai awalWajib adaOpsional
ReplaySelalu replay 1 nilai terakhirKonfigurabel (0, 1, N)
DeduplikasidistinctUntilChanged otomatisTidak (kecuali replay > 0)
Cocok untukUI State (loading, data, error)Events (navigasi, snackbar, toast)
Value typeTidak null (wajib initial)Bisa nullable

5. Flow Operators

Flow operators memungkinkan Anda mentransformasi, memfilter, menggabungkan, dan memanipulasi data stream secara deklaratif.

5.1 Transforming Operators

Kotlin — Transforming Operators
// ── map: Transformasi setiap nilai ──
fun getUsers(): Flow<List<User>> = repository.getUserFlow()
    .map { users ->
        users.sortedBy { it.name }
            .filter { it.isActive }
    }

// ── flatMapConcat: Transformasi yang menghasilkan Flow ──
fun getUserWithPosts(): Flow<UserWithPosts> = getUsers()
    .flatMapConcat { user ->
        flow {
            val posts = repository.getPosts(user.id)
            emit(UserWithPosts(user, posts))
        }
    }

// ── filter: Hanya lewatkan nilai yang memenuhi syarat ──
fun getActiveUsers(): Flow<User> = getAllUsers()
    .filter { it.isActive }
    .filter { it.age >= 18 }

// ── distinctUntilChanged: Hanya emit saat nilai berubah ──
fun searchResults(query: Flow<String>): Flow<List<Result>> = query
    .debounce(300)
    .distinctUntilChanged()
    .flatMapLatest { q ->
        repository.search(q)
    }

// ── transform: Transformasi fleksibel (bisa emit 0, 1, atau banyak) ──
fun paginateUsers(pageSize: Int = 20): Flow<List<User>> = flow {
    var page = 0
    var hasMore = true
    while (hasMore) {
        val users = repository.getUsers(page, pageSize)
        if (users.isEmpty()) {
            hasMore = false
        } else {
            emit(users)
            page++
        }
    }
}

// ── scan: Accumulate dengan running result ──
fun runningTotal(numbers: Flow<Int>): Flow<Int> = numbers
    .scan(0) { acc, value -> acc + value }
// 1, 2, 3, 4 → 0, 1, 3, 6, 10

// ── onEach: Side effect tanpa mengubah nilai ──
fun trackedFlow(): Flow<Data> = dataFlow
    .onEach { analytics.trackEvent("data_received", it) }
    .onEach { println("Data received: $it") }

5.2 Combining Operators

Kotlin — Combining Operators
// ── combine: Gabungkan 2+ flow, emit saat ada yang berubah ──
val searchState: Flow<SearchUiState> = combine(
    searchQuery,     // Flow<String>
    searchResults,   // Flow<List<Result>>
    isLoading,       // Flow<Boolean>
) { query, results, loading ->
    SearchUiState(
        query = query,
        results = results,
        isLoading = loading,
        isEmpty = results.isEmpty() && !loading,
    )
}

// ── zip: Gabungkan 2 flow, emit saat KEDUANYA punya nilai baru ──
val userProfile: Flow<UserProfile> = zip(
    userFlow,    // Flow<User>
    avatarFlow,  // Flow<Avatar>
) { user, avatar ->
    UserProfile(user, avatar)
}

// ── merge: Gabungkan 2+ flow, semua emit di-forward ──
val allEvents: Flow<Event> = merge(
    networkEvents,
    databaseEvents,
    localEvents,
)

// ── flatMapLatest: Batalkan flow lama saat nilai baru datang ──
// Cocok untuk search — batalkan request lama saat user mengetik
fun searchUsers(query: Flow<String>): Flow<List<User>> = query
    .debounce(300)
    .flatMapLatest { q ->
        if (q.isBlank()) flowOf(emptyList())
        else repository.searchUsers(q)
    }

5.3 Terminal Operators

Kotlin — Terminal Operators
// ── collect: Collect semua nilai ──
viewModelScope.launch {
    dataFlow.collect { value ->
        println(value)
    }
}

// ── first: Ambil nilai pertama ──
val firstUser = usersFlow.first()

// ── toList: Collect semua ke list ──
val allUsers = usersFlow.toList()

// ── reduce: Accumulate ke satu nilai ──
val totalPrice = itemsFlow.reduce { acc, item ->
    acc.copy(total = acc.total + item.price)
}

// ── fold: Reduce dengan initial value ──
val sum = numbersFlow.fold(0) { acc, num -> acc + num }

// ── single: Ambil satu-satunya nilai (error jika lebih) ──
val config = configFlow.single()

// ── stateIn: Convert cold flow ke StateFlow ──
class UserViewModel : ViewModel() {
    // Cold flow → StateFlow (lifecycle-aware)
    val users: StateFlow<List<User>> = repository
        .getUserFlow()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000), // Stop 5s setelah no subscriber
            initialValue = emptyList(),
        )
}

// ── shareIn: Convert cold flow ke SharedFlow ──
val events: SharedFlow<Event> = eventSource
    .shareIn(
        scope = viewModelScope,
        started = SharingStarted.Eagerly, // Mulai langsung
        replay = 0,
    )
💡 SharingStarted Strategies
StrategyKapan StartKapan StopCocok Untuk
EagerlyLangsungSaat scope cancelledData yang harus selalu ada
LazilySaat collector pertamaSaat scope cancelledData mahal yang tidak perlu langsung
WhileSubscribed(5000)Saat collector pertama5 detik setelah collector terakhir hilangBest practice untuk UI state

6. Exception Handling

Kotlin — Exception Handling Patterns
// ── catch operator: Tangkap error di dalam flow chain ──
val safeDataFlow: Flow<Result<Data>> = dataFlow
    .map { data -> Result.success(data) }
    .catch { e ->
        // Tangkap semua exception dari upstream
        emit(Result.failure(e))
        // Atau emit fallback value
        // emit(Result.success(defaultData))
    }

// ── try-catch di collect ──
viewModelScope.launch {
    try {
        dataFlow.collect { value ->
            // proses value
        }
    } catch (e: Exception) {
        _uiState.value = _uiState.value.copy(error = e.message)
    }
}

// ── onErrorReturn pattern (seperti RxJava onErrorReturn) ──
fun safeUsers(): Flow<List<User>> = repository.getUserFlow()
    .catch { e ->
        println("Error fetching users: ${e.message}")
        emit(emptyList()) // Fallback ke list kosong
    }

// ── retry: Coba lagi setelah error ──
fun resilientUsers(): Flow<List<User>> = repository.getUserFlow()
    .retry(3) { cause ->
        // Retry hanya untuk IOException
        cause is IOException
    }
    .catch { e ->
        emit(emptyList())
    }

// ── retryWhen: Retry dengan exponential backoff ──
fun resilientFlow(): Flow<Data> = dataFlow
    .retryWhen { cause, attempt ->
        if (cause is IOException && attempt < 3) {
            delay(1000L * (attempt + 1)) // Exponential backoff
            true // Retry
        } else {
            false // Jangan retry
        }
    }
    .catch { e ->
        emit(Data.EMPTY)
    }

7. Flow dengan Jetpack (ViewModel & Compose)

7.1 ViewModel + Flow Pattern

Kotlin — Complete ViewModel Pattern
@HiltViewModel
class ProductViewModel @Inject constructor(
    private val productRepository: ProductRepository,
    private val cartRepository: CartRepository,
) : ViewModel() {

    // ── Search Query ──
    private val _searchQuery = MutableStateFlow("")
    val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()

    // ── Category Filter ──
    private val _selectedCategory = MutableStateFlow<Category?>(null)
    val selectedCategory: StateFlow<Category?> = _selectedCategory.asStateFlow()

    // ── Products (derived dari query + category) ──
    val products: StateFlow<List<Product>> = combine(
        _searchQuery.debounce(300).distinctUntilChanged(),
        _selectedCategory,
    ) { query, category ->
        Pair(query, category)
    }.flatMapLatest { (query, category) ->
        productRepository.getProducts(
            query = query.ifBlank { null },
            category = category,
        )
    }.catch { e ->
        emit(emptyList())
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = emptyList(),
    )

    // ── UI State ──
    val uiState: StateFlow<ProductUiState> = combine(
        products,
        _searchQuery,
        _selectedCategory,
        cartRepository.cartItems,
    ) { products, query, category, cartItems ->
        ProductUiState(
            products = products,
            searchQuery = query,
            selectedCategory = category,
            cartItemCount = cartItems.size,
            isLoading = false,
        )
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = ProductUiState(isLoading = true),
    )

    // ── Events ──
    private val _events = MutableSharedFlow<ProductEvent>()
    val events: SharedFlow<ProductEvent> = _events.asSharedFlow()

    // ── Actions ──
    fun updateSearch(query: String) {
        _searchQuery.value = query
    }

    fun selectCategory(category: Category?) {
        _selectedCategory.value = category
    }

    fun addToCart(product: Product) {
        viewModelScope.launch {
            try {
                cartRepository.addToCart(product)
                _events.emit(ProductEvent.ShowSnackbar(
                    "${product.name} ditambahkan ke keranjang"
                ))
            } catch (e: Exception) {
                _events.emit(ProductEvent.ShowSnackbar(
                    "Gagal menambahkan: ${e.message}"
                ))
            }
        }
    }
}

data class ProductUiState(
    val products: List<Product> = emptyList(),
    val searchQuery: String = "",
    val selectedCategory: Category? = null,
    val cartItemCount: Int = 0,
    val isLoading: Boolean = false,
)

sealed class ProductEvent {
    data class ShowSnackbar(val message: String) : ProductEvent()
    data class NavigateTo(val route: String) : ProductEvent()
}

7.2 Compose UI

Kotlin — Compose Screen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProductScreen(
    viewModel: ProductViewModel = hiltViewModel(),
    onNavigate: (String) -> Unit,
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }

    // Collect events
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is ProductEvent.ShowSnackbar -> {
                    snackbarHostState.showSnackbar(event.message)
                }
                is ProductEvent.NavigateTo -> onNavigate(event.route)
            }
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Produk") },
                actions = {
                    BadgedBox(badge = {
                        if (uiState.cartItemCount > 0) {
                            Badge { Text("${uiState.cartItemCount}") }
                        }
                    }) {
                        IconButton(onClick = { onNavigate("/cart") }) {
                            Icon(Icons.Default.ShoppingCart, "Keranjang")
                        }
                    }
                },
            )
        },
        snackbarHost = { SnackbarHost(snackbarHostState) },
    ) { padding ->
        Column(Modifier.padding(padding)) {
            // Search Bar
            SearchBar(
                query = searchQuery,
                onQueryChange = { viewModel.updateSearch(it) },
                onSearch = {},
                active = false,
                onActiveChange = {},
                modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
                placeholder = { Text("Cari produk...") },
                leadingIcon = { Icon(Icons.Default.Search, "Cari") },
            ) {}

            // Category Chips
            CategoryChips(
                selectedCategory = uiState.selectedCategory,
                onSelectCategory = { viewModel.selectCategory(it) },
            )

            // Product List
            if (uiState.isLoading) {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    CircularProgressIndicator()
                }
            } else {
                LazyColumn(Modifier.fillMaxSize()) {
                    items(uiState.products, key = { it.id }) { product ->
                        ProductCard(
                            product = product,
                            onAddToCart = { viewModel.addToCart(product) },
                        )
                    }
                }
            }
        }
    }
}

8. Pola Repository dengan Flow

Kotlin — Repository Pattern
// ── Network + Database (single source of truth) ──
class ProductRepository @Inject constructor(
    private val api: ProductApi,
    private val dao: ProductDao,
) {
    // Data selalu dari database (offline-first)
    // Network hanya untuk sync
    fun getProducts(
        query: String? = null,
        category: Category? = null,
    ): Flow<List<Product>> = flow {
        // 1. Emit data dari database dulu (cache)
        val cachedProducts = dao.getProducts(query, category)
        emit(cachedProducts)

        // 2. Fetch dari network
        try {
            val networkProducts = api.getProducts(query, category)
            // 3. Simpan ke database
            dao.upsertAll(networkProducts.map { it.toEntity() })
        } catch (e: Exception) {
            // Network error — data cache sudah di-emit
            println("Network error: ${e.message}")
        }

        // 4. Emit data terbaru dari database (setelah update)
        val freshProducts = dao.getProducts(query, category)
        emit(freshProducts)
    }.distinctUntilChanged()

    // ── Room DAO dengan Flow ──
    @Dao
    interface ProductDao {
        @Query("SELECT * FROM products WHERE (:query IS NULL OR name LIKE '%' || :query || '%') AND (:category IS NULL OR category = :category)")
        suspend fun getProducts(query: String?, category: Category?): List<Product>

        // Room mendukung Flow langsung — auto-update saat data berubah
        @Query("SELECT * FROM products")
        fun observeProducts(): Flow<List<ProductEntity>>

        @Upsert
        suspend fun upsertAll(products: List<ProductEntity>)
    }
}

// ── Alternatif: Room Flow langsung ──
class OfflineFirstProductRepository @Inject constructor(
    private val api: ProductApi,
    private val dao: ProductDao,
) {
    // Database sebagai source of truth — otomatis update
    fun getProducts(): Flow<List<Product>> = dao.observeProducts()
        .map { entities -> entities.map { it.toDomain() } }

    // Sync: fetch dari network, update ke database
    suspend fun refresh() {
        try {
            val products = api.getProducts()
            dao.upsertAll(products.map { it.toEntity() })
        } catch (e: Exception) {
            throw RefreshFailedException(e)
        }
    }
}

9. Testing Flow

9.1 Unit Test dengan Turbine

Kotlin — Testing Flow dengan Turbine
// dependencies: testImplementation("app.cash.turbine:turbine:1.0.0")

@Test
fun `products flow emits empty list initially`() = runTest {
    // Given
    val repository = FakeProductRepository()
    val viewModel = ProductViewModel(repository)

    // When & Then
    viewModel.uiState.test {
        val initialState = awaitItem()
        assertEquals(true, initialState.isLoading)

        val loadedState = awaitItem()
        assertEquals(false, loadedState.isLoading)
        assertEquals(emptyList(), loadedState.products)
    }
}

@Test
fun `search filters products`() = runTest {
    // Given
    val repository = FakeProductRepository(
        products = listOf(
            Product("1", "Laptop", Category.ELECTRONICS),
            Product("2", "Phone", Category.ELECTRONICS),
            Product("3", "T-Shirt", Category.CLOTHING),
        )
    )
    val viewModel = ProductViewModel(repository)

    viewModel.uiState.test {
        // Skip initial loading state
        skipItems(2)

        // When: search for "lap"
        viewModel.updateSearch("lap")

        // Then
        val state = awaitItem()
        assertEquals(1, state.products.size)
        assertEquals("Laptop", state.products[0].name)
    }
}

@Test
fun `combine operators work correctly`() = runTest {
    val flow1 = flowOf(1, 2, 3)
    val flow2 = flowOf("A", "B", "C")

    combine(flow1, flow2) { a, b -> "$a$b" }.test {
        // combine emits when any flow emits
        // First: 1+A="1A"
        assertEquals("1A", awaitItem())
        // Then 2 comes: 2+A="2A"
        assertEquals("2A", awaitItem())
        // Then B comes: 2+B="2B"
        assertEquals("2B", awaitItem())
        // Then 3 comes: 3+B="3B"
        assertEquals("3B", awaitItem())
        // Then C comes: 3+C="3C"
        assertEquals("3C", awaitItem())
        awaitComplete()
    }
}

9.2 Testing tanpa Turbine

Kotlin — Testing Flow tanpa Turbine
@Test
fun `search debounce works`() = runTest {
    // Given
    val queryFlow = MutableSharedFlow<String>()
    val results = mutableListOf<List<String>>()

    val searchFlow = queryFlow
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { query ->
            flowOf(listOf("Result for: $query"))
        }

    // When: Collect in background
    val job = launch(UnconfinedTestDispatcher()) {
        searchFlow.collect { results.add(it) }
    }

    // Emit rapid queries
    queryFlow.emit("a")
    queryFlow.emit("ab")
    queryFlow.emit("abc") // Only this should emit (debounce)

    // Advance time to trigger debounce
    advanceTimeBy(400)

    // Then
    assertEquals(1, results.size)
    assertEquals(listOf("Result for: abc"), results[0])

    job.cancel()
}

// ── Using toList() ──
@Test
fun `flow emits expected values`() = runTest {
    val flow = flow {
        emit(1)
        emit(2)
        emit(3)
    }

    val result = flow.toList()
    assertEquals(listOf(1, 2, 3), result)
}

// ── Using first() ──
@Test
fun `flow first value`() = runTest {
    val flow = flow {
        emit("Hello")
        emit("World")
    }

    assertEquals("Hello", flow.first())
}

10. Best Practices & Anti-Patterns

✅ Best Practices
  • Gunakan collectAsStateWithLifecycle() — bukan collectAsState() agar lifecycle-aware
  • Pisahkan State & Events — StateFlow untuk state, SharedFlow untuk one-shot events
  • Gunakan WhileSubscribed(5000) — stop stream 5 detik setelah tidak ada subscriber (hemat resource)
  • Offline-first pattern — database sebagai source of truth, network hanya untuk sync
  • Debounce search — gunakan .debounce(300) untuk input pencarian
  • Gunakan data class untuk statecopy() membuat state update lebih mudah
  • Test dengan Turbine — library testing khusus untuk Flow
⚠️ Anti-Patterns yang Harus Dihindari
  • Collect di luar lifecycle — selalu collect di LaunchedEffect atau collectAsStateWithLifecycle()
  • Gunakan StateFlow untuk events — events akan di-replay saat config change; gunakan SharedFlow
  • Ignore exception — selalu gunakan .catch{} atau try-catch
  • Mutable state expose — selalu expose asStateFlow(), jangan MutableStateFlow
  • Flow di callback non-suspend — gunakan callbackFlow untuk bridge callback-based APIs

11. Quiz Pemahaman

Uji pemahaman Anda tentang Kotlin Flow:

Pertanyaan 1: Apa bedanya Cold Flow dan Hot Flow?

a) Cold Flow lebih cepat
b) Cold Flow mulai emit saat ada collector, Hot Flow emit terus-menerus
c) Hot Flow tidak mendukung operator
d) Tidak ada perbedaan

Pertanyaan 2: Mengapa gunakan collectAsStateWithLifecycle() bukan collectAsState()?

a) Karena lebih cepat
b) Karena lifecycle-aware — stop collect saat Activity di background
c) Karena syntax lebih pendek
d) Karena wajib di Jetpack Compose

Pertanyaan 3: Kapan menggunakan SharedFlow alih-alih StateFlow?

a) Untuk UI state
b) Untuk one-shot events seperti navigasi dan snackbar
c) Untuk data dari database
d) Tidak pernah

Pertanyaan 4: Apa fungsi operator flatMapLatest?

a) Mengambil nilai terakhir dari flow
b) Batalkan flow lama dan mulai flow baru saat upstream emit nilai baru
c) Menggabungkan semua nilai
d) Mengulang flow dari awal

Pertanyaan 5: Apa fungsi stateIn di ViewModel?

a) Mengubah StateFlow menjadi Cold Flow
b) Mengubah Cold Flow menjadi StateFlow yang lifecycle-aware
c) Menyimpan state ke database
d) Membuat flow baru
🔍 Zoom
100%
🎨 Tema