副作用とは、コンポーズ可能な関数の範囲外で発生するアプリの状態の変化を指します。コンポーザブルのライフサイクルとプロパティ(予測できない再コンポジション、異なる順序でのコンポーザブルの再コンポジション、破棄可能な再コンポジションなど)により、コンポーザブルは副作用がないようにするのが理想的です。
ただし、スナックバーを表示するなどの 1 回限りのイベントをトリガーする場合や、特定の状態で別の画面に移動する場合などに、副作用が必要になることがあります。これらのアクションは、コンポーザブルのライフサイクルを認識している制御された環境から呼び出す必要があります。このページでは、Jetpack Compose が提供する各種の副作用 API について学習します。
状態と作用のユースケース
Compose の思想のドキュメントで説明されているように、コンポーザブルは副作用なしであるべきです。アプリの状態を変更する必要がある場合は(状態の管理に関するドキュメントを参照)、作用 API を使用して、副作用が予測可能な方法で実行されるようにする必要があります。
作用はさまざまな機会に Compose で起動できるため、過剰に使われがちです。作用で行う処理が UI に関連していることと、単方向のデータフローを中断しないことを確認してください。これについては、状態の管理に関するドキュメントで説明されています。
LaunchedEffect
: コンポーザブルのスコープ内で suspend 関数を実行する
コンポーザブルの存続期間中に処理を実行し、suspend 関数を呼び出すには、LaunchedEffect
コンポーザブルを使用します。LaunchedEffect
が Composition に入場すると、コードブロックがパラメータとして渡されたコルーチンが起動されます。LaunchedEffect
が Composition から退場すると、コルーチンはキャンセルされます。LaunchedEffect
が別のキーで再コンポーズされた場合(下記の作用を再起動するセクションを参照)、既存のコルーチンはキャンセルされ、新しいコルーチン内で新しい suspend 関数が起動されます。
たとえば、次のアニメーションは、構成可能な遅延でアルファ値をパルスします。
// Allow the pulse rate to be configured, so it can be sped up if the user is running // out of time var pulseRateMs by remember { mutableStateOf(3000L) } val alpha = remember { Animatable(1f) } LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes while (isActive) { delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user alpha.animateTo(0f) alpha.animateTo(1f) } }
上記のコードでは、アニメーションが停止関数 delay
を使用して、設定された時間を待機します。次に、animateTo
を使用して、アルファを 0 にアニメーション化してから元に戻します。これはコンポーザブルの存続期間中繰り返されます。
rememberCoroutineScope
: コンポーザブルの外部でコルーチンを起動するために Composition 対応スコープを取得する
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 */ }
コールサイトのライフサイクルと一致する作用を作成するには、Unit
や true
のような決して変化しない定数をパラメータとして渡します。上記のコードでは、LaunchedEffect(true)
を使用しています。LandingScreen
が再コンポーズされた際の最新の値が onTimeout
ラムダに常に含まれるようにするには、onTimeout
を rememberUpdatedState
関数でラップする必要があります。返された 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 */ }
上記のコードでは、作用は observer
を lifecycleOwner
に追加します。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 の状態に変換できます。たとえば、外部のサブスクリプションに基づく状態(Flow
、LiveData
、RxJava
など)を 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
は最初に表示されるアイテムが変更されるたびに変更されます。スクロールすると、値は 0
、1
、2
、3
、4
、5
などになります。ただし、再コンポジションが必要なのは値が 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
このスニペットでは、fullName
を firstName
および 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 の一部の作用(LaunchedEffect
、produceState
、DisposableEffect
など)は、実行中の作用をキャンセルして新しいキーで新しい作用を開始するために使用する引数(キー)の変数番号を受け取ります。
これらの 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)
}
}
}
currentOnStart
と currentOnStop
は、rememberUpdatedState
の使用により Composition 内で値が変化しないため、DisposableEffect
キーとしては必要ありません。lifecycleOwner
がパラメータとして渡されずに変化した場合、HomeScreen
は再コンポーズされますが、DisposableEffect
は破棄および再起動されません。これにより、それ以降は間違った lifecycleOwner
が使用されることになり、問題が発生します。
キーとしての定数
作用キーとして true
のような定数を使用して、コールサイトのライフサイクルに従うようにすることができます。これには上記の LaunchedEffect
の例のような有効なユースケースがあります。ただし、いったん立ち止まって、それが本当に必要かどうかを確認してください。
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- 状態と Jetpack Compose
- Jetpack Compose で Kotlin を使用する
- Compose でビューを使用する