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 Flow complessi.
  • 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-else o when.

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 un MutableState, che un composable dell'interfaccia utente della barra di ricerca può leggere e aggiornare.
  • transform accetta un initialValue di un elenco vuoto, un CoroutineScope e onUpdate come lambda finale.
  • All'interno del blocco onUpdate, remember mantiene lo stato results tra le ricomposizioni mentre LaunchedEffect viene eseguito.
  • LaunchedEffect accetta query come chiave. Poiché query è supportato da un MutableState, l'effetto viene annullato e rilanciato ogni volta che query cambia.
  • 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 un CoroutineScope o fornire un segnaposto initialValue. Poiché derivedStateOf viene valutato in modo sincrono quando viene letto .value, i chiamanti che leggono .value immediatamente 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 di derivedStateOf.

Utilizza transform quando la derivazione richiede il runtime di Compose:

  • Combinazione di State<T> con stream asincroni: raccolta di un Flow Kotlin da un repository o un database utilizzando collectAsState insieme agli input State<T>.
  • Riavvio del lavoro upstream quando gli input cambiano: utilizzo di remember(key) all'interno del blocco per ricreare un Flow parametrizzato solo quando un input State<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 funzione suspend o aggregare un callback con awaitDispose) o remember per 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 (runningFold o scan) richiedono che ogni valore venga conteggiato una volta. In un blocco transform, gli aggiornamenti rapidi prima della ricomposizione vengono ignorati, due letture identiche di seguito (ad esempio un sensore che emette 10.0 due 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 un Flow, poi raccogli il risultato in transform se 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 -> C o A -> 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. transform richiede un initialValue e 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: un CoroutineScope per l'esecuzione di trasformazioni che non dovrebbero bloccare il completamento del test. Viene annullato automaticamente al termine del test, interrompendo la trasformazione.
  • testDispatcher: un TestDispatcher collegato a testCoroutineScheduler. Passalo al parametro dispatcher di transform in modo che la ricomposizione venga eseguita sullo scheduler di test anziché utilizzare Dispatchers.Default per impostazione predefinita.
  • advance: esegue le coroutine in attesa sullo scheduler di test, invia notifiche di applicazione degli snapshot di Compose (Snapshot.sendApplyNotifications) e ricompone transform in modo che l'output State venga aggiornato.
  • advanceTimeBy(duration) e advanceUntilIdle: fanno avanzare l'orologio virtuale su testCoroutineScheduler per 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:

  1. Dopo aver modificato uno stato di input, chiama advance in modo che il blocco transform si ricomponga e avvii LaunchedEffect.
  2. Chiama advanceTimeBy(duration) per far avanzare l'orologio virtuale, quindi chiama advance per eseguire le attività pianificate in quel timestamp e ricomporre 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")
}

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.