轉換   Android Jetpack 的一部分。

Transform 是以 Compose 執行階段為基礎建構的程式庫,可執行不含 UI 的 @Composable 區塊,將輸入內容轉換為 Compose State。每當區塊內讀取的任何 State 發生變更,區塊就會自動重組並更新傳回的 State,讓您在 ViewModel、存放區或背景服務中使用 Compose 的依附元件追蹤和重組模型。

用途

  • 反應式資料轉換:將原始資料串流 (例如網路回應或資料庫查詢) 轉換為可供 UI 使用的狀態。
  • 合併多個輸入內容:將多個獨立狀態來源 (例如使用者輸入內容、設定和網路狀態) 合併為單一連貫狀態,不需使用複雜的 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 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 支援,搜尋列 UI 可組合函式可讀取及更新。
  • transform 接受空白清單的 initialValue、CoroutineScope 和 onUpdate 做為結尾 lambda。
  • 在 onUpdate 區塊中,remember 會在 LaunchedEffect 執行時,保留各次重組間的 results 狀態。
  • LaunchedEffect 會將 query 做為索引鍵。由於 query 是由 MutableState 支援,因此每次 query 變更時,系統都會取消並重新啟動效果。
  • 為避免每次按鍵都呼叫 API,效果主體會等待 300 毫秒。如果查詢在延遲完成前變更,效果就會取消,且不會發出網路呼叫。

在 @Composable 函式中,您也可以使用 @Composable transform 超載,這會在內部記住 CoroutineScope,並預設在 Dispatchers.Default 上執行 onUpdate。如要從主要 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 觀察狀態

如要在非 UI 元件 (例如狀態管理工具、存放區或背景服務) 中回應 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 中產生 UI 狀態。

條件式狀態產生

在 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.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,
    )
}

在 ViewModel 中使用 transform

非可組合項 transform 超載會在非 UI 層 (例如 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,並公開 UI 可組合項可直接讀取的單一 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 函式和回呼 API (使用 produceState)
執行和一致性 讀取 .value 時,會延遲和同步更新;在同一個快照中自動更新,不含 initialValue 或 CoroutineScope 在 CoroutineScope 中執行有效的組合結構,並在快照提交後以非同步方式重組;需要 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。
  • 在輸入內容變更時重新啟動上游工作:在區塊內使用 remember(key),只有在特定 State<T> 輸入內容更新時,才重新建立參數化 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 中執行其組合結構,並將其 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 的發射。如要進一步瞭解在 ViewModel 中使用 SavedStateHandle.saveable 和 Snapshot.withMutableSnapshot 的模式,請參閱「在 ViewModel 中使用 Compose 狀態」。