1. 「引数は変わっていない」のに再描画される怪奇現象

Jetpack Composeのメンタルモデルを学ぶとき、私たちは最初にこう教わります。「Composeは賢い。入力パラメータが前回の描画時と同一(等価)であれば、そのComposableの実行は自動的にスキップされる(Smart Recomposition)」。

しかし、実際のプロダクトコードでリスト表示画面を組んでLayout Inspectorの「Recomposition Counts」を覗いてみると、背筋が凍るような光景を目撃することになります。

// よくある画面全体のStateとコンポーネント
data class SearchScreenUiState(
    val searchQuery: String = "",
    val searchResults: List<ArticleItem> = emptyList()
)

@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Column {
        // 検索ボックスに1文字入力するたびに uiState.searchQuery が更新される
        SearchInputField(
            query = uiState.searchQuery,
            onQueryChanged = viewModel::onQueryChanged
        )

        // 検索結果リストはまだ1件も変わっていないはずなのに…?
        ArticleListSection(items = uiState.searchResults)
    }
}

@Composable
fun ArticleListSection(items: List<ArticleItem>) {
    // なぜか searchQuery が変わるたびに毎回ここがスキップされずに再実行される!
    LazyColumn {
        items(items) { item ->
            ArticleRow(item = item)
        }
    }
}

ユーザーが検索ボックスに文字をカタカタとタイピングするたび、searchResults の中身は1ミリも変わっていないにもかかわらず、ArticleListSection とその配下のツリー全体が無慈悲に再評価されます。アイテム数が増えたりカスタム描画が増えると、タイピングのレスポンスが鈍り、フレームレートは一瞬で60fpsを割り込みます。

「List の参照も内容も同じなのに、なぜスキップしてくれないのか?」

その答えは、Composeコンパイラが裏で吐き出す composables.txt レポートの中にあります。

// Compose Compiler Metrics の解析結果
restartable fun ArticleListSection(
  unstable items: List<ArticleItem>
)

本来ならば restartable skippable と出力されるべき関数定義に、skippable のフラグが付いていません。そして引数の横には、赤文字で警告されるかのように unstable items と刻まれています。

Composable関数がスキップ可能(skippable)になるための絶対条件は、「すべての引数が安定(Stable)であること」である。1つでも不安定(Unstable)な引数が混入した瞬間、Composeはその関数のスキップを完全に諦める。

2. なぜ Kotlin の `List` は Unstable と判定されるのか?

JavaからKotlinに移行したエンジニアほど、こう反論したくなります。「Kotlinの List は読み取り専用(Read-Only)インターフェースのはずだ。MutableList と明確に分かれているのに、なぜコンパイラは不安定だと決めつけるのか?」と。

ここに、言語仕様と宣言的UIコンパイラとの深い認識ギャップが存在します。

Kotlinの `List` は「Immutable」ではなく、ただの「Read-Only View」

Kotlinの List<E> は、書き換えメソッド(add や remove)を外部に公開していないだけであり、基盤となるデータ構造が不変であることを何ら保証していません。

// 悪意がなくても簡単に起きる実体の書き換え
val mutableList = mutableListOf("A", "B", "C")
val readOnlyList: List<String> = mutableList // 型は List だが…

// 参照先の実体は MutableList なので、別の場所で書き換わる
mutableList.add("D") 

JVMバイトコードレベルでは、Kotlinの List は単なる java.util.List にマッピングされています。誰かが (items as MutableList).add(...) とキャストして破壊的変更を加えることもできれば、別スレッドから裏で要素を操作することも可能です。

Composeコンパイラが関数を安全にスキップするためには、以下の契約(Stability Contract)が厳密に守られている必要があります。

インターフェースである List<T> は、実装クラスが将来にわたって不変である確証がゼロです。そのためComposeコンパイラは、安全側に倒す原則(Conservative Fallback)に従い、Kotlin標準の List, Set, Map などのコレクションインターフェースをすべて一律で「Unstable」と分類します。

結果として、List を引数に取ったComposableは、コンパイラから「いつ中身が書き換わっているか分からない危険物」と見なされ、親が再描画されるたびに無条件で再実行される運命を辿るのです。

3. 「Strong Skipping Mode」の甘い罠とCPUオーバーヘッド

Compose Compiler 1.5.4以降で導入され、Kotlin 2.0 / Compose 1.7 でデフォルト有効化が進められたのが Strong Skipping Mode です。

「Strong Skipping を有効にすれば、Unstable な引数を持つComposableも自動的にスキップされるようになるから、もう List の問題は解決した」と語る開発者がいます。しかし、これは現場のプロファイリングを軽視した危険な誤解です。

罠①: 毎フレームの `equals()` による O(N) 比較地雷

Strong Skipping Mode が行う処理は魔法ではありません。Unstable な引数に対して、インスタンス参照比較(===)ではなく、構造的同値比較(equals())を強制適用することでスキップを実現しています。

// Strong Skipping が有効なとき、コンパイラが裏で挿入する判定ロジック(概念コード)
if ($dirty or (!items.equals($previousItems))) {
    ArticleListSection(items)
} else {
    $composer.skipToGroupEnd()
}

何が起きるか想像してみてください。リストの要素数が数百件、数千件におよぶ場合、親コンポーネントが再描画されるたびに、Composeランタイムは 要素全件のディープな equals() 走査(O(N))をメインスレッドで実行 します。

「Recompositionの実行自体を防ぐために、Recomposition以上のCPUコストをかけて毎フレーム巨大なリストを線形探索で比較する」。これは本末転倒のパフォーマンス自殺です。

罠②: 要素型自体の Unstable 感染

さらに悪いことに、リストの要素である ArticleItem が Unstable な場合(外部モジュールの型を使っている、var プロパティがある等)、List.equals() の判定そのものが意味をなさず、Strong Skipping は結局スキップを断念します。Unstable はウイルスのように親から子へ、コンテナから要素へと感染していきます。

4. 現場で採るべき3つの実践的処方箋

では、現場のプロダクション環境でこの Unstable 地獄を根絶するにはどうすればいいのか。対症療法ではなく、アーキテクチャとして破綻しない3つのアプローチを提示します。

処方箋①: `kotlinx.collections.immutable` の導入(最も堅牢)

Jetpack Composeチーム自身が公式に推奨し、最も型安全な解決策が、公式ライブラリ kotlinx.collections.immutable の活用です。

// build.gradle.kts
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList

// 1. UI State の型を ImmutableList に変更
data class ArticleListUiState(
    val items: ImmutableList<ArticleItem> = persistentListOf()
)

// 2. Composable 側も ImmutableList を厳格に要求する
@Composable
fun ArticleListSection(
    items: ImmutableList<ArticleItem>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier = modifier) {
        items(
            items = items,
            key = { it.id } // LazyLayout の key 指定も忘れない
        ) { item ->
            ArticleRow(item = item)
        }
    }
}

Composeコンパイラには ImmutableList や PersistentList を「本物の不変コレクション」として認識する特別なルールが組み込まれています。これにより、関数の引数は正真正銘の Stable と判定され、無駄な equals() 全走査を挟むことなく、高速なインスタンス比較による Smart Recomposition が完璧に機能します。

処方箋②: UI State ホルダーへの `@Immutable` アノテーション付与

マルチモジュール構成や既存の巨大なコードベースで、ドメイン層から流れてくるすべてのリスト型を即座に ImmutableList に書き換えるのが難しいケースもあります。その場合の現実的な選択肢が、UI State を包むデータクラスへの @Immutable 付与です。

import androidx.compose.runtime.Immutable

@Immutable
data class ArticleListUiState(
    // 内部に標準の List を含んでいても、クラス全体が「不変である」とコンパイラに誓約する
    val items: List<ArticleItem> = emptyList(),
    val isLoading: Boolean = false
)

@Composable
fun ArticleScreen(uiState: ArticleListUiState) {
    // uiState 自体が Stable と扱われるため、この関数は安全に skippable となる
    ArticleContent(uiState = uiState)
}
注意: @Immutable や @Stable アノテーションは、コンパイラに対する「開発者の誓約書」です。もし約束を破り、内部の items に可変なリストを代入して要素を後から書き換えた場合、「値が変わったのに画面が一切再描画されない」という極めて追跡困難なゴーストバグを引き起こします。

処方箋③: Stability Configuration による外部型の一括安定化

Kotlin 1.9 / Compose Compiler 1.5.4 以降では、プロジェクト設定で「特定の型を強制的に Stable とみなす構成ファイル」を指定できるようになりました。

// compose_compiler_config.conf
// プロジェクトルートに配置し、標準コレクションや外部SDKモデルを安定化
kotlin.collections.List
kotlin.collections.Set
kotlin.collections.Map
com.example.network.model.*
// build.gradle.kts
composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
}

チーム全員が「UI層に渡すコレクションは絶対に破壊的変更しない」という規約を徹底できているシニア主導のプロジェクトであれば、この設定1枚でプロジェクト全体の不要なRecompositionを一掃することが可能です。

5. 設計思想:そもそも巨大なListをそのまま渡すな

技術的なハックやアノテーションを駆使する前に、立ち止まって設計そのものを見直す勇気も必要です。「なぜそのComposableは、数十〜数百件のオブジェクトが詰まった List をまるごと受け取る必要があるのか?」

コンポーネントがリスト全体を要求しているとき、多くの場合、そのコンポーネントは責務過多に陥っています。

// リストそのものを渡さず、件数とプロバイダ関数(ラムダ)に分解するミニマル設計
@Composable
fun CompactListContainer(
    itemCount: Int,
    itemContent: @Composable (index: Int) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier = modifier) {
        items(itemCount) { index ->
            itemContent(index)
        }
    }
}

引数をプリミティブ値(Int)や関数型インターフェース(ラムダ)に切り詰めることで、Composableはコレクション型のUnstable問題から完全に解放されます。粒度を極限まで小さく保ち、必要なデータだけをピンポイントで渡す——これこそが宣言的UIにおける最高のパフォーマンスチューニングです。

おわりに: 宣言的UIの美しさは、コンパイラの数理の上に成り立つ

Jetpack Composeのコードは宣言的で美しく、直感的に書けます。しかしその裏側では、緻密なコード生成とコンパイラによる静的解析が休みなく走り、ミリ秒単位の描画パフォーマンスを担保しています。

「List は読み取り専用だから安全だろう」という直感を捨て、コンパイラがどのように型を解釈し、どこでスキップを諦めるのかを理解すること。安易なリスト渡しをやめ、適切な不変性の契約を結ぶこと。その泥臭い配慮の積み重ねの先にしか、指先に吸い付くような60fps / 120fpsの極上のアプリ体験は存在しません。