Compose における副作用

副作用とは、コンポーズ可能な関数の範囲外で発生するアプリの状態の変化を指します。コンポーザブルのライフサイクルとプロパティ(予測できない再コンポジション、異なる順序でのコンポーザブルの再コンポジション、破棄可能な再コンポジションなど)により、コンポーザブルは副作用がないようにするのが理想的です。

ただし、スナックバーを表示するなどの 1 回限りのイベントをトリガーする場合や、特定の状態で別の画面に移動する場合などに、副作用が必要になることがあります。これらのアクションは、コンポーザブルのライフサイクルを認識している制御された環境から呼び出す必要があります。このページでは、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>>,
    snackbarHostState: SnackbarHostState
) {

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

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(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.
            snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        // ...
    }
}

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

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

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

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

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

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

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

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        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 キーが変化した場合、コンポーザブルはその現在の作用を破棄(クリーンアップ)して、作用を再度呼び出すことによりリセットする必要があります。

たとえば、LifecycleObserver を使用し、Lifecycle イベントに基づいて分析イベントを送信する場合を考えます。Compose でこのイベントをリッスンするには、DisposableEffect を使用し、必要に応じてオブザーバーの登録と登録解除を行います。

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

上記のコードでは、作用は observerlifecycleOwner に追加します。lifecycleOwner が変化すると、作用は破棄されて新しい lifecycleOwner で再起動されます。

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

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

Compose で管理されていないオブジェクトと Compose の状態を共有するには、SideEffect コンポーザブルを使用します。SideEffect を使用すると、再コンポーズが成功するたびに効果が実行されることが保証されます。一方、再コンポーズの成功が保証される前に作用を実行することは誤りです。これはコンポーザブルに作用を直接書き込む場合です。

たとえば、分析ライブラリを使用すると、カスタム メタデータ(この例では「ユーザー プロパティ」)を後続のすべての分析イベントにアタッチすることで、ユーザー全体をセグメント化できます。現在のユーザーのユーザータイプを分析ライブラリに伝えるには、SideEffect を使用してその値を更新します。

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

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 = 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 inputs.
    return produceState<Result<Image>>(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 つ以上の状態オブジェクトを別の状態に変換します。

Compose では、監視している状態オブジェクトまたはコンポーザブル入力が変更されるたびに、再コンポジションが発生します。状態オブジェクトまたは入力は実際に UI を更新する必要がある頻度よりも頻繁に変更される場合があり、不要な再コンポジションにつながります。

再コンポーズする必要がある頻度よりも頻繁にコンポーザブルへの入力が変更される場合、derivedStateOf 関数を使用する必要があります。通常は、スクロール位置などが頻繁に変更されるものの、コンポーザブルは特定のしきい値を超えた場合のみ対応する必要があるケースが該当します。derivedStateOf は監視できる新しい Compose 状態オブジェクトを作成し、それは必要な頻度でのみ更新されます。この方法により、Kotlin Flow の distinctUntilChanged() 演算子と同様に機能するようになります。

正しい使用方法

次のスニペットは derivedStateOf の適切なユースケースを示しています。

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

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

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

このスニペットでは、firstVisibleItemIndex は最初に表示されるアイテムが変更されるたびに変更されます。スクロールすると、値は 012345 などになります。ただし、再コンポジションが必要なのは値が 0 より大きくなった場合のみです。このような更新の頻度が一致しない場合は、derivedStateOf の使用が適したユースケースです。

不適切な使用方法

よくある間違いは 2 つの Compose 状態オブジェクトを組み合わせる場合に、「状態を導出」しているため derivedStateOf を使用するケースです。次のスニペットのとおり、これは純粋にオーバーヘッドであり、必須ではありません。

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

このスニペットでは、fullNamefirstName および lastName と同じ頻度で更新する必要があります。そのため、過度な再コンポジションは発生せず、derivedStateOf を使用する必要はありません。

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 のコードでは、作用は、ブロックで使用されている lifecycleOwner をパラメータとして受け取ります。これが変化した場合に、作用を再起動する必要があるからです。

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

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

キーとしての定数

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