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 を使用して、リポジトリまたはデータベースから 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 が再作成されないようにします。

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 を組み合わせます。

  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 の Composition がテスト スケジューラにバインドされるため、各 advance 呼び出しは FakeMediaRepository と FakeNetworkMonitor からの出力を決定論的に処理します。ViewModel で SavedStateHandle.saveable と Snapshot.withMutableSnapshot を使用するパターンの詳細については、ViewModel で Compose の State を使用するをご覧ください。