Trasforma Parte di Android Jetpack.
Transform è una libreria basata sul runtime di Compose che esegue un blocco @Composable senza UI per trasformare gli input in State di Compose.
Ogni volta che un State letto all'interno del blocco cambia, il blocco si ricompone e aggiorna automaticamente il State restituito, consentendoti di utilizzare il modello di monitoraggio delle dipendenze e di ricomposizione di Compose in ViewModel, repository o servizi in background.
Casi d'uso
- Trasformazione dei dati reattiva: converti gli stream di dati non elaborati (come le risposte di rete o le query del database) in uno stato pronto per l'UI.
- Combina più input: unisci più origini di stato indipendenti (come input utente, impostazioni e stato della rete) in un unico stato coeso senza operatori
Flowcomplessi. - Debouncing e throttling: implementa un comportamento basato sul tempo (ad esempio il debouncing delle query di ricerca) utilizzando gli effetti collaterali standard di Compose come
LaunchedEffect. - Operazioni condizionali: esegui una logica di raccolta dello stato diversa in base alle condizioni attuali utilizzando espressioni standard
if-elseowhen.
Panoramica dell'API
Il pacchetto androidx.appstate.transform fornisce le seguenti funzioni:
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,
)
Crea una trasformazione
Puoi creare trasformazioni da sole (utilizzando un CoroutineScope fornito) o all'interno di una composizione esistente. I parametri chiave per qualsiasi trasformazione sono initialValue e onUpdate. La funzione lambda onUpdate riceve il valore di output precedente R come parametro (inizializzato su initialValue nella prima composizione). Puoi restituire questo parametro per mantenere l'ultimo stato emesso anziché ripristinare uno stato di caricamento, ad esempio { previous -> flow.collectAsState(initial = null).value ?: previous }.
Ad esempio, puoi utilizzare transform per osservare le modifiche a una stringa di query di ricerca e ridurre il numero di calcoli onerosi, come le chiamate di rete:
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 }
In questo snippet:
queryè supportato da unMutableState, che un composable dell'interfaccia utente della barra di ricerca può leggere e aggiornare.transformaccetta uninitialValuedi un elenco vuoto, unCoroutineScopeeonUpdatecome lambda finale.- All'interno del blocco
onUpdate,remembermantiene lo statoresultstra le ricomposizioni mentreLaunchedEffectviene eseguito. LaunchedEffectaccettaquerycome chiave. Poichéqueryè supportato da unMutableState, l'effetto viene annullato e rilanciato ogni volta chequerycambia.- Per evitare di chiamare l'API a ogni pressione di un tasto, il corpo dell'effetto attende 300 ms. Se la query cambia prima del completamento di questo ritardo, l'effetto viene annullato e non viene effettuata alcuna chiamata di rete.
All'interno di una funzione @Composable, puoi anche utilizzare l'overload @Composable transform, che memorizza internamente un CoroutineScope ed esegue onUpdate su Dispatchers.Default per impostazione predefinita. Questo è utile quando vuoi scaricare una derivazione di stato costosa (come il filtro o la ricerca in un elenco di grandi dimensioni) dal thread dell'interfaccia utente principale:
@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... }
Osserva lo stato con listener
Quando vuoi reagire alle modifiche di Compose State in un componente non UI (come un gestore di stato, un repository o un servizio in background) senza produrre un State<R> derivato, utilizza listener.
listener è una funzione suspend che esegue un blocco @Composable () -> Unit
fino a quando la coroutine chiamante non viene annullata, ricomponendo automaticamente ogni volta che cambia uno stato letto all'interno di onUpdate:
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... }
In questo esempio, listener osserva session.value. Quando la sessione è
FocusSession.Running, LaunchedEffect attende fino alle ore endTimeMillis prima di
reimpostare la sessione su FocusSession.Idle e pubblicare una notifica di completamento. Se l'utente mette in pausa o termina la sessione in anticipo, il ramo Running
esce dalla composizione e annulla automaticamente il timer. Quando scope viene annullato, listener interrompe l'osservazione.
Pattern comuni
I seguenti pattern mostrano come utilizzare transform per la logica condizionale, la combinazione di più stream e la produzione dello stato della UI in un ViewModel.
Produzione in stato condizionale
Utilizza le espressioni Kotlin standard if-else o when all'interno di transform per passare da un'origine di stato upstream all'altra in base alle condizioni attuali. Poiché il monitoraggio della lettura è dinamico, transform osserva solo il ramo eseguito nella ricomposizione corrente:
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 } }
Combina più origini di stato
Poiché Compose monitora automaticamente le letture dello stato, puoi combinare un numero qualsiasi di proprietà State<T> e stream Flow in un unico blocco transform. A differenza di Flow.combine, che fornisce overload tipizzati solo per un massimo di cinque flussi prima di tornare a un Array<Any?> non tipizzato, l'aggiunta di un altro input in transform è sempre una singola lettura sicura dei tipi:
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, ) }
Utilizza transform in un ViewModel
L'overload transform non componibile esegue la logica reattiva in livelli non UI
(come ViewModel o repository) utilizzando il modello di stato di Compose. Viene eseguito finché l'oggetto CoroutineScope fornito è attivo, quindi puoi associarlo al ciclo di vita di un componente passando viewModelScope all'interno di un 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, ) } }
In questo ViewModel, uiState legge networkMonitor.isOnline e passa dinamicamente dalla raccolta di mediaRepository.downloadedTracksFlow quando è offline alla raccolta di mediaRepository.allTracksFlow quando è online, esponendo un singolo State<HomeUiState> che i componibili dell'interfaccia utente possono leggere direttamente. Per maggiori dettagli sulla combinazione di transform con MutableState e SavedStateHandle.saveable, consulta Utilizza lo stato di Compose in un ViewModel.
Scegli tra transform, derivedStateOf e Flow
Utilizza le seguenti linee guida per scegliere tra transform, derivedStateOf
e Kotlin Flow:
derivedStateOf contro transform
Sia derivedStateOf che transform producono un State<T> derivato che monitora automaticamente gli stati di Compose letti all'interno del suo blocco.
Scegli tra queste opzioni in base al fatto che tu stia calcolando un valore direttamente da oggetti State<T> esistenti o che tu abbia bisogno di funzionalità @Composable come collectAsState, produceState e LaunchedEffect:
| Capacità | derivedStateOf |
transform |
|---|---|---|
| Tipo Lambda | Lambda Kotlin standard (() -> T) |
@Composable lambda (@Composable (R) -> R) |
| Input supportati | Solo letture State<T> sincrone |
State<T>, Kotlin Flow (utilizzando collectAsState), funzioni suspend e API callback (utilizzando produceState) |
| Esecuzione e coerenza | Lazy e sincrono quando viene letto .value; aggiornamenti atomici all'interno dello stesso snapshot senza initialValue o CoroutineScope |
Esegue una composizione attiva in un CoroutineScope e la ricompone in modo asincrono dopo il commit di uno snapshot; richiede un initialValue |
| Stato ed effetti collaterali | Impossibile chiamare remember o API con effetti collaterali |
Supporta le funzioni remember, LaunchedEffect, produceState e @Composable riutilizzabili |
Utilizza derivedStateOf quando tutti gli input sono già oggetti State<T> e non hai bisogno delle API @Composable:
- Derivazioni sincrone con lo stesso snapshot: calcolo di un valore o di un elenco filtrato
da proprietà
State<T>esistenti senza gestire unCoroutineScopeo fornire un segnapostoinitialValue. PoichéderivedStateOfviene valutato in modo sincrono quando viene letto.value, i chiamanti che leggono.valueimmediatamente dopo l'aggiornamento di uno stato di input osservano sempre il risultato aggiornato nello stesso snapshot. - Riduzione della frequenza di aggiornamento degli osservatori: calcolo di un valore che cambia meno spesso rispetto alla sua origine
State<T>(ad esempio, controllo se un offset di scorrimento supera una soglia). Per ulteriori dettagli, consulta la documentazione diderivedStateOf.
Utilizza transform quando la derivazione richiede il runtime di Compose:
- Combinazione di
State<T>con stream asincroni: raccolta di unFlowKotlin da un repository o un database utilizzandocollectAsStateinsieme agli inputState<T>. - Riavvio del lavoro upstream quando gli input cambiano: utilizzo di
remember(key)all'interno del blocco per ricreare unFlowparametrizzato solo quando un inputState<T>specifico viene aggiornato. - Esecuzione di effetti collaterali o memorizzazione dello stato intermedio: utilizzo di
LaunchedEffect(ad esempio per eliminare i rimbalzi degli input o eseguire timer),produceState(per caricare dati da una funzionesuspendo aggregare un callback conawaitDispose) orememberper mantenere lo stato tra le ricomposizioni.
L'esempio seguente confronta derivedStateOf con un blocco transform che combina State<T> e un repository Flow:
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) } }
In questo esempio, hasSearchQuery controlla solo lo stato searchQuery, quindi derivedStateOf lo calcola in modo lazy senza un CoroutineScope. Al contrario, uiState combina preferencesManager.showCompleted e searchQuery con un Flow asincrono da tasksRepository e utilizza remember(showCompleted) per evitare di ricreare Flow quando cambia solo searchQuery.
Quando utilizzare Flow anziché transform
transform è progettato per rappresentare il valore corrente dello stato, non un flusso di eventi. Se uno stato cambia più volte prima della ricomposizione successiva, Compose utilizza solo l'ultimo valore e salta gli aggiornamenti quando il nuovo valore è uguale (==) al valore precedente. Utilizza invece Kotlin Flow
quando la logica deve elaborare ogni valore in ordine:
- Totali e medie progressivi: calcoli come medie mobili, somme cumulative o cronologia di annullamento (
runningFoldoscan) richiedono che ogni valore venga conteggiato una volta. In un bloccotransform, gli aggiornamenti rapidi prima della ricomposizione vengono ignorati, due letture identiche di seguito (ad esempio un sensore che emette10.0due volte) non attivano la ricomposizione e le ricomposizioni attivate da altri stati nello stesso blocco possono conteggiare il valore corrente due volte. Calcola prima il valore corrente in unFlow, poi raccogli il risultato intransformse devi combinarlo con un altro stato. - Logging degli eventi e code: pipeline che registrano le interazioni degli utenti, elaborano le richieste di rete in coda o registrano le transazioni in cui deve essere registrata ogni modifica intermedia (
A -> B -> CoA -> B -> A). - Eventi una tantum: azioni come la riproduzione di un suono o l'invio di un comando hardware, in cui l'invio dello stesso comando due volte di seguito deve essere eseguito due volte.
transformrichiede uninitialValuee salta gli output uguali consecutivi. - Stream sensibili alla contropressione: stream (come letture di file o socket) in cui un producer veloce deve sospendere l'attività quando un consumer rimane indietro. Le scritture dello stato di Compose non vengono sospese e sovrascrivono i valori precedenti.
Test della trasformazione
Poiché le trasformazioni si basano sul runtime di Compose e sulle coroutine Kotlin, per testarle è necessario coordinare l'esecuzione asincrona e la ricomposizione dello stato. Utilizza l'artefatto androidx.appstate.transform:transform-testing per controllare lo scheduler delle coroutine e attivare le ricomposizioni nei test delle unità.
Il pacchetto androidx.appstate.transform.testing fornisce le seguenti API:
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 aggrega runTest ed esegue il blocco di test con un ricevitore TransformTestScope:
backgroundScope: unCoroutineScopeper l'esecuzione di trasformazioni che non dovrebbero bloccare il completamento del test. Viene annullato automaticamente al termine del test, interrompendo la trasformazione.testDispatcher: unTestDispatchercollegato atestCoroutineScheduler. Passalo al parametrodispatcherditransformin modo che la ricomposizione venga eseguita sullo scheduler di test anziché utilizzareDispatchers.Defaultper impostazione predefinita.advance: esegue le coroutine in attesa sullo scheduler di test, invia notifiche di applicazione degli snapshot di Compose (Snapshot.sendApplyNotifications) e ricomponetransformin modo che l'outputStatevenga aggiornato.advanceTimeBy(duration)eadvanceUntilIdle: fanno avanzare l'orologio virtuale sutestCoroutineSchedulerper le operazioni che dipendono dal tempo.
Testa gli aggiornamenti dello stato
Per testare un transform, passa backgroundScope e testDispatcher da
TransformTestScope, quindi chiama advance dopo aver aggiornato uno stato di input o
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) }
Testa le operazioni basate sul tempo
Quando un transform utilizza effetti collaterali basati sul tempo come LaunchedEffect e
delay (ad esempio, il debouncing delle query di ricerca), combina advanceTimeBy(duration)
con advance:
- Dopo aver modificato uno stato di input, chiama
advancein modo che il bloccotransformsi ricomponga e avviiLaunchedEffect. - Chiama
advanceTimeBy(duration)per far avanzare l'orologio virtuale, quindi chiamaadvanceper eseguire le attività pianificate in quel timestamp e ricomporretransform.
@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") }
Testa un ViewModel o un contenitore di stato
Poiché transform esegue la sua composizione nel scope fornito e imposta il parametro dispatcher su Dispatchers.Default per impostazione predefinita, le classi che creano un transform devono accettare un CoroutineScope e un CoroutineDispatcher come parametri del costruttore. In un ViewModel, passa l'oggetto CoroutineScope inserito al costruttore ViewModel(coroutineScope) e inoltra l'oggetto CoroutineDispatcher a 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, ) ) }
In questo test, il passaggio di backgroundScope e testDispatcher a HomeViewModel associa la relativa composizione viewModelScope e transform allo scheduler di test, in modo che ogni chiamata advance elabori in modo deterministico le emissioni da FakeMediaRepository e FakeNetworkMonitor. Per altri pattern sull'utilizzo di SavedStateHandle.saveable e Snapshot.withMutableSnapshot in un ViewModel, consulta Utilizza lo stato di Compose in un ViewModel.