※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)
}
}
さらに、ItemList
やDetailView
をremember
で包んだstateだけを渡すようにすると無駄なリコンポーズが減ります。
結果:Composeは便利だが「状態と再コンポーズの理解」が必須
この一連のトラブルで学んだのは、
- Composeは「状態が変わるとUIが更新される」というシンプルなルール
- だが「どの状態がどこに影響するのか」を考えないと、簡単に無限リコンポーズに陥る
- 最適化のためには
remember
・derivedStateOf
・LaunchedEffect
などを正しく使い分ける必要がある
ということです。
教訓
- Composable関数の中で状態を直接更新するな
- 計算コストの高い処理は
remember
かderivedStateOf
でメモ化 LaunchedEffect
はkeyの指定を慎重に- 状態は必要なスコープで分離する(リフトアップの逆も検討)
- リコンポーズは悪ではない。無限ループだけを避ければいい
まとめ
XML時代には「無駄なfindViewById」「Viewの再生成」といった沼がありました。
Compose時代は「状態とリコンポーズ」が新たな落とし穴です。
「宣言的UIは魔法ではない」
結局は、状態とライフサイクルをどう扱うかが全て。
私は無限リコンポーズで何度もアプリをフリーズさせましたが、その経験のおかげでComposeの動作原理を深く理解するきっかけになりました。