1. 「とりあえず derivedStateOf で囲む」シンドローム

Jetpack Compose で Android アプリを開発していると、誰もが一度は直面する壁があります。それが 「リストをスクロールすると微妙にコマ落ちする(ジャンクが発生する)」「入力フォームで1文字打つたびに画面全体が一瞬チラつく」 という、不要な再コンポジション(Recomposition)の問題です。

そんな時、Web上の技術記事やQ&Aサイトで頻繁に見かけるのが次のフレーズです:

「状態から別の値を計算する時は remember { derivedStateOf { ... } } を使いましょう。不要な再コンポジションを防ぐことができます!」

この説明を文字通りに受け取った結果、現場のコードベースではあらゆる場所に derivedStateOf が乱れ撃ちされる事態が多発しています。入力フォームの空文字チェック、単純なリストの件数カウント、さらには計算コストがゼロに近い単純プロパティの参照まで……。

しかし、あえて断言しましょう。「とりあえず derivedStateOf で囲んでおく」のは、多くの場合において無駄であり、最悪の場合はアプリを余計に遅くするアンチパターン です。

「derivedStateOf は計算結果をキャッシュするための汎用メモ化ツールではない。高頻度に変化する状態から、低頻度にしか変化しない状態へと更新頻度を落とすための『減速ギア(ローパスフィルタ)』である。」

なぜそう言い切れるのか? それを理解するためには、Compose の根底を支える「スナップショットシステム(Snapshot State System)」の内部で何が起きているのかを覗き見る必要があります。

2. スナップショットシステムの内部解剖: derivedStateOf は何をしているのか

Jetpack Compose の状態追跡は、ブラックボックスな魔法ではなく、緻密に設計されたデータ構造によって成り立っています。

2.1 通常の State 読み取りと RecomposeScope

Composable 関数の中で mutableStateOf(0) のような State の value を読み取ると、Compose ランタイムは「現在の Composable のスコープ(RecomposeScope)がこの State に依存している」という事実を、スレッドローカルな読み取りログ(Read Observer)に記録します。

その後、State の値が書き換えられると、登録されていた RecomposeScope に無効化(Invalidation)のフラグが立ち、次のフレーム描画のタイミングでその Composable 関数が丸ごと再実行されます——これが再コンポジションの基本原則です。

2.2 derivedStateOf の内部構造とオーバーヘッド

では、derivedStateOf { ... } を呼び出すと内部では何が起きるのでしょうか? 実態は DerivedSnapshotState という特殊な状態オブジェクトの生成です。

derivedStateOf のブロックが評価される際、Compose は以下の一連の処理を実行します:

  1. ネストされたスナップショットの開始: ブロック内でどの State が読み取られたかを追跡するための専用オブザーバーを展開する。
  2. 依存関係グラフの構築: ブロック内で読まれたすべての State への参照と、その読み取りバージョンを配列・リスト構造で保持する。
  3. 依存 State の変更検知: 依存しているいずれかの State が書き換わった時、derivedStateOf の内部フックが発火する。
  4. 再計算と等価性判定: 計算ロジックを再評価し、前回の戻り値と今回の戻り値を equals() で比較する。
  5. スコープ無効化の制御: equals() が true(値が変わっていない)であれば、下流の RecomposeScope への無効化通知を完全に握りつぶす(ミュートする)。値が異なっていた場合のみ、初めて Composable に再コンポジションを要求する。

ここが最大のキモです。derivedStateOf はタダではありません。「依存状態の走査」「ネストされた読み取り監視」「アロケーション」「前後の戻り値の equals() 比較」という明確な計算コスト を毎回の状態更新時に支払っています。

つまり、上流の State が変化した時に戻り値も毎回変わるような処理で derivedStateOf を使っても、下流の再コンポジションは一切抑制されず、単に中間に重いオーバーヘッドを1枚挟んだだけになってしまうのです。

3. 現場で頻出する3大アンチパターン

アンチパターン①: 「更新頻度が1:1」の単純判定に使う

もっとも頻繁に見かけるのが、テキストフィールドの文字数判定や入力チェックを derivedStateOf で包んでしまうケースです。

// ❌ アンチパターン: 1文字入力するたびに State が変わり、結果の再計算が毎回走る
@Composable
fun SubmitForm(text: String, onSubmit: () -> Unit) {
    // text は文字を打つたびに変化する
    // 戻り値(Boolean)も最初の1文字目でしか変化しないか、あるいは単純な比較に過ぎない
    val isButtonEnabled by remember {
        derivedStateOf { text.isNotBlank() }
    }

    Button(
        onClick = onSubmit,
        enabled = isButtonEnabled
    ) {
        Text("送信する")
    }
}

一見良さそうに見えますが、text は String というプリミティブな引数であり、State ではありません。引数そのものが変われば Composable 自体が呼ばれ直します。また、仮に textState: MutableState<String> を参照していたとしても、文字列が空白かどうかのチェック(isNotBlank())は数ナノ秒で完了する極めて軽量な操作です。

数ナノ秒の比較処理をスキップするために、DerivedSnapshotState の追跡トラップとヒープ確保を行うのは、本末転倒の極みと言えます。このような場合は、単純にローカル変数として書くか、どうしても再計算を抑えたい重い処理なら remember(key) を使うのが正解です:

// ⭕ 正解: 単純なプロパティ判定ならそのまま評価するだけで十分
@Composable
fun SubmitForm(text: String, onSubmit: () -> Unit) {
    val isButtonEnabled = text.isNotBlank()

    Button(
        onClick = onSubmit,
        enabled = isButtonEnabled
    ) {
        Text("送信する")
    }
}

アンチパターン②: remember を忘れて毎回インスタンス生成

信じられないかもしれませんが、大規模なリファクタリング時やレビュー漏れで頻出するのが「remember の付け忘れ」です。

// ❌ 致命的なミス: 毎回のコンポジションで新しいインスタンスが生成される
@Composable
fun ScrollAwareFab(listState: LazyListState) {
    // remember がないため、再コンポジションのたびに DerivedSnapshotState が新規生成される
    val showFab = derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }.value

    if (showFab) {
        FloatingActionButton(onClick = { /* ... */ }) {
            Icon(Icons.Default.KeyboardArrowUp, contentDescription = null)
        }
    }
}

remember なしで derivedStateOf を呼ぶと、Composable 関数が実行されるたびに新しいスナップショット監視オブジェクトがヒープにアロケートされ、古いオブジェクトの監視解除と新しいオブジェクトの監視登録が毎フレーム発生します。これにより GC(ガベージコレクション)が頻発し、画面が盛大にカクつくことになります。

正しくは、必ず remember でインスタンスを一貫して保持しなければなりません:

// ⭕ 正解: remember でインスタンスを一貫して保持する
val showFab by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

アンチパターン③: 読み取りフェーズ(Phase)の無視による再コンポジション汚染

Compose には描画までに 3つのフェーズ が存在します:

  1. Composition(コンポジション): どんなUIツリーを作るかを決定する(最も重い)。
  2. Layout(レイアウト): 子要素のサイズ計測と配置位置を決定する。
  3. Draw(描画): 画面上のキャンバスにピクセルを描画する。

スクロール位置に応じてヘッダーのオフセットや透明度を変えたい場合、多くの人が次のように書いてしまいます:

// ❌ 惜しい例: スクロール位置(毎フレーム変化)を Composition フェーズで読み取っている
@Composable
fun StickyHeader(listState: LazyListState) {
    // スクロール中、firstVisibleItemScrollOffset は毎フレーム(毎秒60〜120回)激しく変わる
    val offset by remember {
        derivedStateOf { listState.firstVisibleItemScrollOffset }
    }

    // offset の値が毎フレーム変わるため、derivedStateOf は何一つフィルタできず
    // この Composable 全体が毎フレーム再コンポジション(Composition フェーズ)を繰り返す!
    Box(
        modifier = Modifier.offset(y = offset.dp)
    ) {
        HeaderContent()
    }
}

firstVisibleItemScrollOffset はピクセル単位の移動量なので、スクロール中は毎フレーム異なる数値になります。つまり、derivedStateOf を挟もうが挟むまいが、毎フレーム値が更新され、結果として毎フレーム再コンポジションが発火 します。

ここで使うべきなのは、derivedStateOf ではなく フェーズスキップ(Phase Skip) です:

// ⭕ 劇的な最適化: Modifier.graphicsLayer のラムダブロックで解決する
@Composable
fun StickyHeader(listState: LazyListState) {
    // 状態の読み取りを Composition フェーズではなく Draw/Layout フェーズに遅延させる
    Box(
        modifier = Modifier.graphicsLayer {
            // ここで listState を読むと、Composition は一切走らず Draw フェーズだけで反映される
            translationY = listState.firstVisibleItemScrollOffset.toFloat()
        }
    ) {
        HeaderContent()
    }
}

ラムダを受け取る Modifier(graphicsLayer { ... } や offset { ... })を使用すると、状態の読み取りが Composition フェーズを飛び越え、Layout や Draw のフェーズまで遅延されます。結果として、再コンポジションの発生回数は完全に「0回」になり、フレームレートは120fpsに張り付きます。

4. Kotlin 2.x 時代における Compose 最適化マトリクス

Kotlin 2.0 以降、Compose コンパイラには Strong Skipping Mode(強力なスキッピングモード) がデフォルトで導入されました。これにより、以前のようにすべてのデータクラスに @Immutable や @Stable を狂ったように付与したり、コレクションを ImmutableList でラップしたりする手間は大きく減少しました。コンパイラが賢くなり、インスタンスの参照同値性などを用いて自動的にスキップ判定を行ってくれます。

しかし、どれほどコンパイラが進化しても、「開発者がどのフェーズでどの頻度の状態を購読しているか」というアーキテクチャ上の設計ミスを自動修正することはできません。

状態の扱い方と最適化の手法は、以下のシンプルな判断基準で整理できます:

5. まとめ: 「計測なき最適化」を捨て、フレームレートを掴み取れ

「この処理は遅そうだから derivedStateOf を挟んでおこう」という推測に基づく最適化は、大半のケースで裏目に出ます。

Compose におけるパフォーマンスチューニングの真髄は、コードを技巧的にこねくり回すことではなく、Android Studio の Layout Inspector(Recomposition Count)や Composition Tracing(Perfetto)を開き、実際にどこで何回の無駄な再コンポジションが発生しているかを可視化すること です。

道具の内部メカニズム(スナップショットシステムの購読ツリーや3つのレンダリングフェーズ)を正しく理解したとき、初めて derivedStateOf は真価を発揮し、あなたのアプリにシルクのように滑らかな操作感をもたらしてくれます。

「フレームを落とさない美しいUI」を作るために、まずは手元のリストの再コンポジション回数をインスペクタで確認することから始めてみてください。