轉換 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>輸入,從存放區或資料庫收集 KotlinFlow。 - 在輸入內容變更時重新啟動上游工作:在區塊內使用
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 結合:
- 修改輸入狀態後,請呼叫
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 會在提供的 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 狀態」。