Jetpack Composeで意図せず無限リコンポーズした話

※ChatGPTを使用して記事を作成しています。

UIを書くのが楽になり、宣言的に画面を構築できるようになったJetpack Compose

直感的な書き方で状態管理も容易になり、「もうXMLに戻れない」と思った人も多いでしょう。

しかし、ComposeにはComposeなりの落とし穴があります。

その代表例が、「無限リコンポーズ」です。

今回は、私が実際にComposeアプリを作る中で無限ループのように画面が更新され続け、端末が発熱・フリーズした失敗談をシェアします。

背景:リスト表示とフィルタ機能をComposeで実装

あるアプリで、ユーザーがアイテムリストを検索・フィルタできる画面を作りました。

やりたいことはシンプル:

  • 入力フォームに文字を打つとリストが絞り込まれる
  • 絞り込んだ結果を表示する
  • UIはComposeでスッキリ書く

その時の最初の実装がこちら。

@Composable
fun ItemListScreen(items: List<String>) {
    var query by remember { mutableStateOf("") }

    val filtered = items.filter { it.contains(query) }

    Column {
        TextField(
            value = query,
            onValueChange = { query = it }
        )
        LazyColumn {
            items(filtered) { item ->
                Text(item)
            }
        }
    }
}

シンプルで直感的、動作もバッチリ。

――のはずでした。

問題発生:スクロールがガタガタ、CPUが100%

実際にアプリを動かしてみると、次の問題に気づきました。

  • 入力フォームに文字を打つとカクカクする
  • スクロールがやたら重い
  • 端末が熱くなり、バッテリーが一気に減る

調べてみると、なんと無限にリコンポーズが走っていることが分かりました。

原因1:計算コストの高い処理を直接Composableに書いた

Composeでは、Composable関数は何度も呼ばれます。

そのため、items.filter { ... } のような処理を直接書くと、リコンポーズのたびに計算されることになります。

val filtered = items.filter { it.contains(query) }

これを数百件・数千件のリストでやれば、当然重くなります。

原因2:rememberの使い方を誤った

次にやらかしたのは、rememberを使ったつもりが、Composable外の状態管理がループを引き起こすケース。

例えばこんな実装:

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }

    Text("Count: $count")

    // 🔴 無限リコンポーズの原因
    count++
}

このコードは、Composableが呼ばれるたびにcount++が実行され、状態が変わり、再びリコンポーズされる…。

結果、無限リコンポーズ地獄に突入しました。

解決策:状態更新はイベントから行う

状態更新はユーザー操作や副作用の中で行うべきで、Composableの本体で直接変更してはいけません。

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

原因3:LaunchedEffectを誤用した

さらに深い沼がLaunchedEffectです。

「初回だけ非同期処理をしたい」と思ってこう書いたら…。

@Composable
fun UserScreen(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }

    LaunchedEffect(userId) {
        user = loadUser(userId) // suspend関数
    }

    Text(user?.name ?: "Loading…")
}

一見正しそうですが、loadUser()の中で状態が更新されるたびにLaunchedEffectが走り、再度loadUser()が呼ばれるというループに。

結果、APIが大量に叩かれる事件に発展しました。

解決策:keyの指定を正しくする

LaunchedEffect(userId)とすることで、本来はuserIdが変わった時だけ走るはずです。

しかし、内部で参照しているstateがリコンポーズを引き起こし、意図せず再実行されることがあります。

この場合は、状態をもう少し分離し、再取得が不要なときに動かないよう制御する必要があります。

原因4:再利用性を考えないComposable設計

Composeは「小さく分けて組み立てる」のが基本ですが、大きなComposableに状態や処理を全部詰め込んでしまうと、一部の変更が全体のリコンポーズを誘発します。

@Composable
fun BigScreen() {
    var query by remember { mutableStateOf("") }
    var selected by remember { mutableStateOf<String?>(null) }

    Column {
        SearchBar(query) { query = it }
        ItemList(query, onSelect = { selected = it })
        DetailView(selected)
    }
}

ここでは、検索バーを入力するだけでリストも詳細画面もリコンポーズされる事態に。

解決策:状態をスコープごとに分離

@Composable
fun BigScreen() {
    var query by remember { mutableStateOf("") }
    var selected by remember { mutableStateOf<String?>(null) }

    Column {
        SearchBar(query, onQueryChange = { query = it })
        ItemList(query, onSelect = { selected = it })
        DetailView(selected)
    }
}

さらに、ItemListDetailViewrememberで包んだstateだけを渡すようにすると無駄なリコンポーズが減ります。

結果:Composeは便利だが「状態と再コンポーズの理解」が必須

この一連のトラブルで学んだのは、

  • Composeは「状態が変わるとUIが更新される」というシンプルなルール
  • だが「どの状態がどこに影響するのか」を考えないと、簡単に無限リコンポーズに陥る
  • 最適化のためにはrememberderivedStateOfLaunchedEffectなどを正しく使い分ける必要がある

ということです。

教訓

  1. Composable関数の中で状態を直接更新するな
  2. 計算コストの高い処理はrememberderivedStateOfでメモ化
  3. LaunchedEffectはkeyの指定を慎重に
  4. 状態は必要なスコープで分離する(リフトアップの逆も検討)
  5. リコンポーズは悪ではない。無限ループだけを避ければいい

まとめ

XML時代には「無駄なfindViewById」「Viewの再生成」といった沼がありました。

Compose時代は「状態とリコンポーズ」が新たな落とし穴です。

「宣言的UIは魔法ではない」

結局は、状態とライフサイクルをどう扱うかが全て。

私は無限リコンポーズで何度もアプリをフリーズさせましたが、その経験のおかげでComposeの動作原理を深く理解するきっかけになりました。

タイトルとURLをコピーしました