Transform Parte de Android Jetpack.
Transform es una biblioteca compilada en el entorno de ejecución de Compose que ejecuta un bloque @Composable sin una IU para transformar las entradas en State de Compose.
Cada vez que cambia cualquier lectura de State dentro del bloque, este se recompone automáticamente y actualiza el State que se muestra, lo que te permite usar el modelo de seguimiento de dependencias y recomposición de Compose en ViewModels, repositorios o servicios en segundo plano.
Casos de uso
- Transformación de datos reactiva: Convierte flujos de datos sin procesar (como respuestas de red o consultas de bases de datos) en un estado listo para la IU.
- Combina varias entradas: Combina varias fuentes de estado independientes (como la entrada del usuario, la configuración y el estado de la red) en un solo estado coherente sin operadores
Flowcomplejos. - Antirrebote y limitación: Implementa un comportamiento basado en el tiempo (como
el antirrebote de las consultas de búsqueda) con los efectos secundarios estándar de Compose,
como
LaunchedEffect. - Operaciones condicionales: Ejecuta diferentes lógicas de recopilación de estado según
las condiciones actuales con expresiones
if-elseowhenestándar.
Descripción general de la API
El paquete androidx.appstate.transform proporciona las siguientes funciones:
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 transformación
Puedes crear transformaciones por sí solas (con un CoroutineScope proporcionado) o dentro de una composición existente. Los parámetros clave para cualquier transformación son initialValue y onUpdate. La expresión lambda onUpdate recibe el valor de salida anterior R como su parámetro (inicializado en initialValue en la primera composición). Puedes devolver este parámetro para mantener el último estado emitido en lugar de restablecerlo a un estado de carga, como { previous -> flow.collectAsState(initial = null).value ?: previous }.
Por ejemplo, puedes usar transform para observar los cambios en una cadena de consulta de búsqueda y limitar los cálculos costosos, como las llamadas de red:
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 }
En este fragmento, se incluye lo siguiente:
queryestá respaldado por unMutableState, que un elemento componible de la IU de la barra de búsqueda puede leer y actualizar.transformtoma uninitialValuede una lista vacía, unCoroutineScopeyonUpdatecomo una lambda final.- Dentro del bloque
onUpdate,remembermantiene el estado deresultsen todas las recomposiciones mientras se ejecutaLaunchedEffect. LaunchedEffecttomaquerycomo clave. Comoqueryestá respaldado por unMutableState, el efecto se cancela y se vuelve a iniciar cada vez que cambiaquery.- Para evitar llamar a la API en cada pulsación de tecla, el cuerpo del efecto espera 300 ms. Si la consulta cambia antes de que se complete esa demora, se cancela el efecto y no se realiza ninguna llamada de red.
Dentro de una función @Composable, también puedes usar la sobrecarga @Composable transform, que recuerda un CoroutineScope de forma interna y ejecuta onUpdate en Dispatchers.Default de forma predeterminada. Esto es útil cuando quieres descargar una derivación de estado costosa (como filtrar o buscar una lista grande) del subproceso de IU principal:
@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... }
Observa el estado con listener
Cuando quieras reaccionar a los cambios de State de Compose en un componente que no sea de la IU (como un administrador de estado, un repositorio o un servicio en segundo plano) sin producir un State<R> derivado, usa listener.
listener es una función suspend que ejecuta un bloque @Composable () -> Unit hasta que se cancela la corrutina de llamada, lo que recompone automáticamente cada vez que cambia cualquier estado leído dentro de 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... }
En este ejemplo, listener observa session.value. Cuando la sesión está en estado
FocusSession.Running, LaunchedEffect espera hasta endTimeMillis antes de
restablecer la sesión a FocusSession.Idle y publicar una notificación de
finalización. Si el usuario pausa o finaliza la sesión antes de tiempo, la rama Running sale de la composición y cancela el temporizador automáticamente. Cuando se cancela scope, listener deja de observar.
Patrones comunes
En los siguientes patrones, se muestra cómo usar transform para la lógica condicional, combinar varios flujos y producir el estado de la IU en un ViewModel.
Producción de estado condicional
Usa expresiones estándar de Kotlin if-else o when dentro de transform para
cambiar entre fuentes de estado ascendentes según las condiciones actuales. Debido a que el seguimiento de lectura es dinámico, transform solo observa la rama que se ejecuta en la recomposición actual:
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 } }
Cómo combinar varias fuentes de estado
Como Compose hace un seguimiento automático de las lecturas de estado, puedes combinar cualquier cantidad de propiedades State<T> y flujos de Flow en un solo bloque transform. A diferencia de Flow.combine, que solo proporciona sobrecargas con tipo para un máximo de cinco flujos antes de recurrir a un Array<Any?> sin tipo, agregar otra entrada en transform siempre es una sola lectura con seguridad de tipos:
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, ) }
Cómo usar transform en un ViewModel
La sobrecarga de transform no componible ejecuta la lógica reactiva en capas que no son de IU
(como ViewModels o repositorios) mientras se usa el modelo de estado de Compose. Se ejecuta mientras el CoroutineScope proporcionado esté activo, por lo que puedes vincularlo al ciclo de vida de un componente pasando viewModelScope dentro de 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, ) } }
En este ViewModel, uiState lee networkMonitor.isOnline y cambia de forma dinámica entre recopilar mediaRepository.downloadedTracksFlow cuando no hay conexión y mediaRepository.allTracksFlow cuando hay conexión, lo que expone un solo State<HomeUiState> que los elementos componibles de la IU pueden leer directamente. Para obtener más detalles sobre cómo combinar transform con MutableState y SavedStateHandle.saveable, consulta Cómo usar el estado de Compose en un ViewModel.
Elige entre transform, derivedStateOf y Flow
Usa los siguientes lineamientos para elegir entre transform, derivedStateOf y Flow de Kotlin:
derivedStateOf contra transform
Tanto derivedStateOf como transform producen un State<T> derivado que hace un seguimiento automático de los estados de Compose que se leen dentro de su bloque.
Elige entre ellas según si calculas un valor directamente a partir de objetos State<T> existentes o si necesitas funciones de @Composable, como collectAsState, produceState y LaunchedEffect:
| Función | derivedStateOf |
transform |
|---|---|---|
| Tipo de lambda | Lambda estándar de Kotlin (() -> T) |
@Composable lambda (@Composable (R) -> R) |
| Entradas admitidas | Solo lecturas síncronas de State<T> |
State<T>, Flow de Kotlin (con collectAsState), funciones suspend y APIs de devolución de llamada (con produceState) |
| Ejecución y coherencia | Diferida y síncrona cuando se lee .value; se actualiza de forma atómica dentro de la misma instantánea sin un initialValue ni un CoroutineScope |
Ejecuta una composición activa en un CoroutineScope y vuelve a componer de forma asíncrona después de que se confirma una instantánea; requiere un initialValue |
| Estado y efectos secundarios | No se puede llamar a remember ni a las APIs de efectos secundarios |
Admite remember, LaunchedEffect, produceState y funciones @Composable reutilizables |
Usa derivedStateOf cuando todas las entradas ya sean objetos State<T> y no necesites las APIs de @Composable:
- Derivaciones síncronas de la misma instantánea: Se calcula un valor o una lista filtrada a partir de las propiedades
State<T>existentes sin administrar unCoroutineScopeni proporcionar un marcador de posicióninitialValue. Debido a quederivedStateOfse evalúa de forma síncrona cuando se lee.value, los emisores que leen.valueinmediatamente después de actualizar un estado de entrada siempre observan el resultado actualizado en la misma instantánea. - Reducir la frecuencia con la que se actualizan los observadores: Calcular un valor que cambia con menos frecuencia que su fuente
State<T>(como verificar si un desplazamiento supera un umbral). Para obtener más detalles, consulta la documentación dederivedStateOf.
Usa transform cuando tu derivación requiera el entorno de ejecución de Compose:
- Combinación de
State<T>con transmisiones asíncronas: Recopilación de unFlowde Kotlin desde un repositorio o una base de datos concollectAsStatejunto con entradas deState<T>. - Reiniciar el trabajo ascendente cuando cambian las entradas: Usa
remember(key)dentro del bloque para volver a crear unFlowparametrizado solo cuando se actualiza una entradaState<T>específica. - Ejecutar efectos secundarios o recordar el estado intermedio: Usar
LaunchedEffect(por ejemplo, para quitar el rebote de las entradas o ejecutar temporizadores),produceState(para cargar datos de una funciónsuspendo unir una devolución de llamada conawaitDispose) orememberpara mantener el estado en las recomposiciones.
En el siguiente ejemplo, se compara derivedStateOf con un bloque transform que
combina State<T> y un repositorio 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) } }
En este ejemplo, hasSearchQuery solo inspecciona el estado searchQuery, por lo que derivedStateOf lo calcula de forma diferida sin un CoroutineScope. En cambio, uiState combina preferencesManager.showCompleted y searchQuery con un Flow asíncrono de tasksRepository y usa remember(showCompleted) para evitar volver a crear el Flow cuando solo cambia searchQuery.
Cuándo usar Flow en lugar de transform
transform está diseñado para representar el valor actual del estado, no un flujo de eventos. Si un estado cambia varias veces antes de la siguiente recomposición, Compose solo usa el valor más reciente y omite las actualizaciones cuando el valor nuevo es igual (==) al valor anterior. En su lugar, usa Flow de Kotlin cuando tu lógica deba procesar cada valor en orden:
- Totales y promedios acumulados: Los cálculos, como los promedios móviles, las sumas acumulativas o el historial de deshacer (
runningFoldoscan), necesitan que cada valor se cuente una vez. En un bloquetransform, se omiten las actualizaciones rápidas antes de la recomposición, dos lecturas idénticas seguidas (como un sensor que emite10.0dos veces) no activan la recomposición, y las recomposiciones activadas por otros estados en el mismo bloque pueden contar el valor actual dos veces. Primero, calcula el valor de ejecución en unFlowy, luego, recopila el resultado entransformsi necesitas combinarlo con otro estado. - Registro de eventos y colas: Son canalizaciones que registran las interacciones de los usuarios, procesan las solicitudes de red en cola o registran las transacciones en las que se debe registrar cada cambio intermedio (
A -> B -> CoA -> B -> A). - Eventos únicos: Son acciones como reproducir un sonido o enviar un comando de hardware, en las que enviar el mismo comando dos veces seguidas debe ejecutarse dos veces.
transformrequiere uninitialValuey omite los resultados iguales consecutivos. - Flujos sensibles a la contrapresión: Son flujos (como lecturas de archivos o sockets) en los que un productor rápido debe suspenderse cuando un consumidor se queda atrás. Las escrituras de estado de Compose no se suspenden y, en su lugar, reemplazan los valores anteriores.
Transformaciones de prueba
Debido a que las transformaciones dependen del entorno de ejecución de Compose y las corrutinas de Kotlin, probarlas requiere coordinar la ejecución asíncrona y la recomposición de estado. Usa el artefacto androidx.appstate.transform:transform-testing para controlar el programador de corrutinas y activar recomposiciones en las pruebas de unidades.
El paquete androidx.appstate.transform.testing proporciona las siguientes APIs:
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 une runTest y ejecuta tu bloque de prueba con un receptor TransformTestScope:
backgroundScope: Es unCoroutineScopepara ejecutar transformaciones que no deberían bloquear la finalización de la prueba. Se cancela automáticamente cuando finaliza la prueba, lo que detiene la transformación.testDispatcher: Es unTestDispatchervinculado atestCoroutineScheduler. Pásalo al parámetrodispatcherdetransformpara que la recomposición se ejecute en el programador de pruebas en lugar de usarDispatchers.Defaultde forma predeterminada.advance: Ejecuta corrutinas pendientes en el programador de pruebas, envía notificaciones de aplicación de instantáneas de Compose (Snapshot.sendApplyNotifications) y recompone eltransformpara que se actualice elStatede salida.advanceTimeBy(duration)yadvanceUntilIdle: Avanza el reloj virtual entestCoroutineSchedulerpara las operaciones que dependen del tiempo.
Cómo probar las actualizaciones de estado
Para probar un transform, pasa backgroundScope y testDispatcher desde TransformTestScope y, luego, llama a advance después de actualizar un estado de entrada 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) }
Prueba operaciones basadas en el tiempo
Cuando un transform usa efectos secundarios basados en el tiempo, como LaunchedEffect y delay (como la eliminación de rebotes en las búsquedas), combina advanceTimeBy(duration) con advance:
- Después de modificar un estado de entrada, llama a
advancepara que el bloquetransformse vuelva a componer y se inicieLaunchedEffect. - Llama a
advanceTimeBy(duration)para avanzar el reloj virtual y, luego, llama aadvancepara ejecutar las tareas programadas en esa marca de tiempo y volver a componer eltransform.
@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") }
Cómo probar un ViewModel o un contenedor de estado
Debido a que transform ejecuta su composición en el scope proporcionado y establece de forma predeterminada su parámetro dispatcher en Dispatchers.Default, las clases que crean un transform deben aceptar un CoroutineScope y un CoroutineDispatcher como parámetros del constructor. En un ViewModel, pasa el CoroutineScope insertado al constructor ViewModel(coroutineScope) y reenvía el 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, ) ) }
En esta prueba, pasar backgroundScope y testDispatcher a HomeViewModel vincula su composición viewModelScope y transform al programador de pruebas, por lo que cada llamada advance procesa de forma determinística las emisiones de FakeMediaRepository y FakeNetworkMonitor. Para obtener más patrones sobre el uso de SavedStateHandle.saveable y Snapshot.withMutableSnapshot en un ViewModel, consulta Cómo usar el estado de Compose en un ViewModel.