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

このページでは、コンポーザブルのライフサイクルと、コンポーザブルに再コンポーズが必要かどうかを Compose が判断する仕組みについて説明します。

ライフサイクルの概要

状態の管理に関するドキュメントで説明されているように、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() { /* ... */ }

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

上記のコード スニペットでは、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 MoviesScreenWithKey(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 MoviesScreenLazy(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

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

再コンポジション中に、対象となるコンポーザブル関数の入力が前回のコンポジションから変更されていない場合、その関数の実行が完全にスキップされることがあります。

コンポーズ可能な関数は、次の条件を満たさない限りスキップできます。

  • 関数の戻り値の型が Unit 以外である
  • 関数に @NonRestartableComposable または @NonSkippableComposable アノテーションが付けられている
  • 必須パラメータの型が安定していない

試験運用版のコンパイラ モードであるStrong Skipping では、最後の要件が緩和されます。

型が安定していると見なされるには、次のコントラクトに準拠している必要があります。

  • 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 はそのすべての実装を安定しているものとして扱います。