Transform   Android Jetpack 的一部分。

Transform 是一个基于 Compose 运行时构建的库,它会运行一个没有界面的 @Composable 代码块,以将输入转换为 Compose State。每当代码块内读取的任何 State 发生变化时,代码块都会自动重组并更新返回的 State,从而让您可以在 ViewModel、代码库或后台服务中使用 Compose 的依赖项跟踪和重组模型。

使用场景

  • 响应式数据转换:将原始数据流(例如网络响应或数据库查询)转换为界面就绪状态。
  • 合并多个输入:将多个独立的状态源(例如用户输入、设置和网络状态)合并为一个连贯的状态,而无需使用复杂的 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),也可以在现有 Composition 中创建转换。任何转换的关键参数都是 initialValue 和 onUpdate。onUpdate lambda 接收之前的输出值 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 提供支持,搜索栏界面可组合项可以读取和更新该状态。
  • transform 接受空列表的 initialValue、CoroutineScope 和作为尾随 lambda 的 onUpdate。
  • 在 onUpdate 块内,remember 会在 LaunchedEffect 运行期间在重组后保持 results 状态。
  • LaunchedEffect 将 query 作为键。由于 query 由 MutableState 提供支持,因此每次 query 发生更改时,该效应都会取消并重新启动。
  • 为了避免在每次按键时都调用 API,效应主体会等待 300 毫秒。如果查询在延迟完成之前发生更改,则效应会被取消,并且不会发出网络调用。

在 @Composable 函数内,您还可以使用 @Composable transform 重载,该重载会在内部记住 CoroutineScope,并默认在 Dispatchers.Default 上运行 onUpdate。如果您想将开销较大的状态派生(例如过滤或搜索大型列表)从主界面线程中分流出去,此方法非常有用:

@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 观察状态

如果您想在非界面组件(例如状态管理器、代码库或后台服务)中对 Compose 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 中生成界面状态。

条件状态生成

在 transform 中使用标准 Kotlin if-else 或 when 表达式,以便根据当前条件在上游状态源之间切换。由于读取跟踪是动态的,因此 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 会自动跟踪状态读取,因此您可以在单个 transform 代码块中组合任意数量的 State<T> 属性和 Flow 流。仅为最多 五个 flow 提供类型化重载,之后会回退到非类型化 Array<Any?>。与 Flow.combine 不同,在 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,
    )
}

在 ViewModel 中使用 transform

非可组合项 transform 重载在使用 Compose 的状态模型时,会在非界面层(例如 ViewModel 或代码库)中运行响应式逻辑。只要提供的 CoroutineScope 处于活跃状态,它就会运行,因此您可以通过在 ViewModel 中传递 viewModelScope 将其与组件的生命周期相关联:

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>。如需详细了解如何将 transform 与 MutableState 和 SavedStateHandle.saveable 结合使用,请参阅在 ViewModel 中使用 Compose 状态。

选择 transform、derivedStateOf 和 Flow

请根据以下准则在 transform、derivedStateOf 和 Kotlin Flow 之间进行选择:

derivedStateOf对transform

derivedStateOf 和 transform 都会生成一个派生的 State<T>,用于自动跟踪在其代码块内读取的 Compose 状态。您可以根据以下条件进行选择:是直接根据现有 State<T> 对象计算值,还是需要 @Composable 功能(例如 collectAsState、produceState 和 LaunchedEffect):

能力 derivedStateOf transform
Lambda 类型 标准 Kotlin lambda (() -> T) @Composable lambda (@Composable (R) -> R)
支持的输入 仅同步 State<T> 读取 State<T>、Kotlin Flow(使用 collectAsState)、suspend 函数和 callback API(使用 produceState)
执行和一致性 在读取 .value 时延迟和同步;在同一快照内以原子方式更新,无需 initialValue 或 CoroutineScope 在 CoroutineScope 中运行活跃的 Composition,并在快照提交后异步重组;需要 initialValue
状态和附带效应 无法调用 remember 或附带效应 API 支持 remember、LaunchedEffect、produceState 和可重复使用的 @Composable 函数

当所有输入都已经是 State<T> 对象且您不需要 @Composable API 时,请使用 derivedStateOf:

  • 同步、同快照派生:根据现有 State<T> 属性计算值或过滤列表,而无需管理 CoroutineScope 或提供占位符 initialValue。由于 derivedStateOf 会在读取 .value 时同步求值,因此在更新输入状态后立即读取 .value 的调用方始终会在同一快照中观察到更新后的结果。
  • 降低观察器的更新频率:计算变化频率低于其来源 State<T> 的值(例如,检查滚动偏移量是否超过阈值)。如需了解详情,请参阅 derivedStateOf 文档。

如果您的派生需要 Compose 运行时,请使用 transform:

  • 将 State<T> 与异步流结合使用:使用 collectAsState 以及 State<T> 输入从代码库或数据库收集 Kotlin Flow。
  • 在输入发生变化时重启上游工作:仅在特定 State<T> 输入更新时,在块内使用 remember(key) 重新创建参数化 Flow。
  • 运行附带效应或记住中间状态:使用 LaunchedEffect(例如,对输入进行去抖动或运行计时器)、produceState(从 suspend 函数加载数据或使用 awaitDispose 封装回调)或 remember 在重组之间保持状态。

以下示例将 derivedStateOf 与结合了 State<T> 和代码库 Flow 的 transform 块进行比较:

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 与来自 tasksRepository 的异步 Flow 结合使用,并使用 remember(showCompleted) 避免在仅 searchQuery 发生变化时重新创建 Flow。

何时使用 Flow 而非 transform

transform 旨在表示状态的当前值,而不是事件流。如果某个状态在下次重组之前多次发生变化,Compose 只会使用最新值,并且当新值等于 (==) 之前的值时,它会跳过更新。如果您的逻辑必须按顺序处理每个值,请改用 Kotlin Flow:

  • 累计总和和平均值:移动平均值、累计总和或撤消历史记录(runningFold 或 scan)等计算需要每个值都计算一次。在 transform 代码块中,系统会跳过重组前的快速更新,连续两次相同的读取(例如传感器发出两次 10.0)不会触发重组,并且由同一代码块中的其他状态触发的重组可能会对当前值进行两次计数。首先在 Flow 中计算运行值,然后如果您需要将其与其他状态组合,请在 transform 中收集结果。
  • 事件日志记录和队列:记录用户互动、处理排队的网络请求或记录事务的流水线,其中必须记录每个中间更改(A -> B -> C 或 A -> B -> A)。
  • 一次性事件:播放声音或发送硬件命令等操作,其中连续两次发送同一命令必须运行两次。transform 需要 initialValue,并会跳过连续的相同输出。
  • 背压敏感流:当使用方落后时,快速提供方必须暂停的流(例如文件或套接字读取)。Compose 状态写入不会挂起,而是会覆盖之前的值。

测试转换

由于转换依赖于 Compose 运行时和 Kotlin 协程,因此测试转换需要协调异步执行和状态重组。使用 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:与 testCoroutineScheduler 关联的 TestDispatcher。 将此参数传递给 transform 的 dispatcher 参数,以便重组在测试调度程序上运行,而不是默认在 Dispatchers.Default 上运行。
  • advance:在测试调度程序上运行待处理的协程,发送 Compose 快照应用通知 (Snapshot.sendApplyNotifications),并重组 transform,以便更新输出 State。
  • advanceTimeBy(duration) 和 advanceUntilIdle:在 testCoroutineScheduler 上推进虚拟时钟,以执行与时间相关的操作。

测试状态更新

如需测试 transform,请从 TransformTestScope 传递 backgroundScope 和 testDispatcher,然后在更新输入状态或 Flow 后调用 advance:

@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 会在提供的 scope 中运行其 Composition,并将其 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 Composition 绑定到测试调度程序,因此每个 advance 调用都会确定性地处理来自 FakeMediaRepository 和 FakeNetworkMonitor 的发出。如需了解在 ViewModel 中使用 SavedStateHandle.saveable 和 Snapshot.withMutableSnapshot 的更多模式,请参阅在 ViewModel 中使用 Compose 状态。