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
Flowyang 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-elseatauwhenstandar.
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:
querydidukung olehMutableState, yang dapat dibaca dan diupdate oleh composable UI kotak penelusuran.transformmenggunakaninitialValuedari daftar kosong,CoroutineScope, danonUpdatesebagai lambda di akhir.- Di dalam blok
onUpdate,remembermempertahankan statusresultsdi seluruh rekomposisi saatLaunchedEffectberjalan. LaunchedEffectmenggunakanquerysebagai kunci. Karenaquerydidukung olehMutableState, efeknya akan dibatalkan dan diluncurkan kembali setiap kaliqueryberubah.- 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 mengelolaCoroutineScopeatau menyediakan placeholderinitialValue. KarenaderivedStateOfmengevaluasi secara sinkron saat.valuedibaca, pemanggil yang membaca.valuesegera 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 dokumentasiderivedStateOf.
Gunakan transform saat turunan Anda memerlukan runtime Compose:
- Menggabungkan
State<T>dengan streaming asinkron: MengumpulkanFlowKotlin dari repositori atau database menggunakancollectAsStatebersama inputState<T>. - Memulai ulang pekerjaan upstream saat input berubah: Menggunakan
remember(key)di dalam blok untuk membuat ulangFlowberparameter hanya saat inputState<T>tertentu diperbarui. - Menjalankan efek samping atau mengingat status perantara: Menggunakan
LaunchedEffect(misalnya, untuk mendebounce input atau menjalankan timer),produceState(untuk memuat data dari fungsisuspendatau menggabungkan callback denganawaitDispose), ataurememberuntuk 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 (
runningFoldatauscan) memerlukan setiap nilai dihitung sekali. Dalam bloktransform, update cepat sebelum rekomposisi akan dilewati, dua pembacaan yang identik berturut-turut (seperti sensor yang memunculkan10.0dua 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 diFlowterlebih dahulu, lalu kumpulkan hasilnya ditransformjika 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 -> CatauA -> 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.
transformmemerlukaninitialValuedan 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:CoroutineScopeuntuk menjalankan transformasi yang tidak boleh memblokir penyelesaian pengujian. Tugas ini otomatis dibatalkan saat pengujian selesai, yang akan menghentikan transformasi.testDispatcher:TestDispatcheryang ditautkan ketestCoroutineScheduler. Teruskan ini ke parameterdispatchertransformagar rekomposisi berjalan di penjadwal pengujian, bukan secara default keDispatchers.Default.advance: Menjalankan coroutine yang tertunda di scheduler pengujian, mengirim notifikasi penerapan snapshot Compose (Snapshot.sendApplyNotifications), dan merekomposisitransformsehingga outputStatediperbarui.advanceTimeBy(duration)danadvanceUntilIdle: Memajukan jam virtual ditestCoroutineScheduleruntuk 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:
- Setelah mengubah status input, panggil
advanceagar bloktransformmelakukan rekomposisi dan memulaiLaunchedEffect. - Panggil
advanceTimeBy(duration)untuk memajukan jam virtual, lalu panggiladvanceuntuk menjalankan tugas yang dijadwalkan pada stempel waktu tersebut dan merekomposisitransform.
@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.