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)が厳密に守られている必要があります。
- 同一インスタンスの比較結果が変わらないこと: 同じ2つのインスタンスに対する
equalsの比較結果は、常に同じ値を返す。 - 公開プロパティの変更通知: 公開プロパティが変更された場合、Composeランタイムへ即座に通知される(あるいはプロパティが完全不変である)。
- すべての公開プロパティの型が Stable であること。
インターフェースである 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の極上のアプリ体験は存在しません。