Transformasi   Bagian dari Android Jetpack.

Transform adalah library yang dibuat di runtime Compose yang menjalankan blok @Composable tanpa UI untuk mengubah input menjadi State Compose. Setiap kali State yang dibaca di dalam blok berubah, blok akan otomatis merekomposisi dan memperbarui State yang ditampilkan—sehingga Anda dapat menggunakan pelacakan dependensi dan model rekomposisi Compose di ViewModel, repositori, atau layanan latar belakang.

Kasus penggunaan

  • Transformasi data reaktif: Mengonversi aliran data mentah (seperti respons jaringan atau kueri database) menjadi status siap UI.
  • Menggabungkan beberapa input: Menggabungkan beberapa sumber status independen (seperti input pengguna, setelan, dan status jaringan) menjadi satu status yang kohesif tanpa operator Flow yang kompleks.
  • Debouncing dan throttling: Terapkan perilaku berbasis waktu (seperti debouncing kueri penelusuran) menggunakan efek samping Compose standar seperti LaunchedEffect.
  • Operasi kondisional: Jalankan logika pengumpulan status yang berbeda berdasarkan kondisi saat ini menggunakan ekspresi if-else atau when standar.

Ringkasan API

Paket androidx.appstate.transform menyediakan fungsi berikut:

package androidx.appstate.transform

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers

public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
    onUpdate: @Composable (R) -> R,
): State<R>

@Composable
public fun <R> transform(
    initialValue: R,
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
    onUpdate: @Composable (R) -> R,
): State<R>

public suspend fun listener(
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
    onUpdate: @Composable () -> Unit,
)

Membuat transformasi

Anda dapat membuat transformasi sendiri (menggunakan CoroutineScope yang disediakan) atau dalam Komposisi yang ada. Parameter utama untuk transformasi apa pun adalah initialValue dan onUpdate. Lambda onUpdate menerima nilai output sebelumnya R sebagai parameternya (diinisialisasi ke initialValue pada komposisi pertama). Anda dapat menampilkan parameter ini untuk mempertahankan status terakhir yang ditampilkan, bukan mereset ke status pemuatan, seperti { previous -> flow.collectAsState(initial = null).value ?: previous }.

Misalnya, Anda dapat menggunakan transform untuk mengamati perubahan pada string kueri penelusuran dan melakukan debounce pada komputasi yang mahal seperti panggilan jaringan:

var query: String by mutableStateOf("")
val searchResults: List<String> by transform<List<String>>(
    initialValue = emptyList(),
    scope = coroutineScope,
) {
    var results by remember { mutableStateOf(emptyList<String>()) }
    LaunchedEffect(query) {
        if (query.isBlank()) {
            results = emptyList()
            return@LaunchedEffect
        }
        delay(300.milliseconds)
        results = api.search(query)
    }
    results
}

Dalam cuplikan ini:

  • query didukung oleh MutableState, yang dapat dibaca dan diupdate oleh composable UI kotak penelusuran.
  • transform menggunakan initialValue dari daftar kosong, CoroutineScope, dan onUpdate sebagai lambda di akhir.
  • Di dalam blok onUpdate, remember mempertahankan status results di seluruh rekomposisi saat LaunchedEffect berjalan.
  • LaunchedEffect menggunakan query sebagai kunci. Karena query didukung oleh MutableState, efeknya akan dibatalkan dan diluncurkan kembali setiap kali query berubah.
  • Untuk menghindari pemanggilan API pada setiap penekanan tombol, isi efek menunggu selama 300 md. Jika kueri berubah sebelum penundaan tersebut selesai, efek akan dibatalkan dan tidak ada panggilan jaringan yang dilakukan.

Di dalam fungsi @Composable, Anda juga dapat menggunakan overload @Composable transform, yang mengingat CoroutineScope secara internal dan menjalankan onUpdate di Dispatchers.Default secara default. Hal ini berguna saat Anda ingin mengurangi beban derivasi status yang mahal (seperti memfilter atau menelusuri daftar besar) dari UI thread utama:

@Composable
fun LogViewerScreen(
    entriesState: State<List<LogEntry>>,
    filterQueryState: State<String>,
) {
    // Calls rememberCoroutineScope() internally and runs onUpdate on
    // Dispatchers.Default so main-thread UI recomposition isn't blocked
    val matchingEntries by transform<List<LogEntry>>(
        initialValue = emptyList(),
        dispatcher = Dispatchers.Default,
    ) {
        val entries by entriesState
        val filterQuery by filterQueryState
        if (filterQuery.isBlank()) {
            entries
        } else {
            entries.filter { entry ->
                entry.matchesQuery(filterQuery)
            }
        }
    }
    // Render matchingEntries...
}

Mengamati status dengan listener

Jika Anda ingin bereaksi terhadap perubahan State Compose dalam komponen non-UI (seperti pengelola status, repositori, atau layanan latar belakang) tanpa menghasilkan State<R> turunan, gunakan listener.

listener adalah fungsi suspend yang menjalankan blok @Composable () -> Unit hingga coroutine pemanggil dibatalkan, yang otomatis merekomposisi setiap kali status yang dibaca di dalam onUpdate berubah:

class FocusSessionManager(
    private val stateStore: StateStore,
    private val notificationManager: NotificationManager,
    scope: CoroutineScope,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)

    init {
        scope.launch {
            listener {
                when (val current = session.value) {
                    is FocusSession.Running -> {
                        LaunchedEffect(current.endTimeMillis) {
                            val remainingMillis =
                                current.endTimeMillis - System.currentTimeMillis()
                            if (remainingMillis > 0) {
                                delay(remainingMillis)
                            }
                            stateStore.setState(FocusSessionKey, FocusSession.Idle)
                            notificationManager.notify(
                                NOTIFICATION_ID,
                                buildSessionCompleteNotification(current.taskId),
                            )
                        }
                    }
                    FocusSession.Idle,
                    is FocusSession.Paused -> Unit
                }
            }
        }
    }
    // startFocus, pauseFocus, resumeFocus, endFocus...
}

Dalam contoh ini, listener mengamati session.value. Saat sesi FocusSession.Running, LaunchedEffect akan menunggu hingga endTimeMillis sebelum mereset sesi ke FocusSession.Idle dan memposting notifikasi penyelesaian. Jika pengguna menjeda atau mengakhiri sesi lebih awal, cabang Running akan keluar dari komposisi dan membatalkan timer secara otomatis. Jika scope dibatalkan, listener akan berhenti mengamati.

Pola umum

Pola berikut menunjukkan cara menggunakan transform untuk logika kondisional, menggabungkan beberapa aliran data, dan menghasilkan status UI dalam ViewModel.

Produksi status bersyarat

Gunakan ekspresi if-else atau when Kotlin standar di dalam transform untuk beralih di antara sumber status upstream berdasarkan kondisi saat ini. Karena pelacakan pembacaan bersifat dinamis, transform hanya mengamati cabang yang berjalan pada rekomposisi saat ini:

var searchQuery by mutableStateOf("")

val displayedTasks: State<List<Task>> = transform(
    initialValue = emptyList(),
    scope = coroutineScope,
) {
    if (searchQuery.isBlank()) {
        val recentTasks by tasksRepository.recentTasks.collectAsState(
            initial = emptyList(),
        )
        recentTasks
    } else {
        val matchingTasks by remember(searchQuery) {
            tasksRepository.searchTasks(searchQuery)
        }.collectAsState(initial = emptyList())
        matchingTasks
    }
}

Menggabungkan beberapa sumber status

Karena Compose otomatis melacak pembacaan status, Anda dapat menggabungkan sejumlah properti State<T> dan aliran data Flow dalam satu blok transform. Tidak seperti Flow.combine, yang hanya menyediakan overload yang diketik hingga lima flow sebelum kembali ke Array<Any?> yang tidak diketik, menambahkan input lain di transform selalu merupakan satu pembacaan yang aman dari jenis:

var showCompleted by mutableStateOf(false)
var selectedCategory by mutableStateOf<Category?>(null)

val dashboardState: State<DashboardUiState> = transform(
    initialValue = DashboardUiState(),
    scope = coroutineScope,
) {
    val tasks by tasksRepository.tasksFlow.collectAsState(initial = emptyList())
    val sortOrder by userPrefsRepository.sortOrderFlow.collectAsState(
        initial = SortOrder.BY_DEADLINE,
    )
    val focusSession by focusSessionManager.session
    val isOnline by networkMonitor.isOnline

    val visibleTasks = tasks
        .filter { task ->
            (showCompleted || !task.isCompleted) &&
                (selectedCategory == null || task.category == selectedCategory)
        }
        .sortedWith(sortOrder.comparator)

    DashboardUiState(
        tasks = visibleTasks,
        selectedCategory = selectedCategory,
        focusSession = focusSession,
        isOnline = isOnline,
    )
}

Menggunakan transform di ViewModel

Overload transform non-composable menjalankan logika reaktif di lapisan non-UI (seperti ViewModel atau repositori) saat menggunakan model status Compose. Fungsi ini berjalan selama CoroutineScope yang disediakan aktif, sehingga Anda dapat mengaitkannya ke siklus proses komponen dengan meneruskan viewModelScope di dalam ViewModel:

class HomeViewModel(
    private val mediaRepository: MediaRepository,
    private val networkMonitor: NetworkMonitor,
) : ViewModel() {
    val uiState: State<HomeUiState> = transform<HomeUiState>(
        initialValue = HomeUiState.Loading,
        scope = viewModelScope,
    ) {
        val isOnline by networkMonitor.isOnline
        val tracks = if (isOnline) {
            mediaRepository.allTracksFlow.collectAsState(initial = null).value
        } else {
            mediaRepository.downloadedTracksFlow
                .collectAsState(initial = null).value
        } ?: return@transform HomeUiState.Loading

        HomeUiState.Success(
            tracks = tracks,
            isOnline = isOnline,
        )
    }
}

Dalam ViewModel ini, uiState membaca networkMonitor.isOnline dan secara dinamis beralih antara mengumpulkan mediaRepository.downloadedTracksFlow saat offline dan mediaRepository.allTracksFlow saat online, sehingga mengekspos satu State<HomeUiState> yang dapat dibaca langsung oleh composable UI. Untuk mengetahui detail selengkapnya tentang cara menggabungkan transform dengan MutableState dan SavedStateHandle.saveable, lihat Menggunakan Status Compose di ViewModel.

Pilih antara transform, derivedStateOf, dan Flow

Gunakan panduan berikut untuk memilih antara transform, derivedStateOf, dan Flow Kotlin:

derivedStateOf versus transform

derivedStateOf dan transform menghasilkan State<T> turunan yang otomatis melacak status Compose yang dibaca di dalam bloknya. Pilih di antara keduanya berdasarkan apakah Anda menghitung nilai langsung dari objek State<T> yang ada atau memerlukan fitur @Composable seperti collectAsState, produceState, dan LaunchedEffect:

Kemampuan derivedStateOf transform
Jenis lambda Lambda Kotlin standar (() -> T) @Composable lambda (@Composable (R) -> R)
Input yang didukung Hanya baca State<T> sinkron State<T>, Flow Kotlin (menggunakan collectAsState), fungsi suspend, dan callback API (menggunakan produceState)
Eksekusi dan konsistensi Lambat dan sinkron saat .value dibaca; diupdate secara atomik dalam snapshot yang sama tanpa initialValue atau CoroutineScope Menjalankan Composition aktif di CoroutineScope dan merekomposisi secara asinkron setelah snapshot di-commit; memerlukan initialValue
Status dan efek samping Tidak dapat memanggil remember atau API efek samping Mendukung fungsi remember, LaunchedEffect, produceState, dan @Composable yang dapat digunakan kembali

Gunakan derivedStateOf jika semua input sudah berupa objek State<T> dan Anda tidak memerlukan API @Composable:

  • Derivasi sinkron, snapshot yang sama: Menghitung nilai atau daftar yang difilter dari properti State<T> yang ada tanpa mengelola CoroutineScope atau menyediakan placeholder initialValue. Karena derivedStateOf mengevaluasi secara sinkron saat .value dibaca, pemanggil yang membaca .value segera setelah memperbarui status input akan selalu mengamati hasil yang diperbarui dalam snapshot yang sama.
  • Mengurangi frekuensi pembaruan observer: Menghitung nilai yang lebih jarang berubah daripada sumbernya State<T> (seperti memeriksa apakah offset scroll melebihi batas). Untuk mengetahui detail selengkapnya, lihat dokumentasi derivedStateOf.

Gunakan transform saat turunan Anda memerlukan runtime Compose:

  • Menggabungkan State<T> dengan streaming asinkron: Mengumpulkan Flow Kotlin dari repositori atau database menggunakan collectAsState bersama input State<T>.
  • Memulai ulang pekerjaan upstream saat input berubah: Menggunakan remember(key) di dalam blok untuk membuat ulang Flow berparameter hanya saat input State<T> tertentu diperbarui.
  • Menjalankan efek samping atau mengingat status perantara: Menggunakan LaunchedEffect (misalnya, untuk mendebounce input atau menjalankan timer), produceState (untuk memuat data dari fungsi suspend atau menggabungkan callback dengan awaitDispose), atau remember untuk mempertahankan status di seluruh rekomposisi.

Contoh berikut membandingkan derivedStateOf dengan blok transform yang menggabungkan State<T> dan Flow repositori:

class TaskListViewModel(
    private val tasksRepository: TasksRepository,
    private val preferencesManager: TaskPreferencesManager,
) : ViewModel() {
    var searchQuery by mutableStateOf("")
        private set

    // Computing a value from existing State<T>: use derivedStateOf
    val hasSearchQuery: State<Boolean> = derivedStateOf {
        searchQuery.isNotBlank()
    }

    // Combining State<T> with a Flow and remember(key): use transform
    val uiState: State<TaskListUiState> = transform<TaskListUiState>(
        initialValue = TaskListUiState.Loading,
        scope = viewModelScope,
    ) {
        val showCompleted by preferencesManager.showCompleted
        val tasks by remember(showCompleted) {
            tasksRepository.observeTasks(showCompleted)
        }.collectAsState(initial = null)

        val currentTasks = tasks ?: return@transform TaskListUiState.Loading
        val filteredTasks = currentTasks.filter { task ->
            task.title.contains(searchQuery, ignoreCase = true)
        }
        TaskListUiState.Success(filteredTasks)
    }
}

Dalam contoh ini, hasSearchQuery hanya memeriksa status searchQuery, sehingga derivedStateOf menghitungnya secara lambat tanpa CoroutineScope. Sebaliknya, uiState menggabungkan preferencesManager.showCompleted dan searchQuery dengan Flow asinkron dari tasksRepository dan menggunakan remember(showCompleted) untuk menghindari pembuatan ulang Flow saat hanya searchQuery yang berubah.

Kapan harus menggunakan Flow, bukan transform

transform dirancang untuk merepresentasikan nilai saat ini dari status, bukan aliran peristiwa. Jika status berubah beberapa kali sebelum rekomposisi berikutnya, Compose hanya akan menggunakan nilai terbaru, dan akan melewati update jika nilai baru sama dengan (==) nilai sebelumnya. Gunakan Flow Kotlin sebagai gantinya jika logika Anda harus memproses setiap nilai secara berurutan:

  • Total dan rata-rata berjalan: Penghitungan seperti rata-rata bergerak, jumlah kumulatif, atau histori urungkan (runningFold atau scan) memerlukan setiap nilai dihitung sekali. Dalam blok transform, update cepat sebelum rekomposisi akan dilewati, dua pembacaan yang identik berturut-turut (seperti sensor yang memunculkan 10.0 dua kali) tidak akan memicu rekomposisi, dan rekomposisi yang dipicu oleh status lain dalam blok yang sama dapat menghitung nilai saat ini dua kali. Hitung nilai yang berjalan di Flow terlebih dahulu, lalu kumpulkan hasilnya di transform jika Anda perlu menggabungkannya dengan status lain.
  • Logging dan antrean peristiwa: Pipeline yang mencatat interaksi pengguna ke dalam log, memproses permintaan jaringan yang diantrekan, atau mencatat transaksi yang setiap perubahan menengahnya (A -> B -> C atau A -> B -> A) harus dicatat.
  • Peristiwa satu kali: Tindakan seperti memutar suara atau mengirim perintah hardware, yang jika perintah yang sama dikirim dua kali berturut-turut, perintah tersebut harus dijalankan dua kali. transform memerlukan initialValue dan melewati output yang sama secara berturut-turut.
  • Streaming yang sensitif terhadap tekanan balik: Streaming (seperti pembacaan file atau soket) tempat produsen yang cepat harus ditangguhkan saat konsumen tertinggal. Penulisan status Compose tidak ditangguhkan dan akan menimpa nilai sebelumnya.

Menguji transformasi

Karena transformasi mengandalkan runtime Compose dan Coroutine Kotlin, pengujiannya memerlukan koordinasi eksekusi asinkron dan rekomposisi status. Gunakan artefak androidx.appstate.transform:transform-testing untuk mengontrol penjadwal coroutine dan memicu rekomposisi dalam pengujian unit.

Paket androidx.appstate.transform.testing menyediakan API berikut:

package androidx.appstate.transform.testing

import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestResult

public fun transformTest(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend TransformTestScope.() -> Unit,
): TestResult

public sealed interface TransformTestScope : CoroutineScope {
    public val testCoroutineScheduler: TestCoroutineScheduler
    public val testDispatcher: TestDispatcher
    public val backgroundScope: CoroutineScope
    public fun advance()
    public fun advanceTimeBy(duration: Duration)
    public fun advanceUntilIdle()
}

transformTest menggabungkan runTest dan mengeksekusi blok pengujian Anda dengan penerima TransformTestScope:

  • backgroundScope: CoroutineScope untuk menjalankan transformasi yang tidak boleh memblokir penyelesaian pengujian. Tugas ini otomatis dibatalkan saat pengujian selesai, yang akan menghentikan transformasi.
  • testDispatcher: TestDispatcher yang ditautkan ke testCoroutineScheduler. Teruskan ini ke parameter dispatcher transform agar rekomposisi berjalan di penjadwal pengujian, bukan secara default ke Dispatchers.Default.
  • advance: Menjalankan coroutine yang tertunda di scheduler pengujian, mengirim notifikasi penerapan snapshot Compose (Snapshot.sendApplyNotifications), dan merekomposisi transform sehingga output State diperbarui.
  • advanceTimeBy(duration) dan advanceUntilIdle: Memajukan jam virtual di testCoroutineScheduler untuk operasi yang bergantung pada waktu.

Menguji perubahan status

Untuk menguji transform, teruskan backgroundScope dan testDispatcher dari TransformTestScope, lalu panggil advance setelah memperbarui status input atau Flow:

@Test
fun testStateUpdate() = transformTest {
    var sourceState by mutableStateOf(0)

    val state = transform(
        initialValue = 0,
        scope = backgroundScope,
        dispatcher = testDispatcher,
    ) {
        sourceState
    }
    assertThat(state.value).isEqualTo(0)

    // Modify the source state and advance to trigger recomposition.
    sourceState = 1
    advance()

    assertThat(state.value).isEqualTo(1)
}

Menguji operasi berbasis waktu

Jika transform menggunakan efek samping berbasis waktu seperti LaunchedEffect dan delay (seperti kueri penelusuran debouncing), gabungkan advanceTimeBy(duration) dengan advance:

  1. Setelah mengubah status input, panggil advance agar blok transform melakukan rekomposisi dan memulai LaunchedEffect.
  2. Panggil advanceTimeBy(duration) untuk memajukan jam virtual, lalu panggil advance untuk menjalankan tugas yang dijadwalkan pada stempel waktu tersebut dan merekomposisi transform.

@Test
fun testDebouncedSearch() = transformTest {
    var query by mutableStateOf("")

    val searchResults = transform<List<String>>(
        initialValue = emptyList(),
        scope = backgroundScope,
        dispatcher = testDispatcher,
    ) {
        var results by remember { mutableStateOf(emptyList<String>()) }
        LaunchedEffect(query) {
            if (query.isBlank()) {
                results = emptyList()
                return@LaunchedEffect
            }
            delay(300.milliseconds)
            results = api.search(query)
        }
        results
    }

    query = "compose"
    // Recompose to start the LaunchedEffect for the new query.
    advance()

    // Results don't update before the 300ms debounce delay completes.
    advanceTimeBy(200.milliseconds)
    advance()
    assertThat(searchResults.value).isEmpty()

    // Advance past the remaining 100ms and trigger recomposition.
    advanceTimeBy(100.milliseconds)
    advance()
    assertThat(searchResults.value).containsExactly("Compose State", "Compose UI")
}

Menguji ViewModel atau holder status

Karena transform menjalankan Komposisinya di scope yang disediakan dan menetapkan parameter dispatcher-nya secara default ke Dispatchers.Default, class yang membuat transform harus menerima CoroutineScope dan CoroutineDispatcher sebagai parameter konstruktor. Dalam ViewModel, teruskan CoroutineScope yang diinjeksikan ke konstruktor ViewModel(coroutineScope) dan teruskan CoroutineDispatcher ke transform:

class HomeViewModel(
    private val mediaRepository: MediaRepository,
    private val networkMonitor: NetworkMonitor,
    coroutineScope: CoroutineScope =
        CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
    dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel(coroutineScope) {
    val uiState: State<HomeUiState> = transform<HomeUiState>(
        initialValue = HomeUiState.Loading,
        scope = viewModelScope,
        dispatcher = dispatcher,
    ) {
        val isOnline by networkMonitor.isOnline
        val tracks = if (isOnline) {
            mediaRepository.allTracksFlow.collectAsState(initial = null).value
        } else {
            mediaRepository.downloadedTracksFlow
                .collectAsState(initial = null).value
        } ?: return@transform HomeUiState.Loading

        HomeUiState.Success(
            tracks = tracks,
            isOnline = isOnline,
        )
    }
}

@Test
fun testHomeViewModelOfflineState() = transformTest {
    val mediaRepository = FakeMediaRepository()
    val networkMonitor = FakeNetworkMonitor()
    val viewModel = HomeViewModel(
        mediaRepository = mediaRepository,
        networkMonitor = networkMonitor,
        coroutineScope = backgroundScope,
        dispatcher = testDispatcher,
    )

    val downloadedTrack = Track(id = "1", isDownloaded = true)
    val streamingTrack = Track(id = "2", isDownloaded = false)
    mediaRepository.emitAllTracks(listOf(downloadedTrack, streamingTrack))
    mediaRepository.emitDownloadedTracks(listOf(downloadedTrack))
    advance()
    assertThat(viewModel.uiState.value).isEqualTo(
        HomeUiState.Success(
            tracks = listOf(downloadedTrack, streamingTrack),
            isOnline = true,
        )
    )

    // Switch offline and verify it switches to downloadedTracksFlow.
    networkMonitor.setOnline(false)
    // First advance() recomposes into the offline branch (where collectAsState
    // initially returns null -> Loading) and starts collecting downloadedTracksFlow.
    advance()
    // Second advance() applies the collected downloadedTracksFlow value and
    // recomposes uiState with the offline Success state.
    advance()
    assertThat(viewModel.uiState.value).isEqualTo(
        HomeUiState.Success(
            tracks = listOf(downloadedTrack),
            isOnline = false,
        )
    )
}

Dalam pengujian ini, meneruskan backgroundScope dan testDispatcher ke HomeViewModel akan mengikat Composition viewModelScope dan transform ke penjadwal pengujian, sehingga setiap panggilan advance secara deterministik memproses emisi dari FakeMediaRepository dan FakeNetworkMonitor. Untuk pola lainnya tentang penggunaan SavedStateHandle.saveable dan Snapshot.withMutableSnapshot dalam ViewModel, lihat Menggunakan Status Compose di ViewModel.