壁紙アプリのお気に入り一覧を作り直していたとき、サムネイルのハートを一つ押すたびに、画面に見えている十数枚のサムネイルがほんの一瞬ちらつくのに気づきました。タップした一枚だけが切り替わってほしいのに、可視範囲のセル全部が描き直されているような挙動です。
スクロール中だと、このちらつきがそのままカクつきになります。自分の Pixel では辛うじて滑らかでも、古い端末を貸してくれる知人の手元では明らかに引っかかる。個人開発で同じアプリを長く触っていると、自分の端末の速さに慣れてしまって、こういう劣化を見落とします。気づいたのは、低速端末でスクロールしながらお気に入りを連打したときでした。原因は Jetpack Compose の再コンポーズ範囲が広がりっぱなしになっていたことで、まず「何回描き直されているか」を数字にするところから手をつけました。
推測でチューニングを始めない — まず再コンポーズ回数を可視化する
Compose のパフォーマンス問題でいちばんやってはいけないのは、remember や key を勘で足していくことです。どの Composable が何回再コンポーズされているか分からないまま手を入れると、効いたのか効いていないのか判断できず、別の場所を壊します。
最初にやったのは Composition Tracing の有効化でした。System Trace に「どの Composable 関数が何回コンポーズ/再コンポーズされたか」が名前付きで載るようになります。このセットアップ自体は定型作業なので、Antigravity のエージェントに「composition tracing を有効化して、デバッグビルドでだけ計測できるようにして」と頼み、出てきた差分を私が確認しました。
// app/build.gradle.kts
dependencies {
// Composition Tracing 用(debug のみで十分)
implementation ( "androidx.tracing:tracing-perfetto:1.0.0" )
implementation ( "androidx.tracing:tracing-perfetto-binary:1.0.0" )
}
// Compose コンパイラに composition tracing マーカーを埋め込ませる
composeCompiler {
// デバッグ計測時のみ true。リリースには含めない
includeTraceMarkers = true
}
計測は Android Studio の System Trace プロファイラ、もしくは Macrobenchmark から androidx.tracing.perfetto を起動して取りました。起動後にお気に入りを一回トグルし、トレースを Perfetto で開いて、サムネイル Composable のスライス数を数えます。ここで「一回のトグルで可視セル数ぶんの再コンポーズが走っている」ことが、初めて疑いではなく事実になりました。
犯人は「不安定な引数」だった
再コンポーズが広がる典型は、Composable に渡している引数が Compose コンパイラから「不安定(unstable)」と判定されているケースです。不安定な引数を一つでも受け取る Composable は、スキップ(skippable)の対象から外れ、親が再コンポーズされるたびに無条件で再コンポーズされます。
私のグリッドのアイテムは、こんなデータクラスを受け取っていました。
// 一見ふつうのモデル。だが Compose からは unstable に見える
data class WallpaperItem (
val id: String ,
val thumbnailUrl: String ,
val tags: List < String >, // ← List はインターフェースなので unstable 扱い
var isFavorite: Boolean , // ← var は unstable の決定打
)
List<String> は実装が差し替わりうるインターフェース型なので、コンパイラは中身が変わらない保証がないと判断します。さらに var プロパティは、いつ書き換わるか分からないため不安定の決定打です。この一つのモデルが unstable なせいで、それを受け取るサムネイル Composable 全体が skippable でなくなり、お気に入りの Set が更新されて親が再コンポーズされるたびに、可視セルが全部巻き込まれていました。
安定性の判定は、Compose コンパイラのレポート出力で確認できます。
# build.gradle.kts に compiler reports を出す設定を入れた上で
./gradlew assembleRelease \
-Pandroidx.compose.compiler.reports.destination=build/compose_reports
# 生成された *-classes.txt を見ると stable/unstable が一覧で出る
# 例: unstable class WallpaperItem
モデルを安定にする — @Immutable と不変コレクション
直し方は二つあります。一つはモデルを本当に不変にして、その事実をコンパイラに伝えること。もう一つは strong skipping mode に頼ることです。私は前者を主軸にしました。データが不変だと自分で保証できるなら、その方が挙動が読みやすいからです。
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Immutable
data class WallpaperItem (
val id: String ,
val thumbnailUrl: String ,
val tags: ImmutableList < String >, // 不変コレクションで stable に
val isFavorite: Boolean , // var を val に。状態は外で持つ
)
// 生成時に一度だけ不変化する
fun List < Wallpaper > . toUiItems (favorites: Set < String >): ImmutableList < WallpaperItem > =
map { w ->
WallpaperItem (
id = w.id,
thumbnailUrl = w.thumbUrl,
tags = w.tags. toImmutableList (),
isFavorite = w.id in favorites,
)
}. toImmutableList ()
@Immutable は「このクラスのインスタンスは生成後に観測可能な変化をしない」という約束をコンパイラに渡す注釈です。約束を破ると(あとから中身が変わると)画面が更新されなくなるので、本当に不変なときだけ付けます。kotlinx.collections.immutable の ImmutableList を使うと、コレクション型も stable と見なされます。
Compose 2.0 以降の strong skipping mode を有効にすれば、unstable な引数を持つ Composable も「インスタンスが同一参照なら」スキップできるようになり、この手の問題はかなり緩和されます。ただし私は、モデルの不変性は設計として明示しておきたかったので、strong skipping は保険として有効化しつつ、データクラス側も安定化させました。
お気に入りの状態は、アイテムの外に出す
isFavorite をアイテムのモデルに埋めてしまうと、お気に入りを一件変えるたびにリスト全体を作り直すことになり、結局すべてのアイテムが「別の値」になって再コンポーズされます。お気に入りの集合は一段上で持ち、各セルには「自分が favorite かどうか」だけを derivedStateOf で切り出して渡しました。
@Composable
fun WallpaperGrid (
items: ImmutableList < WallpaperItem >,
favorites: SnapshotStateMap < String , Boolean >,
onToggleFavorite: ( String ) -> Unit ,
) {
LazyVerticalGrid (
columns = GridCells. Adaptive (minSize = 108 .dp),
) {
items (
items = items,
key = { it.id }, // ← 安定キー。並び替えや挿入で誤再利用を防ぐ
contentType = { "thumb" }, // 同型セルの再利用を助ける
) { item ->
// この派生状態は「このアイテムの favorite」だけを読む。
// 他のアイテムの favorite が変わってもここは再コンポーズされない
val isFav by remember (item.id) {
derivedStateOf { favorites[item.id] == true }
}
WallpaperCell (
item = item,
isFavorite = isFav,
onToggleFavorite = onToggleFavorite, // 安定な関数参照を渡す
)
}
}
}
このグリッドには無料版で AdMob のネイティブ広告セルも一定間隔で差し込んでいるため、再コンポーズが広がると広告セルまで巻き込まれて再描画されます。お気に入りの一操作で広告の再レイアウトまで走らせないことは、表示品質の面でも避けたい挙動でした。
ここで効いているのは三点です。key = { it.id } でセルの同一性を ID に固定し、並び替えや挿入で別アイテムの状態を誤って引き継がないようにすること。derivedStateOf で「自分の favorite」だけを購読し、お気に入り集合のうち他の要素の変化を無視すること。そして onToggleFavorite を毎回新しいラムダで作らず、安定した関数参照のまま下ろすことです。コールバックを { onToggleFavorite(item.id) } のようにセル内で作り直すと、それ自体が引数の変化になりやすいので、ID はセル側で閉じ込めて呼び出します。
@Composable
private fun WallpaperCell (
item: WallpaperItem ,
isFavorite: Boolean ,
onToggleFavorite: ( String ) -> Unit ,
) {
Box {
AsyncImage (
model = item.thumbnailUrl,
contentDescription = null ,
modifier = Modifier. aspectRatio ( 0.62f ),
)
FavoriteButton (
isFavorite = isFavorite,
onClick = { onToggleFavorite (item.id) }, // 呼び出しはここで。引数は安定
)
}
}
実測 — 再コンポーズ回数とフレーム時間の前後比較
直したつもりで終わらせず、計測した最初の手順をもう一度回しました。可視セルがおよそ 14 枚の状態で、お気に入りを一回トグルしたときの、サムネイル Composable の再コンポーズ回数です。あわせて、スクロールしながらトグルを連打したときのフレーム時間(Macrobenchmark の FrameTimingMetric)も取りました。数値は私の手元の中位機(実機)での値で、端末差はありますが、傾向は再現します。
指標 修正前 修正後
1回のトグルでのセル再コンポーズ数 約 14(可視全件) 1(押した一件のみ)
トグル時のフレーム時間 P50 約 11.8 ms 約 4.6 ms
スクロール+連打時のフレーム時間 P90 約 27 ms(数フレーム脱落) 約 9 ms(脱落なし)
WallpaperItem の安定性(compiler report) unstable stable
可視全件が動いていた再コンポーズが、押した一件だけに収まりました。トグル時のフレーム時間 P50 は約 61% 短縮、再コンポーズ回数はおよそ 14 分の 1 です。P90 のフレーム時間が 16.7 ms(60fps の予算)を下回ったことで、低速端末での連打スクロールでもフレーム脱落が出なくなっています。本番運用に乗せる前に、段階公開のうちはこの数値を端末層ごとに見て、特定の機種だけ回避が効いていないケースがないかを確認しています。ここで大事だったのは「速くなった気がする」で止めず、修正前と同じ条件で同じ数字を取り直したことでした。Compose の最適化は、安定性注釈を一つ外し忘れただけで簡単に元へ戻るので、前後の数値がそろって初めて直ったと言えます。
落とし穴も一つ残ります。@Immutable を付けたモデルの中身を、あとからどこかで書き換えてしまうと、コンパイラはスキップを信じたまま画面を更新しなくなります。お気に入り状態をモデルから外に出したのは見た目の都合だけでなく、この嘘をつかないための設計でもありました。
どこまでエージェントに任せ、どこを自分で握るか
この修正でエージェントに任せたのは、Composition Tracing の配線、compiler report を出す Gradle 設定、そして「unstable と報告されたクラスの一覧」を読み上げてもらうところまででした。計測の足場づくりは定型で、機械が速く正確です。
一方で、isFavorite をモデルから外して状態を一段上へ持ち上げるという判断は、私自身が握りました。これはパフォーマンスの問題に見えて、実際にはデータの所有権をどこに置くかという設計判断だからです。エージェントは「unstable を stable にする」最短経路を提案しますが、var を val にして状態を外出しするか、strong skipping で押し切るかは、そのアプリのデータの寿命や更新頻度を知っている人間が決めるべきだと、私はこう考えています。計測は預け、設計は手放さない。個人開発でエージェントと組むときの、私なりの線引きです。
次に同じ症状に出会ったら、私はこの順番をお勧めします。
composeCompiler.reports.destination を一度だけ通し、unstable なクラスの一覧を取る
その先頭にあるモデルを @Immutable と不変コレクションで stable に直す
修正前と同じ条件で再コンポーズ回数とフレーム時間を取り直し、数値がそろうことを確認する
再コンポーズが広がる原因の多くは、その一覧の先頭の数行に書かれています。推測で remember を足す前に、まず一覧を眺めることを推奨します。