Transform Jest częścią Android Jetpack.
Transform to biblioteka oparta na środowisku wykonawczym Compose, która uruchamia blok @Composable bez interfejsu, aby przekształcać dane wejściowe w State Compose.
Za każdym razem, gdy zmieni się dowolny odczyt State w bloku, blok automatycznie ponownie się skomponuje i zaktualizuje zwracany State. Dzięki temu możesz używać modelu śledzenia zależności i ponownej kompozycji Compose w ViewModel, repozytoriach lub usługach w tle.
Przypadki użycia
- Reaktywne przekształcanie danych: konwertowanie nieprzetworzonych danych (takich jak odpowiedzi sieciowe lub zapytania do bazy danych) na stan gotowy do użycia w interfejsie.
- Łączenie wielu danych wejściowych: scalanie wielu niezależnych źródeł stanu (takich jak dane wejściowe użytkownika, ustawienia i stan sieci) w jeden spójny stan bez złożonych operatorów
Flow. - Debouncing i throttling: zaimplementuj zachowanie oparte na czasie (np. debouncing zapytań) za pomocą standardowych efektów ubocznych Compose, takich jak
LaunchedEffect. - Operacje warunkowe: uruchamiaj różne funkcje zbierania danych o stanie na podstawie bieżących warunków, korzystając ze standardowych wyrażeń
if-elselubwhen.
Przegląd interfejsów API
Pakiet androidx.appstate.transform udostępnia te funkcje:
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,
)
Tworzenie przekształcenia
Przekształcenia możesz tworzyć samodzielnie (za pomocą udostępnionego CoroutineScope) lub w ramach istniejącej kompozycji. Kluczowe parametry każdej transformacji to initialValue i onUpdate. Funkcja lambda onUpdate otrzymuje poprzednią wartość wyjściową R jako parametr (zainicjowany jako initialValue przy pierwszej kompozycji). Możesz zwrócić ten parametr, aby zachować ostatni wyemitowany stan zamiast resetować go do stanu wczytywania, np. { previous -> flow.collectAsState(initial = null).value ?: previous }.
Możesz na przykład użyć transform, aby obserwować zmiany w ciągu zapytania i ograniczyć kosztowne obliczenia, takie jak wywołania sieciowe:
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 }
W tym fragmencie kodu:
queryjest obsługiwany przezMutableState, który może być odczytywany i aktualizowany przez komponent interfejsu funkcji kompozycyjnej paska wyszukiwania.transformprzyjmujeinitialValuepustej listy,CoroutineScopeionUpdatejako końcową funkcję lambda.- Wewnątrz bloku
onUpdatefunkcjarememberzachowuje stanresultsmiędzy rekompozycjami, podczas gdyLaunchedEffectjest uruchomiona. LaunchedEffectprzyjmujequeryjako klucz. Ponieważqueryjest wspierany przezMutableState, efekt jest anulowany i uruchamiany ponownie za każdym razem, gdyquerysię zmienia.- Aby uniknąć wywoływania interfejsu API przy każdym naciśnięciu klawisza, treść efektu czeka 300 ms. Jeśli zapytanie zmieni się przed upływem tego czasu, efekt zostanie anulowany i nie zostanie wykonane żadne wywołanie sieciowe.
W funkcji @Composable możesz też użyć przeciążenia @Composable transform, które wewnętrznie zapamiętuje CoroutineScope i domyślnie uruchamia onUpdate w Dispatchers.Default. Jest to przydatne, gdy chcesz odciążyć główny wątek UI od kosztownego wyprowadzania stanu (np. filtrowania lub przeszukiwania dużej listy):
@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... }
Obserwuj stan za pomocą listener
Jeśli chcesz reagować na zmiany State w Compose w komponencie innym niż interfejs użytkownika (np. w menedżerze stanu, repozytorium lub usłudze w tle) bez tworzenia pochodnego State<R>, użyj listener.
listener to funkcja suspend, która uruchamia blok @Composable () -> Unit, dopóki wywołująca współprogram nie zostanie anulowana, automatycznie rekomponując się, gdy zmieni się stan odczytu w 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... }
W tym przykładzie listener obserwuje session.value. Gdy sesja jest w stanie FocusSession.Running, LaunchedEffect czeka do endTimeMillis, zanim zresetuje sesję do stanu FocusSession.Idle i opublikuje powiadomienie o zakończeniu. Jeśli użytkownik wstrzyma lub zakończy sesję wcześniej, gałąź Running opuści kompozycję i automatycznie anuluje minutnik. Gdy scope zostanie anulowany, listener przestanie obserwować.
Typowe wzorce
Poniższe wzorce pokazują, jak używać transform do logiki warunkowej, łączenia wielu strumieni i generowania stanu interfejsu w ViewModel.
Produkcja stanu warunkowego
Używaj standardowych wyrażeń if-else lub when w języku Kotlin w funkcji transform, aby przełączać się między źródłami stanu nadrzędnego na podstawie bieżących warunków. Ponieważ śledzenie odczytu jest dynamiczne, transform obserwuje tylko gałąź, która działa w bieżącej rekompozycji:
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 } }
Łączenie wielu źródeł stanu
Compose automatycznie śledzi odczyty stanu, więc możesz połączyć dowolną liczbę właściwości State<T> i strumieni Flow w jednym bloku transform. W przeciwieństwie do Flow.combine, który zapewnia tylko typowane przeciążenia dla maksymalnie 5 przepływów, zanim powróci do nietypowanego Array<Any?>, dodanie kolejnych danych wejściowych w transform zawsze jest pojedynczym odczytem z zabezpieczeniami wpisywania:
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, ) }
Używanie transform w ViewModel
Przeciążenie transform, które nie jest elementem kompozycyjnym, uruchamia logikę reaktywną w warstwach innych niż interfejs (np. ViewModels lub repozytoria) podczas korzystania z modelu stanu Compose. Działa tak długo, jak długo aktywny jest podany CoroutineScope, więc możesz powiązać go z cyklem życia komponentu, przekazując viewModelScope w 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, ) } }
W tym ViewModel uiState odczytuje networkMonitor.isOnline i dynamicznie przełącza się między zbieraniem mediaRepository.downloadedTracksFlow w trybie offline i mediaRepository.allTracksFlow w trybie online, udostępniając pojedynczy State<HomeUiState>, który elementy kompozycyjne interfejsu mogą odczytywać bezpośrednio. Więcej informacji o łączeniu transform z MutableState i SavedStateHandle.saveable znajdziesz w artykule na temat używania stanu Compose w ViewModel.
Wybierz transform, derivedStateOf lub Flow
Skorzystaj z tych wskazówek, aby wybrać między transform, derivedStateOf i Kotlinem Flow:
derivedStateOf – transform
Zarówno derivedStateOf, jak i transform generują pochodny obiekt State<T>, który automatycznie śledzi stany Compose odczytywane w jego bloku.
Wybierz jedną z nich w zależności od tego, czy obliczasz wartość bezpośrednio z istniejących obiektów State<T>, czy potrzebujesz funkcji @Composable, takich jak collectAsState, produceState i LaunchedEffect:
| Możliwości | derivedStateOf |
transform |
|---|---|---|
| Typ Lambda | Standardowa funkcja lambda w języku Kotlin (() -> T) |
@Composable lambda (@Composable (R) -> R) |
| Obsługiwane dane wejściowe | Tylko synchroniczne odczyty State<T> |
State<T>, Kotlin Flow (z użyciem collectAsState), funkcje suspend i interfejsy API oparte na wywołaniach zwrotnych (z użyciem produceState) |
| Realizacja i spójność | Leniwe i synchroniczne, gdy odczytywana jest wartość .value; aktualizuje się niepodzielnie w ramach tego samego zrzutu bez initialValue ani CoroutineScope |
Uruchamia aktywną kompozycję w CoroutineScope i rekomponuje asynchronicznie po zatwierdzeniu zrzutu; wymaga initialValue |
| Stan i efekty uboczne | Nie można wywołać interfejsów API remember ani interfejsów API z efektami ubocznymi |
Obsługuje funkcje remember, LaunchedEffect, produceState i funkcje @Composable wielokrotnego użytku |
Używaj derivedStateOf, gdy wszystkie dane wejściowe są już obiektami State<T> i nie potrzebujesz interfejsów API @Composable:
- Synchroniczne pochodne z tego samego zrzutu: obliczanie wartości lub filtrowanej listy na podstawie istniejących właściwości
State<T>bez zarządzaniaCoroutineScopelub podawania obiektu zastępczegoinitialValue. PonieważderivedStateOfjest oceniany synchronicznie podczas odczytu.value, elementy wywołujące, które odczytują.valuenatychmiast po zaktualizowaniu stanu wejściowego, zawsze widzą zaktualizowany wynik w tym samym zrzucie. - Zmniejszenie częstotliwości aktualizacji obserwatorów: obliczanie wartości, która zmienia się rzadziej niż jej źródło
State<T>(np. sprawdzanie, czy przesunięcie przewijania przekracza próg). Więcej informacji znajdziesz w dokumentacjiderivedStateOf.
Użyj transform, gdy pochodna wymaga środowiska wykonawczego Compose:
- Łączenie
State<T>ze strumieniami asynchronicznymi: zbieranie danych KotlinFlowz repozytorium lub bazy danych za pomocącollectAsStatei danych wejściowychState<T>. - Ponowne uruchamianie zadań nadrzędnych po zmianie danych wejściowych: używanie
remember(key)wewnątrz bloku do ponownego tworzenia sparametryzowanegoFlowtylko wtedy, gdy określone dane wejścioweState<T>zostaną zaktualizowane. - Uruchamianie efektów ubocznych lub zapamiętywanie stanu pośredniego: używanie
LaunchedEffect(np. do eliminowania drgań styków lub uruchamiania liczników czasu),produceState(do wczytywania danych z funkcjisuspendlub opakowywania wywołania zwrotnego za pomocąawaitDispose) lubrememberdo zachowywania stanu między rekompozycjami.
W tym przykładzie porównujemy derivedStateOf z blokiem transform, który łączy State<T> i repozytorium 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) } }
W tym przykładzie hasSearchQuery sprawdza tylko stan searchQuery, więc derivedStateOf oblicza go leniwie bez CoroutineScope. Z kolei uiState łączy preferencesManager.showCompleted i searchQuery z asynchronicznym Flow z tasksRepository i używa remember(showCompleted), aby uniknąć ponownego tworzenia Flow, gdy zmienia się tylko searchQuery.
Kiedy używać Flow zamiast transform
transform reprezentuje bieżącą wartość stanu, a nie strumień zdarzeń. Jeśli stan zmieni się wielokrotnie przed kolejną rekompozycją, Compose użyje tylko najnowszej wartości i pominie aktualizacje, gdy nowa wartość będzie równa (==) poprzedniej. Jeśli logika musi przetwarzać każdą wartość po kolei, użyj funkcji Flow w języku Kotlin:
- Sumy i średnie bieżące: obliczenia takie jak średnie ruchome, sumy skumulowane czy historia cofania (
runningFoldlubscan) wymagają, aby każda wartość była liczona raz. W blokutransformszybkie aktualizacje przed rekompozycją są pomijane, 2 identyczne odczyty z rzędu (np. czujnik emitujący10.0dwukrotnie) nie wywołują rekompozycji, a rekompozycje wywołane przez inne stany w tym samym bloku mogą liczyć bieżącą wartość dwukrotnie. Najpierw oblicz wartość bieżącą wFlow, a następnie zbierz wynik wtransform, jeśli chcesz połączyć go z innym stanem. - Rejestrowanie zdarzeń i kolejki: potoki, które rejestrują interakcje użytkowników, przetwarzają żądania sieciowe w kolejce lub rejestrują transakcje, w przypadku których należy zarejestrować każdą zmianę pośrednią (
A -> B -> ClubA -> B -> A). - Zdarzenia jednorazowe: działania takie jak odtworzenie dźwięku lub wysłanie polecenia sprzętowego, w przypadku których wysłanie tego samego polecenia 2 razy z rzędu musi spowodować jego dwukrotne wykonanie.
transformwymagainitialValuei pomija kolejne równe dane wyjściowe. - Strumienie wrażliwe na przeciwciśnienie: strumienie (takie jak odczyty plików lub gniazd), w których szybki producent musi zawiesić działanie, gdy konsument pozostaje w tyle. Zapisy stanu tworzenia nie są zawieszane i zastępują poprzednie wartości.
Testuj przekształcenia
Przekształcenia opierają się na środowisku wykonawczym Compose i funkcjach Coroutines w języku Kotlin, dlatego ich testowanie wymaga koordynacji asynchronicznego wykonywania i rekompozycji stanu. Użyj artefaktu androidx.appstate.transform:transform-testing, aby kontrolować harmonogram współprogramów i wywoływać rekompozycje w testach jednostkowych.
Pakiet androidx.appstate.transform.testing udostępnia te interfejsy API:
package androidx.appstate.transform.testing
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestResult
public fun transformTest(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend TransformTestScope.() -> Unit,
): TestResult
public sealed interface TransformTestScope : CoroutineScope {
public val testCoroutineScheduler: TestCoroutineScheduler
public val testDispatcher: TestDispatcher
public val backgroundScope: CoroutineScope
public fun advance()
public fun advanceTimeBy(duration: Duration)
public fun advanceUntilIdle()
}
transformTest opakowuje runTest i wykonuje blok testowy z odbiornikiem TransformTestScope:
backgroundScope:CoroutineScopedo uruchamiania przekształceń, które nie powinny blokować ukończenia testu. Zostanie ono automatycznie anulowane po zakończeniu testu, co spowoduje zatrzymanie przekształcenia.testDispatcher:TestDispatcherpołączony ztestCoroutineScheduler. Przekaż to do parametrudispatcherfunkcjitransform, aby rekompozycja była uruchamiana w harmonogramie testowym, a nie domyślnie wDispatchers.Default.advance: uruchamia oczekujące współprogramy w harmonogramie testów, wysyła powiadomienia o zastosowaniu migawki Compose (Snapshot.sendApplyNotifications) i ponownie tworzytransform, aby zaktualizować dane wyjścioweState.advanceTimeBy(duration)iadvanceUntilIdle: przesuń zegar wirtualny natestCoroutineSchedulerw przypadku operacji zależnych od czasu.
Aktualizacje stanu testu
Aby przetestować transform, przekaż backgroundScope i testDispatcher z TransformTestScope, a następnie wywołaj advance po zaktualizowaniu stanu wejściowego lub 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) }
Testowanie operacji czasowych
Gdy transform używa efektów ubocznych opartych na czasie, takich jak LaunchedEffect i delay (np. debouncing zapytań), połącz advanceTimeBy(duration) z advance:
- Po zmodyfikowaniu stanu wejściowego wywołaj funkcję
advance, aby bloktransformzostał ponownie skomponowany i uruchomiłLaunchedEffect. - Wywołaj
advanceTimeBy(duration), aby przesunąć wirtualny zegar do przodu, a następnie wywołajadvance, aby uruchomić zadania zaplanowane na tę sygnaturę czasową i ponownie skomponować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") }
Testowanie ViewModel lub zmiennej stanu
transform uruchamia kompozycję w podanym scope i domyślnie ustawia parametr dispatcher na Dispatchers.Default, dlatego klasy, które tworzą transform, powinny akceptować CoroutineScope i CoroutineDispatcher jako parametry konstruktora. W klasie ViewModel przekaż wstrzyknięty obiekt CoroutineScope do konstruktora ViewModel(coroutineScope) i przekaż obiekt CoroutineDispatcher do 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, ) ) }
W tym teście przekazanie backgroundScope i testDispatcher do HomeViewModel wiąże jego viewModelScope i transform Composition z harmonogramem testów, dzięki czemu każde wywołanie advance deterministycznie przetwarza emisje z FakeMediaRepository i FakeNetworkMonitor. Więcej wzorców używania SavedStateHandle.saveable i Snapshot.withMutableSnapshot w ViewModel znajdziesz w artykule Używanie stanu Compose w ViewModel.