Android Flow

Oke, mari kita buat implementasi lengkap yang menggabungkan semua konsep yang kita bahas: Retrofit untuk ambil data, Flow sebagai pipanya, Sealed Class untuk status UI, dan WhileSubscribed(5000) sebagai satpamnya.

Anggaplah kita mau ambil daftar "Popular Movies".


1. UI State (Sealed Class)

Ini adalah cara modern untuk memberi tahu Compose apa yang harus digambar. Cuma ada 3 kemungkinan: Loading, Sukses (bawa data), atau Error.

sealed class MovieUiState {
    object Loading : MovieUiState()
    data class Success(val movies: List<Movie>) : MovieUiState()
    data class Error(val message: String) : MovieUiState()
}

2. Retrofit API Service

Standar Retrofit, tapi perhatikan kita pakai suspend function.

interface MovieApiService {
    @GET("movie/popular")
    suspend fun getPopularMovies(
        @Query("api_key") apiKey: String
    ): MovieResponse // MovieResponse adalah POJO/Data Class hasil JSON
}

3. Repository (Si Producer Flow)

Di sini tempat kita membuat "Pipa" (Flow). Kita pakai blok flow { ... } dan emit().

class MovieRepository(private val apiService: MovieApiService) {

    fun fetchPopularMovies(): Flow<MovieUiState> = flow {
        emit(MovieUiState.Loading) // 1. Kasih tahu UI lagi loading
        try {
            val response = apiService.getPopularMovies("YOUR_API_KEY")
            emit(MovieUiState.Success(response.results)) // 2. Kirim data kalau sukses
        } catch (e: Exception) {
            emit(MovieUiState.Error(e.message ?: "Unknown Error")) // 3. Kirim error kalau gagal
        }
    }.flowOn(Dispatchers.IO) // Pastikan jalan di background thread (seperti subscribeOn)
}

4. ViewModel (Si Pengelola State)

Ini bagian paling krusial. Kita ubah Flow dari Repository menjadi StateFlow agar bisa dibaca Compose dan tahan banting saat rotasi layar (pakai angka 5000 tadi).

class MovieViewModel(private val repository: MovieRepository) : ViewModel() {

    val uiState: StateFlow<MovieUiState> = repository.fetchPopularMovies()
        .stateIn(
            scope = viewModelScope,
            // Si "Magic Number" 5 detik agar tidak fetch ulang saat rotasi HP
            started = SharingStarted.WhileSubscribed(5000), 
            initialValue = MovieUiState.Loading
        )
}

5. Jetpack Compose (Si Consumer)

Di Compose, kita tinggal "mengamati" (collect) aliran datanya.

@Composable
fun MovieScreen(viewModel: MovieViewModel) {
    // collectAsStateWithLifecycle adalah cara paling aman di Android
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    when (state) {
        is MovieUiState.Loading -> {
            CircularProgressIndicator() // Tampilkan muter-muter
        }
        is MovieUiState.Success -> {
            val movies = (state as MovieUiState.Success).movies
            LazyColumn {
                items(movies) { movie ->
                    Text(text = movie.title)
                }
            }
        }
        is MovieUiState.Error -> {
            val msg = (state as MovieUiState.Error).message
            Text(text = "Waduh Error: $msg", color = Color.Red)
        }
    }
}

Kenapa Struktur Ini "Mantap"?

  1. Anti-Leak: Karena pakai viewModelScope, kalau ViewModel hancur, Flow otomatis berhenti. Tidak ada memori bocor.

  2. Hemat Baterai: Berkat WhileSubscribed(5000), kalau user buka WhatsApp sebentar terus balik lagi, kita nggak perlu panggil API ulang. Tapi kalau user tutup app kelamaan, koneksi diputus otomatis.

  3. Thread Safe: Kamu nggak perlu pusing postValue atau setValue. flowOn(Dispatchers.IO) sudah mengurus perpindahan jalur kabel di balik layar.

  4. Readable: Dengan Sealed Class, kodenya gampang dibaca. "Kalau Loading lakuin ini, kalau Success lakuin itu."

#Mapping

Pilihan yang sangat bagus! Memisahkan Response Model (apa yang datang dari API) dan UI Model (apa yang dibutuhkan layar) adalah standar profesional yang disebut sebagai Data Mapping.

Satu tips penting: Jangan gunakan extend (inheritance). Dalam Kotlin modern, kita lebih suka menggunakan komposisi atau fungsi konversi.

Berikut adalah cara terbaik melakukannya:


1. Kenapa Jangan Pakai Extend?

  • Response Model itu "Kotor": Isinya seringkali mengikuti struktur JSON yang aneh (misal: poster_path, release_date).

  • UI Model itu "Bersih": Kamu hanya butuh data yang mau ditampilkan. Misalnya, daripada menyimpan poster_path (string pendek), kamu lebih baik menyimpan fullUrl yang sudah siap ditampilkan oleh library Image (seperti Coil).

  • Keterikatan: Kalau API berubah, tapi UI Model kamu extend ke Response Model, maka UI kamu bisa ikut rusak/error secara otomatis.


2. Cara Terbaik: Gunakan "Mapper Function"

Buatlah sebuah Extension Function untuk mengubah model API menjadi model UI.

Step 1: Definisi Model

// Model dari API (Sesuai JSON)
data class MovieResponse(
    @SerializedName("id") val id: Int,
    @SerializedName("title") val title: String,
    @SerializedName("poster_path") val posterPath: String?
)

// Model untuk UI (Siap pakai di Compose)
data class MovieUiModel(
    val id: Int,
    val title: String,
    val imageUrl: String // Sudah berbentuk URL lengkap
)

Step 2: Buat Mapper

fun MovieResponse.toUiModel(): MovieUiModel {
    return MovieUiModel(
        id = this.id,
        title = this.title,
        // Kita olah datanya di sini, bukan di Compose!
        imageUrl = "https://image.tmdb.org/t/p/w500${this.posterPath}"
    )
}

3. Di Mana Melakukan Mapping? (Level Repository)

Lakukan mapping di dalam Flow di level Repository. Dengan begitu, ViewModel dan UI sudah menerima data yang "matang".

class MovieRepository(private val api: MovieApiService) {

    fun getPopularMovies(): Flow<List<MovieUiModel>> = flow {
        val response = api.getPopularMovies("API_KEY")
        
        // Kita ubah List<MovieResponse> menjadi List<MovieUiModel>
        val uiModels = response.results.map { it.toUiModel() }
        
        emit(uiModels)
    }.flowOn(Dispatchers.IO)
}

Keuntungan Cara Ini:

  1. UI Lebih Simple: Di Jetpack Compose, kamu tinggal panggil movie.imageUrl. Kamu tidak perlu tahu cara gabungin base URL-nya lagi.

  2. Unit Test Mudah: Kamu bisa mengetes fungsi toUiModel() secara terpisah untuk memastikan logika penggabungan string-nya benar.

  3. Default Value: Jika ada data dari API yang null, kamu bisa langsung kasih nilai default di mapper:

    • title = this.title ?: "No Title"

Analogi:

  • Response Model: Sayuran mentah dari pasar yang masih ada tanahnya.

  • Mapper: Proses mencuci dan memotong sayur.

  • UI Model: Sayur yang sudah siap dimasak/dimakan.

Kamu nggak mau kan masak sayur yang masih ada tanahnya di dapur (Compose)? Lebih baik bersihkan dulu di gudang (Repository/Mapper).

Komentar