コンポーザブルは副作用なしであるべきです。しかし、コンポーザブルでアプリの状態を変更する必要がある場合は、コンポーザブルのライフサイクルを認識している制御された環境からコンポーザブルを呼び出す必要があります。このページでは、コンポーザブルのライフサイクルと、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
コンポーザブルを呼び出します。各呼び出しには一意のコールサイトとソース位置があり、コンパイラはそれらを使用して呼び出しを一意に識別します。
図 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
の値は、グローバルに一意である必要はありません。コールサイトのコンポーザブルの呼び出しの中で一意であれば十分です。したがって、この例では、各 movie
は movies
の中で一意の 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
の個々の呼び出しを認識してそれらを再利用できます。
図 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 コンパイラが @Stable
として扱う重要で一般的な型がいくつかあります。
- すべてのプリミティブ値型:
Boolean
、Int
、Long
、Float
、Char
など - 文字列
- すべての関数型(ラムダ)
これらの型はすべて不変であるため、@Stable
のコントラクトに従うことが可能です。不変の型は決して変化しないので、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 関数が起動されます。
たとえば、Scaffold
に Snackbar
を表示する処理は、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 */
}
コールサイトのライフサイクルと一致する作用を作成するには、Unit
や true
のような決して変化しない定数をパラメータとして渡します。上記のコードでは、LaunchedEffect(true)
を使用しています。LandingScreen
が再コンポーズされた際の最新の値が onTimeout
ラムダに常に含まれるようにするには、onTimeout
を rememberUpdatedState
関数でラップする必要があります。返された 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()
}
}
}
上記のコードでは、作用は記憶された backCallback
を backDispatcher
に追加します。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 の状態に変換できます。たとえば、外部のサブスクリプションに基づく状態(Flow
、LiveData
、RxJava
など)を 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
では、返された状態が読み取られるコンポーザブルのみを再コンポーズします。
作用を再起動する
Compose の一部の作用(LaunchedEffect
、produceState
、DisposableEffect
など)は、実行中の作用をキャンセルして新しいキーで新しい作用を開始するために使用する引数(キー)の変数番号を受け取ります。
これらの 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
の例のような有効なユースケースがあります。ただし、いったん立ち止まって、それが本当に必要かどうかを確認してください。