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 в качестве завершающей лямбда-функции.
  • Внутри блока onUpdate remember сохраняет состояние 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> с асинхронными потоками. Сбор Kotlin Flow из репозитория или базы данных с помощью 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:

  1. После изменения состояния ввода вызовите advance, чтобы блок transform перекомпоновался и запустил LaunchedEffect.
  2. Вызовите 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.