Transform Android Jetpack の一部。
Transform は、Compose ランタイム上に構築されたライブラリです。UI なしで @Composable ブロックを実行し、入力を Compose State に変換します。ブロック内で読み取られた State が変更されるたびに、ブロックは自動的に再コンポーズされ、返された State を更新します。これにより、ViewModel、リポジトリ、バックグラウンド サービスで Compose の依存関係トラッキングと再コンポーズ モデルを使用できます。
ユースケース
- リアクティブなデータ変換: 元データ ストリーム(ネットワーク レスポンスやデータベース クエリなど)を UI 対応の状態に変換します。
- 複数の入力を結合する: 複数の独立した状態ソース(ユーザー入力、設定、ネットワーク ステータスなど)を、複雑な
Flow演算子を使用せずに、1 つのまとまった状態に結合します。 - デバウンスとスロットリング:
LaunchedEffectなどの標準の Compose 副作用を使用して、時間ベースの動作(検索クエリのデバウンスなど)を実装します。 - 条件付きオペレーション: 標準の
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 ラムダは、前の出力値 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を後置ラムダとして受け取ります。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 で状態を観察する
派生 State<R> を生成せずに、UI 以外のコンポーネント(状態マネージャー、リポジトリ、バックグラウンド サービスなど)で Compose State の変更に対応する場合は、listener を使用します。
listener は、呼び出し元のコルーチンがキャンセルされるまで @Composable () -> Unit ブロックを実行する suspend 関数であり、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 は状態の読み取りを自動的に追跡するため、任意の数の State<T> プロパティと Flow ストリームを 1 つの transform ブロックに結合できます。Flow.combine では、型指定されたオーバーロードが最大 5 つのフローにのみ提供され、その後は型指定されていない 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 オーバーロードは、Compose の状態モデルを使用しながら、UI 以外のレイヤ(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 の収集を動的に切り替えて、UI コンポーザブルが直接読み取れる単一の State<HomeUiState> を公開します。transform と MutableState および SavedStateHandle.saveable の組み合わせについて詳しくは、ViewModel で Compose State を使用するをご覧ください。
transform、derivedStateOf、Flow のいずれかを選択します。
transform、derivedStateOf、Kotlin Flow のいずれかを選択するには、次のガイドラインを使用します。
derivedStateOf 対 transform
derivedStateOf と transform はどちらも、ブロック内で読み取られた Compose 状態を自動的に追跡する派生 State<T> を生成します。既存の State<T> オブジェクトから値を直接計算するか、collectAsState、produceState、LaunchedEffect などの @Composable 機能が必要かどうかに応じて、どちらかを選択します。
| 能力 | derivedStateOf |
transform |
|---|---|---|
| ラムダタイプ | 標準の Kotlin ラムダ(() -> T) |
@Composable ラムダ(@Composable (R) -> R) |
| サポートされている入力 | 同期 State<T> の読み取りのみ |
State<T>、Kotlin Flow(collectAsState を使用)、suspend 関数、Callback API(produceState を使用) |
| 実行と整合性 | .value が読み取られるときに遅延同期。initialValue または CoroutineScope なしで、同じスナップショット内でアトミックに更新 |
CoroutineScope でアクティブな Composition を実行し、スナップショットの commit 後に非同期で再コンポーズします。initialValue が必要です。 |
| 状態と副作用 | remember または副作用 API を呼び出すことはできません |
remember、LaunchedEffect、produceState、再利用可能な @Composable 関数をサポート |
すべての入力がすでに State<T> オブジェクトであり、@Composable API が不要な場合は、derivedStateOf を使用します。
- 同期、同じスナップショットの派生:
CoroutineScopeを管理したり、プレースホルダinitialValueを指定したりせずに、既存のState<T>プロパティから値またはフィルタされたリストを計算します。derivedStateOfは.valueの読み取り時に同期的に評価されるため、入力状態の更新直後に.valueを読み取る呼び出し元は、常に同じスナップショットで更新された結果を監視します。 - オブザーバーの更新頻度を減らす: ソース
State<T>よりも頻繁に変化しない値を計算します(スクロール オフセットがしきい値を超えているかどうかを確認するなど)。詳細については、derivedStateOfのドキュメントをご覧ください。
派生に Compose ランタイムが必要な場合は、transform を使用します。
State<T>と非同期ストリームの組み合わせ:State<T>入力とともにcollectAsStateを使用して、リポジトリまたはデータベースから 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 が再作成されないようにします。
transform ではなく Flow を使用する場合
transform は、イベントのストリームではなく、状態の現在の値を表すように設計されています。次の再コンポーズの前に状態が複数回変更された場合、Compose は最新の値のみを使用し、新しい値が以前の値と等しい(==)場合は更新をスキップします。ロジックですべての値を順番に処理する必要がある場合は、代わりに Kotlin の Flow を使用します。
- 累積合計と平均: 移動平均、累積合計、元に戻す履歴(
runningFoldまたはscan)などの計算では、すべての値を 1 回カウントする必要があります。transformブロックでは、再コンポジション前の急速な更新はスキップされ、2 回連続して同じ読み取りが行われても(センサーが10.0を 2 回出力するなど)、再コンポジションはトリガーされません。また、同じブロック内の他の状態によってトリガーされた再コンポジションでは、現在の値が 2 回カウントされることがあります。まずFlowで実行中の値を計算し、他の状態と組み合わせる必要がある場合は、transformで結果を収集します。 - イベント ロギングとキュー: ユーザー インタラクションをログに記録する、キューに格納されたネットワーク リクエストを処理する、またはすべての変更(
A -> B -> CまたはA -> B -> A)を記録する必要があるトランザクションを記録するパイプライン。 - 1 回限りのイベント: 音声の再生やハードウェア コマンドの送信などのアクション。同じコマンドを 2 回連続して送信すると、2 回実行されます。
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 の Composition がテスト スケジューラにバインドされるため、各 advance 呼び出しは FakeMediaRepository と FakeNetworkMonitor からの出力を決定論的に処理します。ViewModel で SavedStateHandle.saveable と Snapshot.withMutableSnapshot を使用するパターンの詳細については、ViewModel で Compose の State を使用するをご覧ください。