ライフサイクルと副作用

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

コンポーザブルのライフサイクル

状態の管理に関するドキュメントで説明されているように、Composition はアプリの UI の記述であり、コンポーザブルの実行により生成されます。Composition は、UI を記述するコンポーザブルのツリー構造です。

Jetpack Compose は、初回コンポジションで初めてコンポーザブルを実行する際に、Composition の UI を記述するために呼び出されるコンポーザブルをトラッキングします。その後、アプリの状態が変化すると、Jetpack Compose は再コンポジションをスケジュール設定します。再コンポジションの際、Jetpack Compose は状態の変化に応じて変化した可能性があるコンポーザブルを再実行し、変化を反映するために Composition を更新します。

Composition は、初回コンポジションによってのみ作成され、再コンポジションによってのみ更新されます。Composition を変更する唯一の方法は、再コンポジションを行うことです。

コンポーザブルのライフサイクルを示す図

図 1. Composition におけるコンポーザブルのライフサイクル。コンポーザブルは Composition に入場し、0 回以上再コンポースされ、Composition から退場します。

通常、再コンポジションは State<T> オブジェクトの変更によってトリガーされます。Compose はそうした変更をトラッキングし、特定の State<T> を読み取る Composition 内のすべてのコンポーザブルと、スキップできない呼び出し対象コンポーザブルを実行します。

コンポーザブルが複数回呼び出されると、複数のインスタンスが Composition 内に配置されます。各呼び出しには、Composition における固有のライフサイクルがあります。

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

上記のコード スニペットに含まれる要素の階層的な配置を示す図

図 2. Composition 内の MyComposable を表した図。コンポーザブルが複数回呼び出されると、複数のインスタンスが Composition 内に配置されます。色が異なる要素は、別のインスタンスであることを示します。

Composition 内のコンポーザブルの構造

Composition 内のコンポーザブルのインスタンスは、そのコールサイトによって識別されます。Compose コンパイラは、各コールサイトを別個のものと見なします。複数のコールサイトからコンポーザブルを呼び出すと、コンポーザブルの複数のインスタンスが Composition 内に作成されます。

再コンポジションの際にコンポーザブルが前回のコンポジションのときと異なるコンポーザブルを呼び出した場合、Compose はどのコンポーザブルが呼び出され、どのコンポーザブルが呼び出されなかったかを識別し、両方のコンポジションで呼び出されたコンポーザブルについては、入力が変化していなければ再コンポジションを回避します

ID の保持は、副作用をコンポーザブルに関連付けるために必要不可欠です。これにより、コンポーザブルは再コンポジションのたびに再起動されることなく、正常に完了することができます。

次の例を考えてみましょう。

@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

@Composable
fun LoginInput() { /* ... */ }

上記のコード スニペットでは、LoginScreen は条件付きで LoginError コンポーザブルを呼び出し、常に LoginInput コンポーザブルを呼び出します。各呼び出しには一意のコールサイトとソース位置があり、コンパイラはそれらを使用して呼び出しを一意に識別します。

showError フラグが true に変更された場合に上記のコードがどのように再コンポーズされるかを示す図。LoginError コンポーザブルは追加されますが、その他のコンポーザブルは再コンポーズされません。

図 3. 状態が変化して再コンポジションが発生したときの Composition 内の LoginScreen を表した図。同じ色の要素は、再コンポーズされていないことを示します。

LoginInput が初めて呼び出されてから二度目に呼び出された場合でも、LoginInput インスタンスは再コンポジションの前後で保持されます。さらに、LoginInput には再コンポジションの前後で変化したパラメータがないため、LoginInput の呼び出しは Compose によってスキップされます。

スマートな再コンポジションに役立つ情報を追加する

コンポーザブルを複数回呼び出すと、複数回 Composition に追加されます。あるコンポーザブルを同じコールサイトから複数回呼び出した場合、Compose はそのコンポーザブルの各呼び出しを一意に識別する情報を持っていないため、インスタンスを区別する情報として、コールサイトに加えて実行順序が使用されます。この動作は十分に必要を満たすこともありますが、場合によっては望ましくない動作を引き起こす可能性があります。

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // MovieOverview composables are placed in Composition given its
            // index position in the for loop
            MovieOverview(movie)
        }
    }
}

上記の例では、Compose はコールサイトに加えて実行順序を使用し、Composition 内のインスタンスを区別しています。新しい movie がリストの末尾に追加された場合、Compose は、すでに Composition 内にあるインスタンスを再利用できます。リスト内の位置が変化せず、したがってそれらのインスタンスでは movie 入力が同一であるからです。

新しい要素がリストの末尾に追加されたときに上記のコードがどのように再コンポーズされるかを示す図。リスト内の他のアイテムは位置が変化せず、再コンポーズされません。

図 4. 新しい要素がリストの末尾に追加されたときの Composition 内の MoviesScreen を表した図。Composition 内の MovieOverview コンポーザブルは再利用可能です。同じ色の MovieOverview は、コンポーザブルが再コンポーズされなかったことを示します。

一方、movies リストの先頭または途中にアイテムが追加されるか、アイテムが削除されたり並べ替えられたりしてリストが変更された場合は、入力パラメータのリスト内の位置が変更されたすべての MovieOverview 呼び出しで再コンポジションが発生します。たとえば、MovieOverview が副作用を使用して映画画像を取得する場合、この点は非常に重要です。作用の進行中に再コンポジションが発生すると、作用はキャンセルされ、再起動されます。

@Composable
fun MovieOverview(movie: Movie) {
    Column {
        // Side effect explained later in the docs. If MovieOverview
        // recomposes, while fetching the image is in progress,
        // it is cancelled and restarted.
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)

        /* ... */
    }
}

新しい要素がリストの先頭に追加されたときに上記のコードがどのように再コンポーズされるかを示す図。リスト内の他のすべてのアイテムは位置が変更され、再コンポーズが必要になります。

図 5. 新しい要素がリストに追加されたときの Composition 内の MoviesScreen を表した図。MovieOverview コンポーザブルは再利用できず、すべての副作用が再起動されます。色が異なる MovieOverview は、コンポーザブルが再コンポーズされたことを示します。

理想としては、MovieOverview インスタンスの ID はインスタンスに渡された movie の ID にリンクされていると考えることができます。映画のリストを並べ替える場合は、Composition ツリー内のインスタンスを同様の方法で並べ替えるのが理想的です。そうすれば、異なる映画インスタンスで個々の MovieOverview コンポーザブルを再コンポーズせずに済みます。Compose は、ツリーの特定の部分(key コンポーザブル)を識別するために使用したい値をランタイムに通知する方法を備えています。

キー コンポーザブルを呼び出すコードブロックを、渡された 1 つ以上の値でラップすることにより、それらの値が結合され、Composition 内の該当インスタンスを識別するために使用されます。key の値は、グローバルに一意である必要はありません。コールサイトのコンポーザブルの呼び出しの中で一意であれば十分です。したがって、この例では、各 moviemovies の中で一意の key を持つ必要があります。その key をアプリの他の場所にある他のコンポーザブルと共有してもかまいません。

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // Unique ID for this movie
                MovieOverview(movie)
            }
        }
    }
}

上記の例では、リストの要素が変更された場合でも、Compose は MovieOverview の個々の呼び出しを認識してそれらを再利用できます。

新しい要素がリストの先頭に追加されたときに上記のコードがどのように再コンポーズされるかを示す図。リストアイテムはキーによって識別されるため、Compose は、アイテムの位置が変更されても再コンポーズしなくてよいことを認識します。

図 6. 新しい要素がリストに追加されたときの Composition 内の MoviesScreen を表した図。MovieOverview コンポーザブルは一意のキーを持っているため、Compose は変化していない MovieOverview インスタンスを認識して、それらを再利用できます。それらの副作用は引き続き実行されます。

一部のコンポーザブルは、key コンポーザブルの組み込みサポートを備えています。たとえば、LazyColumn は、items DSL 内のカスタム key の指定を受け入れます。

@Composable
fun MoviesScreen(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

入力が変化していない場合にスキップする

コンポーザブルがすでに Composition 内にある場合、すべての入力が安定していて変化がなければ、再コンポジションをスキップできます。

安定した型は次のコントラクトに従う必要があります。

  • 2 つのインスタンスの equals の結果が、同じ 2 つのインスタンスについて常に同じになる。
  • 型の公開プロパティが変化すると、Composition に通知される。
  • すべての公開プロパティの型も安定している。

このコントラクトに従う型の中には、明示的に @Stable アノテーションによって安定しているとマークされていなくても、Compose コンパイラが安定しているものとして扱う重要で一般的な型がいくつかあります。

  • すべてのプリミティブ値型: BooleanIntLongFloatChar など
  • 文字列
  • すべての関数型(ラムダ)

これらの型はすべて不変であるため、安定性のコントラクトに従うことが可能です。不変の型は決して変化しないので、Composition に変化を通知する必要がありません。したがって、このコントラクトに従う方がはるかに簡単です。

安定しているが可変である型の代表例は、Compose の MutableState 型です。値が MutableState に保持されている場合、State.value プロパティに対する変更は Compose に通知されるため、状態オブジェクトは全体として安定していると見なされます。

コンポーザブルにパラメータとして渡されるすべての型が安定している場合、UI ツリー内のコンポーザブルの位置に基づいてパラメータ値が等しいかどうかが比較されます。前回の呼び出し以降、すべての値が変化していなければ、再コンポジションはスキップされます。

Compose は、証明できる場合にのみ、型を安定していると見なします。たとえば、インターフェースは一般的に安定していないものとして扱われます。実装が不変である可能性がある可変の公開プロパティを持つ型も安定していません。

Compose が安定していると推測できない型を安定しているものとして扱うことを Compose に強制するには、@Stable アノテーションでマークします。

// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}

上記のコード スニペットでは、UiState はインターフェースであるため、Compose は、通常はこの型を安定していないと見なす可能性があります。@Stable アノテーションを追加すると、この型が安定していると Compose に伝えて、Compose にスマートな再コンポジションを選択させることができます。また、インターフェースがパラメータ型として使用されている場合、Compose はそのすべての実装を安定しているものとして扱います。

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

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` 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` 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 の例のような有効なユースケースがあります。ただし、いったん立ち止まって、それが本当に必要かどうかを確認してください。