Transform Часть Android Jetpack.
Transform – это библиотека, созданная на основе среды выполнения Compose, которая запускает блок @Composable без пользовательского интерфейса, чтобы преобразовать входные данные в State Compose.
Всякий раз, когда любое значение State, считанное внутри блока, изменяется, блок автоматически перекомпонуется и обновляет возвращенное значение State, что позволяет вам использовать модель отслеживания зависимостей и перекомпоновки Compose в ViewModels, репозиториях или фоновых службах.
Примеры использования
- Реактивное преобразование данных. Преобразуйте необработанные потоки данных (например, ответы сети или запросы к базе данных) в состояние, готовое к использованию в пользовательском интерфейсе.
- Объединение нескольких входных данных. Объединяйте несколько независимых источников состояния (например, пользовательский ввод, настройки и статус сети) в одно связное состояние без сложных операторов
Flow. - Устранение дребезга и регулирование. Реализуйте поведение, основанное на времени (например, устранение дребезга поисковых запросов), используя стандартные побочные эффекты Compose, такие как
LaunchedEffect. - Условные операции. Выполняйте разные логические операции по сбору данных о состоянии на основе текущих условий, используя стандартные выражения
if-elseилиwhen.
Обзор API
Пакет androidx.appstate.transform предоставляет следующие функции:
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,
)
Как создать преобразование
Преобразования можно создавать как отдельно (с помощью предоставленного CoroutineScope), так и в рамках существующей композиции. Ключевыми параметрами для любого преобразования являются initialValue и onUpdate. Лямбда-функция onUpdate получает предыдущее выходное значение R в качестве параметра (инициализируется значением initialValue при первой композиции). Вы можете вернуть этот параметр, чтобы сохранить последнее состояние вместо сброса до состояния загрузки, например { previous -> flow.collectAsState(initial = null).value ?: previous }.
Например, вы можете использовать transform, чтобы отслеживать изменения в строке поискового запроса и откладывать ресурсоемкие вычисления, такие как сетевые вызовы:
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 }
В этом фрагменте:
queryподдерживаетсяMutableState, который может считывать и обновлять composable-функция строки поиска.transformпринимаетinitialValueпустого списка,CoroutineScopeиonUpdateв качестве завершающей лямбда-функции.- Внутри блока
onUpdaterememberсохраняет состояниеresultsво время рекомпозиций, пока выполняетсяLaunchedEffect. LaunchedEffectпринимаетqueryв качестве ключа. ПосколькуqueryподдерживаетсяMutableState, эффект отменяется и перезапускается каждый раз, когда меняетсяquery.- Чтобы избежать вызова API при каждом нажатии клавиши, тело эффекта ожидает 300 мс. Если запрос изменяется до завершения этой задержки, эффект отменяется и сетевой вызов не выполняется.
Внутри функции @Composable вы также можете использовать перегрузку @Composable transform, которая запоминает CoroutineScope внутри и по умолчанию запускает onUpdate на Dispatchers.Default. Это полезно, когда вы хотите разгрузить поток UI от ресурсоемких вычислений состояния (таких как фильтрация или поиск по большому списку):
@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... }
Отслеживание состояния с помощью listener
Если вы хотите реагировать на изменения State в компоненте, не связанном с интерфейсом (например, в менеджере состояний, репозитории или фоновой службе), не создавая производный объект State<R>, используйте listener.
listener – это функция suspend, которая выполняет блок @Composable () -> Unit, пока вызывающая сопрограмма не будет отменена. При этом автоматически выполняется рекомпозиция, когда изменяется любое состояние, считанное внутри 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... }
В этом примере listener наблюдает за session.value. Когда сеанс FocusSession.Running, LaunchedEffect ждет до endTimeMillis, прежде чем сбросить сеанс до FocusSession.Idle и опубликовать уведомление о завершении. Если пользователь приостанавливает или завершает сеанс раньше времени, ветвь Running покидает композицию и автоматически отменяет таймер. Когда scope отменяется, listener прекращает наблюдение.
Распространенные шаблоны
В следующих примерах показано, как использовать transform для условной логики, объединения нескольких потоков и создания состояния пользовательского интерфейса в ViewModel.
Условное производство
Используйте стандартные выражения Kotlin if-else или when внутри transform, чтобы переключаться между источниками состояния в зависимости от текущих условий. Поскольку отслеживание чтения является динамическим, transform наблюдает только за ветвью, которая выполняется при текущей рекомпозиции:
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 } }
Объединение нескольких источников данных о штатах
Поскольку Compose автоматически отслеживает считывание состояния, вы можете объединить любое количество свойств State<T> и потоков Flow в одном блоке transform. В отличие от Flow.combine, который предоставляет типизированные перегрузки только для пяти потоков, а затем возвращается к нетипизированному Array<Any?>, добавление ещё одних входных данных в transform всегда является одним типобезопасным чтением:
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, ) }
Использование transform в ViewModel
Перегрузка transform, не являющаяся composable-функцией, запускает реактивную логику на уровнях, не связанных с интерфейсом (например, в ViewModel или репозиториях), используя модель состояния Compose. Она выполняется, пока активен предоставленный CoroutineScope, поэтому вы можете связать ее с жизненным циклом компонента, передав viewModelScope в 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, ) } }
В этом ViewModel uiState считывает networkMonitor.isOnline и динамически переключается между сбором mediaRepository.downloadedTracksFlow в офлайн-режиме и mediaRepository.allTracksFlow в онлайн-режиме, предоставляя единый State<HomeUiState>, который composable-функции пользовательского интерфейса могут считывать напрямую. Подробнее о том, как сочетать transform с MutableState и SavedStateHandle.saveable, рассказывается в статье Как использовать состояние Compose в ViewModel.
Выберите transform, derivedStateOf или Flow
Чтобы выбрать между transform, derivedStateOf и Kotlin Flow, следуйте приведенным ниже рекомендациям.
derivedStateOf против transform
И derivedStateOf, и transform создают производный объект State<T>, который автоматически отслеживает состояния Compose, считываемые внутри его блока.
Выбор зависит от того, вычисляете ли вы значение непосредственно из существующих объектов State<T> или вам нужны такие функции @Composable, как collectAsState, produceState и LaunchedEffect.
| Возможности | derivedStateOf |
transform |
|---|---|---|
| Тип лямбда-функции | Стандартное лямбда-выражение Kotlin (() -> T) |
@Composable lambda (@Composable (R) -> R) |
| Поддерживаемые входные данные | Синхронное только чтение State<T> |
State<T>, Kotlin Flow (с помощью collectAsState), функции suspend и API обратного вызова (с помощью produceState). |
| Реализация и последовательность | Ленивое и синхронное при чтении .value; атомарно обновляется в рамках одного снимка без initialValue или CoroutineScope. |
Запускает активную композицию в CoroutineScope и асинхронно перекомпонует ее после фиксации снимка. Требуется initialValue. |
| Состояние и побочные эффекты | Нельзя вызывать remember или API с побочными эффектами |
Поддерживает функции remember, LaunchedEffect, produceState и многоразовые функции @Composable |
Используйте derivedStateOf, когда все входные данные уже являются объектами State<T> и вам не нужны API @Composable:
- Синхронные производные значения на основе одного и того же снимка. Вычисление значения или отфильтрованного списка на основе существующих свойств
State<T>без управленияCoroutineScopeили предоставления плейсхолдераinitialValue. ПосколькуderivedStateOfвычисляется синхронно при чтении.value, вызывающие объекты, считывающие.valueсразу после обновления состояния ввода, всегда видят обновленный результат в том же снимке. - Снижение частоты обновления наблюдателей. Вычисление значения, которое меняется реже, чем его источник
State<T>(например, проверка того, превышает ли смещение прокрутки пороговое значение). Подробную информацию можно найти в документацииderivedStateOf.
Используйте transform, если для вывода требуется среда выполнения Compose:
- Сочетание
State<T>с асинхронными потоками. Сбор KotlinFlowиз репозитория или базы данных с помощьюcollectAsStateвместе с входными даннымиState<T>. - Перезапуск вышестоящих процессов при изменении входных данных. Использование
remember(key)внутри блока для повторного создания параметризованногоFlowтолько при обновлении определенных входных данныхState<T>. - Выполнение побочных эффектов или запоминание промежуточного состояния. Использование
LaunchedEffect(например, для устранения дребезга контактов или запуска таймеров),produceState(для загрузки данных из функцииsuspendили упаковки обратного вызова с помощьюawaitDispose) илиrememberдля сохранения состояния между рекомпозициями.
В примере ниже сравнивается derivedStateOf с блоком transform, который объединяет State<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) } }
В этом примере hasSearchQuery проверяет только состояние searchQuery, поэтому derivedStateOf вычисляет его лениво без CoroutineScope. В отличие от этого, uiState объединяет preferencesManager.showCompleted и searchQuery с асинхронным Flow из tasksRepository и использует remember(showCompleted), чтобы избежать повторного создания Flow, когда изменяется только searchQuery.
Когда использовать Flow вместо transform
transform предназначен для представления текущего значения состояния, а не потока событий. Если состояние меняется несколько раз до следующей рекомпозиции, Compose использует только последнее значение и пропускает обновления, когда новое значение равно (==) предыдущему. Если логика должна обрабатывать каждое значение по порядку, используйте Kotlin Flow:
- Текущие итоги и средние значения. Для вычислений, таких как скользящие средние, кумулятивные суммы или история отмены (
runningFoldилиscan), необходимо, чтобы каждое значение учитывалось один раз. В блокеtransformбыстрые обновления до рекомпозиции пропускаются, два одинаковых показания подряд (например, датчик дважды выдает10.0) не запускают рекомпозицию, а рекомпозиции, запускаемые другими состояниями в том же блоке, могут учитывать текущее значение дважды. Сначала вычислите текущее значение вFlow, а затем соберите результат вtransform, если вам нужно объединить его с другим состоянием. - Журнал событий и очереди. Конвейеры, которые регистрируют взаимодействия пользователей, обрабатывают сетевые запросы в очереди или записывают транзакции, в которых должно быть зафиксировано каждое промежуточное изменение (
A -> B -> CилиA -> B -> A). - Однократные события. Действия, такие как воспроизведение звука или отправка аппаратной команды, при которых отправка одной и той же команды дважды подряд должна выполняться дважды.
Для
transformтребуетсяinitialValue. Одинаковые выходные данные, следующие друг за другом, пропускаются. - Потоки, чувствительные к обратному давлению. Потоки (например, чтение файлов или сокетов), в которых быстрый производитель должен приостанавливать работу, когда потребитель отстает. Записи состояния Compose не приостанавливаются, а перезаписывают предыдущие значения.
Тестирование преобразований
Поскольку преобразования зависят от среды выполнения Compose и Kotlin Coroutines, их тестирование требует координации асинхронного выполнения и рекомпозиции состояния. Используйте артефакт androidx.appstate.transform:transform-testing, чтобы управлять планировщиком сопрограмм и запускать рекомпозиции в модульном тестировании.
Пакет androidx.appstate.transform.testing предоставляет следующие 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 оборачивает runTest и выполняет тестовый блок с получателем TransformTestScope:
backgroundScope–CoroutineScopeдля выполнения преобразований, которые не должны блокировать завершение теста. Она автоматически отменяется по окончании тестирования, что останавливает преобразование.testDispatcher–TestDispatcher, связанный сtestCoroutineScheduler. Передайте его в параметрdispatcherфункцииtransform, чтобы рекомпозиция выполнялась в планировщике тестов, а не вDispatchers.Defaultпо умолчанию.advance: запускает ожидающие сопрограммы в тестовом планировщике, отправляет уведомления о применении снимка Compose (Snapshot.sendApplyNotifications) и повторно компонуетtransform, чтобы обновить выходнойState.advanceTimeBy(duration)иadvanceUntilIdle– перевод виртуальных часов наtestCoroutineSchedulerдля операций, зависящих от времени.
Обновления статуса тестирования
Чтобы протестировать transform, передайте backgroundScope и testDispatcher из TransformTestScope, а затем вызовите advance после обновления состояния ввода или 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) }
Тестирование операций, зависящих от времени
Если в transform используются побочные эффекты, зависящие от времени, например LaunchedEffect и delay (например, устранение дребезга поисковых запросов), объедините advanceTimeBy(duration) с advance:
- После изменения состояния ввода вызовите
advance, чтобы блокtransformперекомпоновался и запустилLaunchedEffect. - Вызовите
advanceTimeBy(duration), чтобы перевести виртуальные часы вперед, а затем вызовитеadvance, чтобы запустить задачи, запланированные на эту временную метку, и перекомпоновать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") }
Тестирование ViewModel или хранилища состояния
Поскольку transform запускает Composition в предоставленном scope и по умолчанию использует параметр dispatcher со значением Dispatchers.Default, классы, создающие transform, должны принимать CoroutineScope и CoroutineDispatcher в качестве параметров конструктора. В ViewModel передайте внедренный объект CoroutineScope в конструктор ViewModel(coroutineScope) и перенаправьте 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, ) ) }
В этом тесте передача backgroundScope и testDispatcher в HomeViewModel связывает его композицию viewModelScope и transform с планировщиком тестов, поэтому каждый вызов advance детерминированно обрабатывает выбросы из FakeMediaRepository и FakeNetworkMonitor. Дополнительные шаблоны использования SavedStateHandle.saveable и Snapshot.withMutableSnapshot в ViewModel описаны в статье Использование состояния Compose в ViewModel.