Compose における副作用

コンポーザブルは副作用なしであるべきです。しかし、コンポーザブルでアプリの状態を変更する必要がある場合は、コンポーザブルのライフサイクルを認識している制御された環境からコンポーザブルを呼び出す必要があります。このページでは、Jetpack Compose が提供する各種の副作用 API について学習します。

状態と作用のユースケース

Compose の思想のドキュメントで説明されているように、コンポーザブルは副作用なしであるべきです。アプリの状態を変更する必要がある場合は(状態の管理に関するドキュメントを参照)、作用 API を使用して、副作用が予測可能な方法で実行されるようにする必要があります

作用はさまざまな機会に Compose で起動できるため、過剰に使われがちです。作用で行う処理が UI に関連していることと、単方向のデータフローを中断しないことを確認してください。これについては、状態の管理に関するドキュメントで説明されています。

LaunchedEffect: コンポーザブルのスコープ内で suspend 関数を実行する

コンポーザブル内から suspend 関数を安全に呼び出すには、LaunchedEffect コンポーザブルを使用します。LaunchedEffect が Composition に入場すると、コードブロックがパラメータとして渡されたコルーチンが起動されます。LaunchedEffect が Composition から退場すると、コルーチンはキャンセルされます。LaunchedEffect が別のキーで再コンポーズされた場合(下記の作用を再起動するセクションを参照)、既存のコルーチンはキャンセルされ、新しいコルーチン内で新しい suspend 関数が起動されます。

たとえば、ScaffoldSnackbar を表示する処理は、suspend 関数である SnackbarHostState.showSnackbar 関数で行われます。

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(scaffoldState.snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError` is true
            // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}

上記のコードでは、コルーチンは状態にエラーが含まれる場合はトリガーされ、含まれない場合はキャンセルされます。LaunchedEffect コールサイトが if ステートメント内にあるため、条件が false のときは、LaunchedEffect が Composition 内にあれば削除され、その場合コルーチンはキャンセルされます。

rememberCoroutineScope: コンポーザブルの外部でコルーチンを起動するために Composition 対応スコープを取得する

LaunchedEffect はコンポーズ可能な関数であるため、他のコンポーズ可能な関数内でのみ使用できます。コンポーザブルの外部でコルーチンを起動するために、Composition から退場すると自動的にキャンセルされるスコープを設定するには、rememberCoroutineScope を使用します。また、1 つ以上のコルーチンのライフサイクルを手動で制御する必要がある場合(たとえば、ユーザー イベントが発生したときにアニメーションをキャンセルする場合)も、常に rememberCoroutineScope を使用します。

rememberCoroutineScope は、自身が呼び出された Composition のポイントにバインドされた CoroutineScope を返すコンポーズ可能な関数です。呼び出しが Composition から退場すると、スコープはキャンセルされます。

上記の例では、ユーザーが Button をタップしたときに、次のコードを使用して Snackbar を表示できます。

@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler
                    // to show a snackbar
                    scope.launch {
                        scaffoldState.snackbarHostState
                            .showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState: 値が変化しても再起動すべきでない作用の値を参照する

LaunchedEffect は、いずれかのキーパラメータが変化すると、再起動されます。ただし、状況によっては、値が変化しても再起動されない作用の値をキャプチャしたい場合があります。そのためには、rememberUpdatedState を使用して、この値への参照を作成し、キャプチャおよび更新できるようにする必要があります。このアプローチは、再作成または再起動が高コストであるか実際的でない長期的なオペレーションを含む作用で役立ちます。

たとえば、表示されてしばらくすると消える LandingScreen がアプリに含まれているとします。LandingScreen が再コンポーズされても、しばらく待機して時間が経過したことを通知する作用は再起動するべきではありません。

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

コールサイトのライフサイクルと一致する作用を作成するには、Unittrue のような決して変化しない定数をパラメータとして渡します。上記のコードでは、LaunchedEffect(true) を使用しています。LandingScreen が再コンポーズされた際の最新の値が onTimeout ラムダに常に含まれるようにするには、onTimeoutrememberUpdatedState 関数でラップする必要があります。返された State(コード内では currentOnTimeout)を作用で使用する必要があります。

DisposableEffect: クリーンアップが必要な作用

キーが変化した後またはコンポーザブルが Composition から退場したときにクリーンアップする必要がある副作用については、DisposableEffect を使用します。DisposableEffect キーが変化した場合、コンポーザブルはその現在の作用を破棄(クリーンアップ)して、作用を再度呼び出すことによりリセットする必要があります。

たとえば、OnBackPressedDispatcher で [戻る] ボタンが押されたことをリッスンするには、OnBackPressedCallback を登録する必要があります。Compose でこのイベントをリッスンするには、DisposableEffect を使用し、必要に応じてコールバックの登録と登録解除を行います。

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {

    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for
        // a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

上記のコードでは、作用は記憶された backCallbackbackDispatcher に追加します。backDispatcher が変化すると、作用は破棄されて再起動されます。

DisposableEffect は、コードブロックの最後のステートメントとして onDispose 句を含んでいる必要があります。含んでいない場合、IDE はビルド時にエラーを表示します。

SideEffect: Compose の状態を非 Compose コードに公開する

Compose が管理していないオブジェクトと Compose の状態を共有するには、再コンポジションが成功するたびに呼び出される SideEffect コンポーザブルを使用します。

前述の BackHandler コードの例では、コールバックを有効にするかどうかを伝えるために、SideEffect を使用してその値を更新しています。

@Composable
fun BackHandler(
    backDispatcher: OnBackPressedDispatcher,
    enabled: Boolean = true, // Whether back events should be intercepted or not
    onBack: () -> Unit
) {
    /* ... */
    val backCallback = remember { /* ... */ }

    // On every successful composition, update the callback with the `enabled` value
    // to tell `backCallback` whether back events should be intercepted or not
    SideEffect {
        backCallback.isEnabled = enabled
    }

    /* Rest of the code */
}

produceState: Compose 外の状態を Compose の状態に変換する

produceState は、Composition をスコープとするコルーチンを起動します。これにより、返される State に値をプッシュできます。これを使用して、Compose 外の状態を Compose の状態に変換できます。たとえば、外部のサブスクリプションに基づく状態(FlowLiveDataRxJava など)を Composition に取り込みます。

プロデューサーは、produceState が Composition に入場すると起動され、Composition から退場するとキャンセルされます。返された State は合成されます。同じ値を設定しても再コンポジションはトリガーされません。

produceState はコルーチンを作成しますが、停止していないデータソースを観測するために使用することもできます。該当するソースのサブスクリプションを削除するには、awaitDispose 関数を使用します。

次の例は、produceState を使用してネットワークから画像を読み込む方法を示しています。loadNetworkImage コンポーザブルは、他のコンポーザブルで使用できる State を返します。

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new keys.
    return produceState(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf: 1 つ以上の状態オブジェクトを別の状態に変換する

特定の状態が他の状態オブジェクトから計算(導出)される場合は、derivedStateOf を使用します。この関数を使用すると、計算で使用される状態のいずれかが変化したときにのみ計算が行われることが保証されます。

次の例は、優先度の高いユーザー定義キーワードを含むタスクが最初に表示される基本的な TO DO リストを示しています。

@Composable
fun TodoList(
    highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")
) {
    val todoTasks = remember { mutableStateListOf<String>() }

    // Calculate high priority tasks only when the todoTasks or
    // highPriorityKeywords change, not on every recomposition
    val highPriorityTasks by remember(todoTasks, highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter { it.containsWord(highPriorityKeywords) }
        }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

上記のコードでは、derivedStateOf によって、todoTasks または highPriorityKeywords が変化するたびに highPriorityTasks の計算が行われ、それに応じて UI が更新されることが保証されます。highPriorityTasks を計算するためのフィルタリングは高コストになる可能性があるため、再コンポジションのたびに実行するのではなく、リストのいずれかが変化したときにのみ実行する必要があります。

さらに、derivedStateOf によって生成された状態を更新しても、状態が宣言されているコンポーザブルの再コンポジションは発生しません。Compose は、この例の LazyColumn では、返された状態が読み取られるコンポーザブルのみを再コンポーズします。

snapshotFlow: Compose の State を Flow に変換する

State<T> オブジェクトをコールド Flow に変換するには、snapshotFlow を使用します。snapshotFlow は、収集される際にそのブロックを実行し、その中で読み取られた State オブジェクトの結果を出力します。snapshotFlow ブロック内で読み取られた State オブジェクトのいずれかが変化したとき、新しい値が前回出力された値と一致しない場合、Flow はそのコレクタに新しい値を出力します(この動作は Flow.distinctUntilChanged の動作と似ています)。

次の例は、リスト内の最初のアイテムをいつユーザーがスクロールして通過したかをアナリティクスに記録する副作用を示しています。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

上記のコードでは listState.firstVisibleItemIndex が Flow に変換され、それにより Flow の演算子が持つ利点を活用できます。

作用を再起動する

Compose の一部の作用(LaunchedEffectproduceStateDisposableEffect など)は、実行中の作用をキャンセルして新しいキーで新しい作用を開始するために使用する引数(キー)の変数番号を受け取ります。

これらの API の一般的な形式は次のとおりです。

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

この動作は微妙であるため、作用の再起動に使用されるパラメータが適切なパラメータでない場合、問題が発生する可能性があります。

  • 再起動する作用が必要な数より少ない場合は、アプリの不具合を引き起こすことがあります。
  • 再起動する作用が必要な数より多い場合は、効率が低下します。

経験則上、コードの作用ブロックで使用される可変および不変の変数は、作用コンポーザブルにパラメータとして追加する必要があります。それとは別に、作用の再起動を強制する際は、パラメータをさらに追加できます。変数の変化が作用の再起動を発生させるべきでない場合は、変数を rememberUpdatedState でラップします。変数がキーなしで remember にラップされているために決して変化しない場合は、変数をキーとして作用に渡す必要はありません。

上記の DisposableEffect のコードでは、作用は、ブロックで使用されている backDispatcher をパラメータとして受け取ります。これが変更された場合、作用を再起動するべきであるからです。

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
    /* ... */
    val backCallback = remember { /* ... */ }

    DisposableEffect(backDispatcher) {
        backDispatcher.addCallback(backCallback)
        onDispose {
            backCallback.remove()
        }
    }
}

backCallback は、Composition 内で値が決して変化しないため、DisposableEffect キーとしては不要であり、キーなしで remember にラップされています。backDispatcher がパラメータとして渡されずに変化した場合、BackHandler は再コンポーズされますが、DisposableEffect は破棄および再起動されません。その時点以降は間違った backDispatcher が使用されるため、問題が発生します。

キーとしての定数

作用キーとして true のような定数を使用して、コールサイトのライフサイクルに従うようにすることができます。これには上記の LaunchedEffect の例のような有効なユースケースがあります。ただし、いったん立ち止まって、それが本当に必要かどうかを確認してください。