Transformar   Parte do Android Jetpack.

Transform é uma biblioteca criada no ambiente de execução do Compose que executa um bloco @Composable sem uma interface para transformar entradas em State do Compose. Sempre que qualquer leitura de State dentro do bloco mudar, o bloco vai ser recomposto automaticamente e vai atualizar o State retornado, permitindo que você use o modelo de rastreamento de dependência e recomposição do Compose em ViewModels, repositórios ou serviços em segundo plano.

Casos de uso

  • Transformação de dados reativa: converta fluxos de dados brutos (como respostas de rede ou consultas de banco de dados) em um estado pronto para a interface.
  • Combinar várias entradas: mescle várias fontes de estado independentes (como entrada do usuário, configurações e status da rede) em um único estado coeso sem operadores Flow complexos.
  • Debouncing e limitação: implemente o comportamento baseado em tempo (como debouncing de consultas de pesquisa) usando efeitos colaterais padrão do Compose, como LaunchedEffect.
  • Operações condicionais: execute diferentes lógicas de coleta de estado com base nas condições atuais usando expressões padrão if-else ou when.

Visão geral da API

O pacote androidx.appstate.transform oferece as seguintes funções:

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,
)

Criar uma transformação

É possível criar transformações por conta própria (usando um CoroutineScope fornecido) ou em uma composição atual. Os parâmetros principais de qualquer transformação são initialValue e onUpdate. A lambda onUpdate recebe o valor de saída anterior R como parâmetro (inicializado como initialValue na primeira composição). É possível retornar esse parâmetro para manter o último estado emitido em vez de redefinir para um estado de carregamento, como { previous -> flow.collectAsState(initial = null).value ?: previous }.

Por exemplo, é possível usar transform para observar mudanças em uma string de consulta de pesquisa e reduzir cálculos caros, como chamadas de rede:

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
}

Neste snippet:

  • query é apoiado por um MutableState, que pode ser lido e atualizado por um elemento combinável de interface da barra de pesquisa.
  • transform usa um initialValue de uma lista vazia, um CoroutineScope e onUpdate como uma lambda final.
  • No bloco onUpdate, remember mantém o estado de results nas recomposições enquanto LaunchedEffect é executado.
  • LaunchedEffect usa query como chave. Como query é apoiado por um MutableState, o efeito é cancelado e reiniciado sempre que query muda.
  • Para evitar chamar a API a cada pressionamento de tecla, o corpo do efeito aguarda 300 ms. Se a consulta mudar antes que esse atraso seja concluído, o efeito será cancelado e nenhuma chamada de rede será feita.

Em uma função @Composable, também é possível usar a sobrecarga @Composable transform, que lembra um CoroutineScope internamente e executa onUpdate em Dispatchers.Default por padrão. Isso é útil quando você quer descarregar uma derivação de estado cara (como filtrar ou pesquisar uma lista grande) da linha de execução de interface 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...
}

Observar o estado com listener

Quando você quiser reagir às mudanças de State do Compose em um componente que não seja de interface (como um gerenciador de estado, repositório ou serviço em segundo plano) sem produzir um State<R> derivado, use listener.

listener é uma função suspend que executa um bloco @Composable () -> Unit até que a corrotina de chamada seja cancelada, fazendo a recomposição automática sempre que qualquer estado lido dentro de onUpdate mude:

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...
}

Neste exemplo, listener observa session.value. Quando a sessão está FocusSession.Running, LaunchedEffect aguarda até endTimeMillis antes de redefinir a sessão para FocusSession.Idle e postar uma notificação de conclusão. Se o usuário pausar ou encerrar a sessão antes do tempo, a ramificação Running vai sair da composição e cancelar o timer automaticamente. Quando scope é cancelado, listener para de observar.

Padrões comuns

Os padrões a seguir mostram como usar transform para lógica condicional, combinar vários fluxos e produzir o estado da interface em um ViewModel.

Produção de estado condicional

Use expressões padrão if-else ou when do Kotlin dentro de transform para alternar entre as fontes de estado upstream com base nas condições atuais. Como o rastreamento de leitura é dinâmico, o transform observa apenas a ramificação que é executada na recomposição atual:

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
    }
}

Combinar várias fontes de estado

Como o Compose rastreia automaticamente as leituras de estado, é possível combinar qualquer número de propriedades State<T> e streams Flow em um único bloco transform. Ao contrário de Flow.combine, que só fornece sobrecargas tipadas para até cinco fluxos antes de voltar para um Array<Any?> não tipado, adicionar outra entrada em transform é sempre uma única leitura com segurança 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,
    )
}

Usar transform em um ViewModel

A sobrecarga não combinável de transform executa a lógica reativa em camadas que não são de IU (como ViewModels ou repositórios) ao usar o modelo de estado do Compose. Ele é executado enquanto o CoroutineScope fornecido estiver ativo. Assim, é possível vinculá-lo ao ciclo de vida de um componente transmitindo viewModelScope dentro de um 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,
        )
    }
}

Nesse ViewModel, o uiState lê o networkMonitor.isOnline e alterna dinamicamente entre a coleta de mediaRepository.downloadedTracksFlow quando off-line e de mediaRepository.allTracksFlow quando on-line, expondo um único State<HomeUiState> que os elementos combináveis da interface podem ler diretamente. Para mais detalhes sobre como combinar transform com MutableState e SavedStateHandle.saveable, consulte Usar o estado do Compose em um ViewModel.

Escolha entre transform, derivedStateOf e Flow

Use as diretrizes a seguir para escolher entre transform, derivedStateOf e Flow do Kotlin:

derivedStateOf x transform

Tanto derivedStateOf quanto transform produzem um State<T> derivado que rastreia automaticamente os estados do Compose lidos no bloco. Escolha entre eles com base em se você está calculando um valor diretamente de objetos State<T> existentes ou se precisa de recursos do @Composable, como collectAsState, produceState e LaunchedEffect:

Capacidade derivedStateOf transform
Tipo lambda Lambda padrão do Kotlin (() -> T) @Composable lambda (@Composable (R) -> R)
Entradas compatíveis Somente leituras síncronas de State<T> State<T>, Kotlin Flow (usando collectAsState), funções suspend e APIs de callback (usando produceState)
Execução e consistência Preguiçoso e síncrono quando .value é lido; atualizações atômicas no mesmo snapshot sem um initialValue ou CoroutineScope Executa uma composição ativa em um CoroutineScope e faz a recomposição de forma assíncrona após a confirmação de um snapshot. Requer um initialValue.
Estado e efeitos colaterais Não é possível chamar APIs remember ou de efeito colateral Oferece suporte a funções remember, LaunchedEffect, produceState e @Composable reutilizáveis

Use derivedStateOf quando todas as entradas já forem objetos State<T> e você não precisar de APIs @Composable:

  • Derivações síncronas do mesmo snapshot: computar um valor ou uma lista filtrada de propriedades State<T> atuais sem gerenciar um CoroutineScope ou fornecer um marcador de posição initialValue. Como derivedStateOf é avaliado de forma síncrona quando .value é lido, os autores de chamada que leem .value imediatamente depois de atualizar um estado de entrada sempre observam o resultado atualizado no mesmo snapshot.
  • Reduzir a frequência de atualização dos observadores: calcular um valor que muda com menos frequência do que a origem State<T> (por exemplo, verificar se um deslocamento de rolagem excede um limite). Para mais detalhes, consulte a documentação do derivedStateOf.

Use transform quando a derivação exigir o ambiente de execução do Compose:

  • Combinar State<T> com streams assíncronos: coletar um Flow do Kotlin de um repositório ou banco de dados usando collectAsState com entradas State<T>.
  • Reiniciar o trabalho upstream quando as entradas mudarem: use remember(key) dentro do bloco para recriar um Flow parametrizado somente quando uma entrada State<T> específica for atualizada.
  • Executar efeitos colaterais ou lembrar o estado intermediário: usar LaunchedEffect (por exemplo, para remover o debounce de entradas ou executar timers), produceState (para carregar dados de uma função suspend ou encapsular um callback com awaitDispose) ou remember para manter o estado entre as recomposições.

O exemplo a seguir compara derivedStateOf com um bloco transform que combina State<T> e um repositório 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)
    }
}

Neste exemplo, hasSearchQuery inspeciona apenas o estado searchQuery, então derivedStateOf o calcula de forma lenta sem um CoroutineScope. Por outro lado, uiState combina preferencesManager.showCompleted e searchQuery com um Flow assíncrono de tasksRepository e usa remember(showCompleted) para evitar a recriação do Flow quando apenas searchQuery muda.

Quando usar Flow em vez de transform

O transform foi projetado para representar o valor atual do estado, não um fluxo de eventos. Se um estado mudar várias vezes antes da próxima recomposição, o Compose vai usar apenas o valor mais recente e pular as atualizações quando o novo valor for igual (==) ao anterior. Use o Flow do Kotlin quando a lógica precisar processar todos os valores em ordem:

  • Totais e médias acumulados: cálculos como médias móveis, somas cumulativas ou histórico de desfazer (runningFold ou scan) precisam que cada valor seja contado uma vez. Em um bloco transform, as atualizações rápidas antes da recomposição são ignoradas, duas leituras idênticas seguidas (como um sensor emitindo 10.0 duas vezes) não acionam a recomposição, e as recomposições acionadas por outros estados no mesmo bloco podem contar o valor atual duas vezes. Primeiro, calcule o valor em execução em um Flow e, em seguida, colete o resultado em transform se precisar combiná-lo com outro estado.
  • Log de eventos e filas: pipelines que registram interações do usuário, processam solicitações de rede em fila ou registram transações em que cada mudança intermediária (A -> B -> C ou A -> B -> A) precisa ser registrada.
  • Eventos únicos: ações como tocar um som ou enviar um comando de hardware, em que o envio do mesmo comando duas vezes seguidas precisa ser executado duas vezes. transform requer um initialValue e ignora saídas iguais consecutivas.
  • Fluxos sensíveis à contrapressão: fluxos (como leituras de arquivos ou soquetes) em que um produtor rápido precisa ser suspenso quando um consumidor fica para trás. As gravações de estado do Compose não são suspensas e substituem os valores anteriores.

Testar transformações

Como as transformações dependem do ambiente de execução do Compose e das corrotinas do Kotlin, o teste delas exige a coordenação da execução assíncrona e da recomposição de estado. Use o artefato androidx.appstate.transform:transform-testing para controlar o programador de corrotinas e acionar recomposições em testes de unidade.

O pacote androidx.appstate.transform.testing oferece as seguintes 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 envolve runTest e executa o bloco de teste com um receptor TransformTestScope:

  • backgroundScope: um CoroutineScope para executar transformações que não devem bloquear a conclusão do teste. Ele é cancelado automaticamente quando o teste é concluído, o que interrompe a transformação.
  • testDispatcher: um TestDispatcher vinculado a testCoroutineScheduler. Transmita isso ao parâmetro dispatcher de transform para que a recomposição seja executada no programador de teste em vez de usar Dispatchers.Default como padrão.
  • advance: executa corrotinas pendentes no programador de teste, envia notificações de aplicação de snapshot do Compose (Snapshot.sendApplyNotifications) e recompõe o transform para que a saída State seja atualizada.
  • advanceTimeBy(duration) e advanceUntilIdle: avança o relógio virtual em testCoroutineScheduler para operações dependentes do tempo.

Testar atualizações de estado

Para testar um transform, transmita backgroundScope e testDispatcher de TransformTestScope e chame advance depois de atualizar um estado de entrada ou 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)
}

Testar operações baseadas em tempo

Quando um transform usa efeitos colaterais baseados em tempo, como LaunchedEffect e delay (por exemplo, consultas de pesquisa de debounce), combine advanceTimeBy(duration) com advance:

  1. Depois de modificar um estado de entrada, chame advance para que o bloco transform seja recomposto e inicie o LaunchedEffect.
  2. Chame advanceTimeBy(duration) para avançar o relógio virtual e, em seguida, chame advance para executar tarefas programadas nesse carimbo de data/hora e recompor o 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")
}

Testar um ViewModel ou detentor de estado

Como transform executa a composição no scope fornecido e usa Dispatchers.Default como padrão para o parâmetro dispatcher, as classes que criam um transform precisam aceitar um CoroutineScope e um CoroutineDispatcher como parâmetros do construtor. Em um ViewModel, transmita o CoroutineScope injetado para o construtor ViewModel(coroutineScope) e encaminhe o CoroutineDispatcher para 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,
        )
    )
}

Nesse teste, transmitir backgroundScope e testDispatcher para HomeViewModel vincula a composição viewModelScope e transform ao programador de teste. Assim, cada chamada advance processa de forma determinista as emissões de FakeMediaRepository e FakeNetworkMonitor. Para mais padrões sobre o uso de SavedStateHandle.saveable e Snapshot.withMutableSnapshot em um ViewModel, consulte Usar o estado do Compose em um ViewModel.