Transform   Inclus dans Android Jetpack.

Transform est une bibliothèque basée sur l'environnement d'exécution Compose qui exécute un bloc @Composable sans UI pour transformer les entrées en State Compose. Chaque fois qu'une lecture State dans le bloc change, le bloc se recompose automatiquement et met à jour le State renvoyé. Vous pouvez ainsi utiliser le modèle de suivi des dépendances et de recomposition de Compose dans les ViewModels, les dépôts ou les services d'arrière-plan.

Cas d'utilisation

  • Transformation réactive des données : convertissez les flux de données brutes (comme les réponses réseau ou les requêtes de base de données) en un état prêt pour l'UI.
  • Combiner plusieurs entrées : fusionnez plusieurs sources d'état indépendantes (telles que les entrées utilisateur, les paramètres et l'état du réseau) en un seul état cohérent sans opérateurs Flow complexes.
  • Anti-rebond et limitation : implémentez un comportement basé sur le temps (comme l'anti-rebond des requêtes de recherche) à l'aide d'effets secondaires Compose standards tels que LaunchedEffect.
  • Opérations conditionnelles : exécutez une logique de collecte d'état différente en fonction des conditions actuelles à l'aide d'expressions if-else ou when standards.

Présentation de l'API

Le package androidx.appstate.transform comprend les fonctions suivantes :

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

Créer une transformation

Vous pouvez créer des transformations de manière autonome (à l'aide d'un CoroutineScope fourni) ou dans une composition existante. Les paramètres clés de toute transformation sont initialValue et onUpdate. Le lambda onUpdate reçoit la valeur de sortie précédente R comme paramètre (initialisée sur initialValue lors de la première composition). Vous pouvez renvoyer ce paramètre pour conserver le dernier état émis au lieu de revenir à un état de chargement, tel que { previous -> flow.collectAsState(initial = null).value ?: previous }.

Par exemple, vous pouvez utiliser transform pour observer les modifications apportées à une chaîne de requête de recherche et éliminer les calculs coûteux tels que les appels réseau :

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
}

Dans cet extrait :

  • query est soutenu par un MutableState, qu'un composable d'UI de barre de recherche peut lire et mettre à jour.
  • transform prend un initialValue d'une liste vide, un CoroutineScope et onUpdate en tant que lambda de fin.
  • Dans le bloc onUpdate, remember conserve l'état results lors des recompositions pendant l'exécution de LaunchedEffect.
  • LaunchedEffect utilise query comme clé. Comme query est soutenu par un MutableState, l'effet est annulé et relancé chaque fois que query change.
  • Pour éviter d'appeler l'API à chaque frappe, le corps de l'effet attend 300 ms. Si la requête change avant la fin de ce délai, l'effet est annulé et aucun appel réseau n'est effectué.

Dans une fonction @Composable, vous pouvez également utiliser la surcharge @Composable transform, qui mémorise un CoroutineScope en interne et exécute onUpdate sur Dispatchers.Default par défaut. Cela est utile lorsque vous souhaitez décharger une dérivation d'état coûteuse (comme le filtrage ou la recherche d'une longue liste) du thread UI 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...
}

Observer l'état avec listener

Lorsque vous souhaitez réagir aux modifications de State Compose dans un composant non-UI (tel qu'un gestionnaire d'état, un dépôt ou un service d'arrière-plan) sans produire de State<R> dérivé, utilisez listener.

listener est une fonction suspend qui exécute un bloc @Composable () -> Unit jusqu'à ce que la coroutine appelante soit annulée, en se recomposant automatiquement chaque fois qu'un état lu dans onUpdate change :

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

Dans cet exemple, listener observe session.value. Lorsque la session est FocusSession.Running, LaunchedEffect attend jusqu'à endTimeMillis avant de réinitialiser la session sur FocusSession.Idle et de publier une notification d'achèvement. Si l'utilisateur met en pause ou termine la session plus tôt, la branche Running quitte la composition et annule automatiquement le minuteur. Lorsque scope est annulé, listener cesse d'observer.

Schémas courants

Les modèles suivants montrent comment utiliser transform pour la logique conditionnelle, en combinant plusieurs flux et en produisant un état d'interface utilisateur dans un ViewModel.

Production d'état conditionnelle

Utilisez les expressions Kotlin standards if-else ou when dans transform pour basculer entre les sources d'état en amont en fonction des conditions actuelles. Étant donné que le suivi des lectures est dynamique, transform n'observe que la branche qui s'exécute lors de la recomposition actuelle :

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

Combiner plusieurs sources d'état

Étant donné que Compose suit automatiquement les lectures d'état, vous pouvez combiner un nombre illimité de propriétés State<T> et de flux Flow dans un seul bloc transform. Contrairement à Flow.combine, qui ne fournit des surcharges typées que pour cinq flux au maximum avant de revenir à un Array<Any?> non typé, l'ajout d'une autre entrée dans transform est toujours une lecture unique de type sécurisé :

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

Utiliser transform dans un ViewModel

La surcharge transform non composable exécute une logique réactive dans des couches non UI (comme les ViewModels ou les dépôts) tout en utilisant le modèle d'état de Compose. Il s'exécute tant que le CoroutineScope fourni est actif. Vous pouvez donc l'associer au cycle de vie d'un composant en transmettant viewModelScope dans 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,
        )
    }
}

Dans ce ViewModel, uiState lit networkMonitor.isOnline et bascule de manière dynamique entre la collecte de mediaRepository.downloadedTracksFlow en mode hors connexion et de mediaRepository.allTracksFlow en mode en ligne, en exposant un seul State<HomeUiState> que les composables d'interface utilisateur peuvent lire directement. Pour en savoir plus sur la combinaison de transform avec MutableState et SavedStateHandle.saveable, consultez Utiliser l'état Compose dans un ViewModel.

Choisissez entre transform, derivedStateOf et Flow.

Suivez les instructions ci-dessous pour choisir entre transform, derivedStateOf et Kotlin Flow :

derivedStateOf contre transform

derivedStateOf et transform produisent tous deux un State<T> dérivé qui suit automatiquement les états Compose lus dans son bloc. Choisissez l'une ou l'autre de ces options selon que vous calculez une valeur directement à partir d'objets State<T> existants ou que vous avez besoin de fonctionnalités @Composable telles que collectAsState, produceState et LaunchedEffect :

Capacité derivedStateOf transform
Type lambda Lambda Kotlin standard (() -> T) @Composable lambda (@Composable (R) -> R)
Entrées compatibles Lectures State<T> synchrones uniquement State<T>, Flow Kotlin (à l'aide de collectAsState), fonctions suspend et API de rappel (à l'aide de produceState)
Exécution et cohérence Paresseux et synchrone lorsque .value est lu ; se met à jour de manière atomique dans le même instantané sans initialValue ni CoroutineScope Exécute une composition active dans un CoroutineScope et la recompose de manière asynchrone après la validation d'un instantané. Nécessite un initialValue.
État et effets secondaires Impossible d'appeler les API remember ou d'effets secondaires Compatible avec les fonctions remember, LaunchedEffect, produceState et @Composable réutilisables

Utilisez derivedStateOf lorsque toutes les entrées sont déjà des objets State<T> et que vous n'avez pas besoin des API @Composable :

  • Dérivations synchrones du même instantané : calcul d'une valeur ou d'une liste filtrée à partir de propriétés State<T> existantes sans gérer de CoroutineScope ni fournir d'espace réservé initialValue. Étant donné que derivedStateOf est évalué de manière synchrone lorsque .value est lu, les appelants qui lisent .value immédiatement après la mise à jour d'un état d'entrée observent toujours le résultat mis à jour dans le même instantané.
  • Réduire la fréquence de mise à jour des observateurs : calculer une valeur qui change moins souvent que sa source State<T> (par exemple, vérifier si un décalage de défilement dépasse un seuil). Pour en savoir plus, consultez la documentation sur derivedStateOf.

Utilisez transform lorsque votre dérivation nécessite l'environnement d'exécution Compose :

  • Combiner State<T> avec des flux asynchrones : collecter un Flow Kotlin à partir d'un dépôt ou d'une base de données à l'aide de collectAsState avec des entrées State<T>.
  • Redémarrer le travail en amont lorsque les entrées changent : utilisez remember(key) dans le bloc pour recréer un Flow paramétré uniquement lorsqu'une entrée State<T> spécifique est mise à jour.
  • Exécuter des effets secondaires ou mémoriser l'état intermédiaire : utiliser LaunchedEffect (par exemple, pour éliminer les rebonds d'entrées ou exécuter des minuteurs), produceState (pour charger des données à partir d'une fonction suspend ou encapsuler un rappel avec awaitDispose) ou remember pour conserver l'état entre les recompositions.

L'exemple suivant compare derivedStateOf à un bloc transform qui combine State<T> et un dépôt 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)
    }
}

Dans cet exemple, hasSearchQuery n'inspecte que l'état searchQuery. derivedStateOf le calcule donc de manière différée sans CoroutineScope. En revanche, uiState combine preferencesManager.showCompleted et searchQuery avec un Flow asynchrone de tasksRepository et utilise remember(showCompleted) pour éviter de recréer le Flow lorsque seul searchQuery change.

Quand utiliser Flow au lieu de transform

transform est conçu pour représenter la valeur actuelle de l'état, et non un flux d'événements. Si un état change plusieurs fois avant la recomposition suivante, Compose n'utilise que la dernière valeur et ignore les mises à jour lorsque la nouvelle valeur est égale (==) à la valeur précédente. Utilisez plutôt Flow de Kotlin lorsque votre logique doit traiter chaque valeur dans l'ordre :

  • Totaux et moyennes cumulés : les calculs tels que les moyennes mobiles, les sommes cumulées ou l'historique d'annulation (runningFold ou scan) nécessitent que chaque valeur soit comptabilisée une fois. Dans un bloc transform, les mises à jour rapides avant la recomposition sont ignorées, deux lectures identiques d'affilée (par exemple, un capteur émettant 10.0 deux fois) ne déclenchent pas de recomposition, et les recompositions déclenchées par d'autres états du même bloc peuvent compter deux fois la valeur actuelle. Calculez d'abord la valeur d'exécution dans un Flow, puis collectez le résultat dans transform si vous devez le combiner avec un autre état.
  • Journalisation des événements et files d'attente : pipelines qui enregistrent les interactions des utilisateurs, traitent les requêtes réseau en file d'attente ou enregistrent les transactions pour lesquelles chaque modification intermédiaire (A -> B -> C ou A -> B -> A) doit être enregistrée.
  • Événements ponctuels : actions telles que la lecture d'un son ou l'envoi d'une commande matérielle, où l'envoi de la même commande deux fois de suite doit s'exécuter deux fois. transform nécessite un initialValue et ignore les sorties égales consécutives.
  • Flux sensibles à la contre-pression : flux (tels que les lectures de fichiers ou de sockets) dans lesquels un producteur rapide doit être suspendu lorsqu'un consommateur prend du retard. Les écritures d'état Compose ne sont pas suspendues et écrasent les valeurs précédentes.

Tester les transformations

Étant donné que les transformations s'appuient sur l'environnement d'exécution Compose et les coroutines Kotlin, leur test nécessite de coordonner l'exécution asynchrone et la recomposition de l'état. Utilisez l'artefact androidx.appstate.transform:transform-testing pour contrôler le planificateur de coroutines et déclencher des recompositions dans les tests unitaires.

Le package androidx.appstate.transform.testing fournit les API suivantes :

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 encapsule runTest et exécute votre bloc de test avec un récepteur TransformTestScope :

  • backgroundScope : CoroutineScope pour exécuter des transformations qui ne doivent pas bloquer l'exécution du test. Il est automatiquement annulé à la fin du test, ce qui arrête la transformation.
  • testDispatcher : TestDispatcher associé à testCoroutineScheduler. Transmettez-le au paramètre dispatcher de transform pour que la recomposition s'exécute sur le planificateur de test au lieu de Dispatchers.Default par défaut.
  • advance : exécute les coroutines en attente sur le planificateur de test, envoie des notifications d'application d'instantané Compose (Snapshot.sendApplyNotifications) et recompose le transform pour que la sortie State soit mise à jour.
  • advanceTimeBy(duration) et advanceUntilIdle : avancez l'horloge virtuelle sur testCoroutineScheduler pour les opérations dépendantes du temps.

Tester les mises à jour d'état

Pour tester un transform, transmettez backgroundScope et testDispatcher à partir de TransformTestScope, puis appelez advance après avoir mis à jour un état d'entrée 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)
}

Tester les opérations temporelles

Lorsqu'un transform utilise des effets secondaires temporels tels que LaunchedEffect et delay (comme l'anti-rebond des requêtes de recherche), combinez advanceTimeBy(duration) avec advance :

  1. Après avoir modifié un état d'entrée, appelez advance pour que le bloc transform se recompose et démarre LaunchedEffect.
  2. Appelez advanceTimeBy(duration) pour faire avancer l'horloge virtuelle, puis appelez advance pour exécuter les tâches planifiées à ce code temporel et recomposer 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")
}

Tester un ViewModel ou un conteneur d'état

Étant donné que transform exécute sa composition dans le scope fourni et que son paramètre dispatcher est défini par défaut sur Dispatchers.Default, les classes qui créent un transform doivent accepter un CoroutineScope et un CoroutineDispatcher en tant que paramètres de constructeur. Dans un ViewModel, transmettez le CoroutineScope injecté au constructeur ViewModel(coroutineScope) et transmettez le CoroutineDispatcher à 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,
        )
    )
}

Dans ce test, la transmission de backgroundScope et testDispatcher dans HomeViewModel lie sa composition viewModelScope et transform au planificateur de test. Ainsi, chaque appel advance traite de manière déterministe les émissions de FakeMediaRepository et FakeNetworkMonitor. Pour en savoir plus sur l'utilisation de SavedStateHandle.saveable et Snapshot.withMutableSnapshot dans un ViewModel, consultez Utiliser l'état Compose dans un ViewModel.