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
Flowcomplexes. - 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-elseouwhenstandards.
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 :
queryest soutenu par unMutableState, qu'un composable d'UI de barre de recherche peut lire et mettre à jour.transformprend uninitialValued'une liste vide, unCoroutineScopeetonUpdateen tant que lambda de fin.- Dans le bloc
onUpdate,rememberconserve l'étatresultslors des recompositions pendant l'exécution deLaunchedEffect. LaunchedEffectutilisequerycomme clé. Commequeryest soutenu par unMutableState, l'effet est annulé et relancé chaque fois quequerychange.- 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 deCoroutineScopeni fournir d'espace réservéinitialValue. Étant donné quederivedStateOfest évalué de manière synchrone lorsque.valueest lu, les appelants qui lisent.valueimmé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 surderivedStateOf.
Utilisez transform lorsque votre dérivation nécessite l'environnement d'exécution Compose :
- Combiner
State<T>avec des flux asynchrones : collecter unFlowKotlin à partir d'un dépôt ou d'une base de données à l'aide decollectAsStateavec des entréesState<T>. - Redémarrer le travail en amont lorsque les entrées changent : utilisez
remember(key)dans le bloc pour recréer unFlowparamétré uniquement lorsqu'une entréeState<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 fonctionsuspendou encapsuler un rappel avecawaitDispose) ourememberpour 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 (
runningFoldouscan) nécessitent que chaque valeur soit comptabilisée une fois. Dans un bloctransform, les mises à jour rapides avant la recomposition sont ignorées, deux lectures identiques d'affilée (par exemple, un capteur émettant10.0deux 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 unFlow, puis collectez le résultat danstransformsi 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 -> CouA -> 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.
transformnécessite uninitialValueet 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:CoroutineScopepour 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:TestDispatcherassocié àtestCoroutineScheduler. Transmettez-le au paramètredispatcherdetransformpour que la recomposition s'exécute sur le planificateur de test au lieu deDispatchers.Defaultpar 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 letransformpour que la sortieStatesoit mise à jour.advanceTimeBy(duration)etadvanceUntilIdle: avancez l'horloge virtuelle surtestCoroutineSchedulerpour 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 :
- Après avoir modifié un état d'entrée, appelez
advancepour que le bloctransformse recompose et démarreLaunchedEffect. - Appelez
advanceTimeBy(duration)pour faire avancer l'horloge virtuelle, puis appelezadvancepour exécuter les tâches planifiées à ce code temporel et recomposertransform.
@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.