Google が Android 開発を「Compose ファースト」と表明したという一文を読んだとき、私が最初に思ったのは移行の手順ではなく、順番のことでした。
手元の壁紙アプリには、View ベースのまま何年も動いている画面が残っています。壊れていないので、移行はいつも「今じゃない」に押し流されてきました。私自身、その先送りを何度も選んできた側です。
ただ、新しい API とツールが Compose を軸に設計されると分かった時点で、話の性質が変わります。View 側は壊れないけれど、増築の余地が細っていく。先送りの費用は、静かに、しかし確実に積み上がっていきます。
では、どの画面から動かすのか。この記事は、その一点にだけ答えます。
「Compose ファースト」が実務で意味する一行
表明の中身を、個人開発者にとっての実害まで縮めると、こうなります。
View ベースの画面は今日から明日にかけて壊れない。ただし、これから来る機能は片側にしか来ない。
この一行が効くのは「移行するかどうか」ではなく「移行の締切がいつ来るか」の方です。締切は Google が発表するのではなく、自分が次に作りたい機能が Compose 前提の API を要求した瞬間に、自分の手元で発生します。
つまり移行の優先度は、Google の発表ではなく自分のロードマップと過去の変更履歴から出すのが筋だ、と私はこの場面では考えています。
画面数で数えると、判断できません
最初に私がやりかけたのは、View ベースの画面を数えることでした。これが役に立たないことは、実際に数えてみるとすぐ分かります。
手元の構成を再現した検証用ツリー(Activity 3・Fragment 1・DialogFragment 1・設定系 1 の計 6 画面)に対して、View への依存度だけでスコアを付けると、こうなりました。
| 画面 | View 依存度 |
| MainActivity | 3 |
| DetailFragment | 2 |
| CategoryActivity | 2 |
| PaywallDialog | 2 |
| SettingsActivity | 2 |
| AboutActivity | 2 |
6 画面のうち 5 画面が同点で並びました。依存度は「View をどれだけ使っているか」しか見ていないので、当然です。この数字からは、課金に直結する PaywallDialog と、年に一度しか開かない AboutActivity が区別できません。
区別できない指標で並べると、結局は「全部やる」か「何もしない」の二択になります。私が何年も後者を選び続けた理由が、ここにありました。
変更頻度を git から出す
必要なのは、その画面をこれからも触るのかという情報です。それは未来の話ですが、過去の変更履歴が驚くほど正直な代理指標になります。半年間一度も触っていない画面を、来月から急に触り始めることは、経験上ほとんどありません。
そこで、churn(期間内にその画面が変更されたコミット数)と View 依存度の積で並べるスクリプトを書きました。
#!/usr/bin/env python3
"""View ベース画面の移行優先度を、git の変更履歴から出す。
使い方:
python3 screen_churn.py <repo> --since 12
出力: 画面ごとの churn(期間内の変更回数)と View 依存度、その積で並べた優先度。
"""
import argparse
import re
import subprocess
import sys
from collections import Counter
from datetime import date, timedelta
from pathlib import Path
# View ベースであることを示す痕跡。Compose 化すると、これらは消える側の記述
VIEW_MARKERS = (
re.compile(r"\bfindViewById\b"),
re.compile(r"\bsetContentView\s*\("),
re.compile(r"\bViewBinding\b|\bDataBindingUtil\b"),
re.compile(r":\s*(AppCompatActivity|Fragment|DialogFragment)\b"),
)
# 既に Compose 化された、または interop で橋渡し済みの画面は対象外にする
COMPOSE_MARKERS = (re.compile(r"\bsetContent\s*\{"), re.compile(r"@Composable"))
def git(repo, *args):
out = subprocess.run(["git", "-C", str(repo), *args],
capture_output=True, text=True)
if out.returncode != 0:
sys.exit(f"git failed: {out.stderr.strip()}")
return out.stdout
def view_score(path: Path) -> int:
"""View への依存度。マーカーの出現回数をそのまま重みにする"""
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return 0
if any(m.search(text) for m in COMPOSE_MARKERS):
return 0 # Compose 済みは数えない
return sum(len(m.findall(text)) for m in VIEW_MARKERS)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("repo")
ap.add_argument("--since", type=int, default=12, help="遡る月数")
ap.add_argument("--glob", default="*.kt")
args = ap.parse_args()
repo = Path(args.repo).resolve()
since = (date.today() - timedelta(days=30 * args.since)).isoformat()
# 期間内に「実際に触った」ファイルだけを数える。存在するだけの画面は churn 0 になる
log = git(repo, "log", f"--since={since}", "--name-only",
"--pretty=format:", "--", args.glob)
churn = Counter(l for l in log.splitlines() if l.strip())
rows = []
for rel in sorted(set(git(repo, "ls-files", args.glob).splitlines())):
p = repo / rel
v = view_score(p)
if v == 0:
continue # View に触れていない、または Compose 済み
c = churn.get(rel, 0)
rows.append((c * v, c, v, rel))
if not rows:
print("View ベースの画面は見つかりませんでした")
return
rows.sort(reverse=True)
top = rows[0][0] or 1
print(f"{'score':>6} {'churn':>6} {'view':>5} tier file")
for score, c, v, rel in rows:
ratio = score / top
tier = "先に移す" if ratio >= 0.5 else ("様子見 " if ratio >= 0.15 else "据え置き")
print(f"{score:>6} {c:>6} {v:>5} {tier} {rel}")
moved = sum(1 for r in rows if r[0] / top >= 0.5)
print(f"\n対象 {len(rows)} 画面 / うち先に移す {moved} 画面"
f" / 期間 {args.since}ヶ月({since} 以降)")
if __name__ == "__main__":
main()
COMPOSE_MARKERS に当たったファイルを 0 点にして除外しているのは、部分移行の途中で同じ画面が何度も上位に出てこないようにするためです。移行済みの画面を「まだ View が残っている」と数え続けると、リストが進捗を反映しなくなります。
--glob を分けているのは、Java が残っているプロジェクトで *.java を別に流せるようにするためです。混ぜて一度に走らせると、順位が言語ごとの行数の癖に引っ張られます。
同じツリーに churn を足すと、順位が割れる
先ほどの 6 画面に対し、12 ヶ月分の変更履歴を足して実行した結果です。
$ python3 screen_churn.py . --since 12
score churn view tier file
36 12 3 先に移す app/src/main/java/net/dolice/wall/ui/MainActivity.kt
14 7 2 様子見 app/src/main/java/net/dolice/wall/ui/DetailFragment.kt
8 4 2 様子見 app/src/main/java/net/dolice/wall/ui/CategoryActivity.kt
6 3 2 様子見 app/src/main/java/net/dolice/wall/ui/PaywallDialog.kt
2 1 2 据え置き app/src/main/java/net/dolice/wall/ui/SettingsActivity.kt
2 1 2 据え置き app/src/main/java/net/dolice/wall/ui/AboutActivity.kt
対象 6 画面 / うち先に移す 1 画面 / 期間 12ヶ月(2025-07-23 以降)
5 画面が同点で並んでいたリストが、churn を掛けた途端に割れました。最上位は 36 点、最下位は 2 点で、18 倍の開きです。
そして結論は「6 画面のうち、まず 1 画面」でした。
この 1 画面という数字が、私には効きました。6 画面の全面移行は、見積もりを出す前から気持ちが折れます。1 画面なら、今週の空き時間で着手できます。移行が進まない理由の相当な部分は、技術ではなく着手の単位が大きすぎることにあるのだと思います。
スコアの読み方 — 3 つの階層に何を割り当てるか
出力の tier は、最上位を 1.0 とした相対比で切っています。絶対値で切らないのは、プロジェクトごとにコミットの粒度が違いすぎるからです。
| tier | 相対比 | 意味 | 取るべき行動 |
| 先に移す | 0.5 以上 | これからも確実に触る画面 | 今回の移行対象。Compose で作り直す |
| 様子見 | 0.15〜0.5 | 触る頻度は中程度 | 次に機能追加が来たタイミングで移す |
| 据え置き | 0.15 未満 | ほぼ触っていない | 移さない。壊れるまで放置してよい |
「据え置き」を明示的に作ったことが、この表で一番大事な部分だと私は考えています。移行計画は、やることを決めるよりやらないことを決める方が難しいからです。AboutActivity を Compose で書き直しても、読者には何も届きません。
churn が見ていないもの
このスコアには、正直に言って見えていないものが 3 つあります。順に挙げます。
これからの予定は、履歴に写らない
churn は過去しか見ていません。来月から課金まわりを作り込むと決めているなら、PaywallDialog は churn が 3 でも「先に移す」に手で引き上げるべきです。スクリプトの出力は最終判断ではなく、議論の出発点となる並びにすぎません。
churn 0 は「安定」とも「死んでいる」とも読める
半年触っていない画面が「安定している」のか「死んでいる」のかを、git は区別しません。私は据え置き判定の画面については、移行の前に「そもそもこの画面は要るか」を先に問うようにしています。消せる画面を移行するのは、二重の損失です。
画面をまたぐ結合は、ファイル単位では見えない
ある画面を Compose 化すると、そこから遷移する先の Fragment の引数の渡し方に手が入ることがあります。churn はファイル単位なので、この波及を捉えられません。着手前に、その画面から生えている遷移を一度目で追ってください。
部分移行の接ぎ目をどこに置くか
「先に移す」1 画面が決まったら、全面書き換えではなく ComposeView による部分移行から入ります。既存の XML レイアウトの中に、Compose の島を置く形です。
// activity_main.xml の中に <androidx.compose.ui.platform.ComposeView
// android:id="@+id/compose_grid" ... /> を置いておく前提
class MainActivity : AppCompatActivity() {
private val viewModel: WallpaperViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<ComposeView>(R.id.compose_grid).apply {
// 親の Activity/Fragment のライフサイクルに合わせて破棄する。
// 既定の DisposeOnDetachedFromWindow のままだと、
// ViewPager の画面外プールに乗った時に composition が生き残る
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
// 既存の XML テーマではなく Compose のテーマを明示的に被せる。
// ここを省くと、島の中だけ色が変わる
WallpaperTheme {
val items by viewModel.items.collectAsStateWithLifecycle()
WallpaperGrid(
items = items,
onFavorite = viewModel::toggleFavorite
)
}
}
}
}
}
setViewCompositionStrategy を既定のままにしないのは、実際に困ったことがあるからです。既定の破棄条件は「ウィンドウから外れたとき」なので、ViewPager2 の画面外プールに保持されている間は composition が残り続けます。壁紙アプリのようにカテゴリ間を横スワイプする画面では、外れた composition が裏で状態を持ち続けることになります。
WallpaperTheme を明示的に被せている理由も同じ性質です。Compose の島は、XML 側の Theme.AppCompat を継承しません。ここを書き忘れると、島の中だけ色が違うという分かりにくい崩れ方をします。
なお、リストを Compose 化する際の再コンポーズの落とし穴については、以前にお気に入りの操作で壁紙グリッド全体が描き直されていた件を実測した記録でまとめています。移行の直後に踏みやすい場所です。
二重管理になる 3 箇所を先に塞ぐ
部分移行の期間中は、View と Compose が同じアプリの中に同居します。この同居で私が実際に取りこぼしたのは、機能そのものではなく設定値の二重管理でした。
| 箇所 | 症状 | 先回りの対処 |
| テーマ・配色 | 島の中だけダークモードが効かない | XML の色定義を単一の情報源にし、Compose 側はそこから読む |
| 寸法・余白 | 同じ「16dp」が2箇所に書かれ、片方だけ直る | dimens.xml を dimensionResource() 経由で参照する |
| 戻る挙動 | 島の中の状態が戻るボタンで戻らない | BackHandler と既存の OnBackPressedDispatcher の優先順位を明示する |
3 つ目が最も厄介です。戻るボタンの処理は、入れ子で書くと優先度が暗黙のうちに決まってしまい、後から追うのが難しくなります。私は広告のゲート処理でこれを経験してから、戻る系の処理は入れ子にせず並列の独立した判定として書くようにしています。Compose の BackHandler を足すときも、同じ方針で扱っています。
エージェントに任せる範囲と、任せない範囲
ここまでの作業のうち、Antigravity のエージェントに渡してよい部分と、渡すと戻ってこない部分がはっきり分かれます。
| 作業 | 任せるか | 理由 |
| churn スコアの算出と一覧化 | 任せる | 入力が git 履歴のみで、出力を機械的に検算できる |
| XML の 1 コンポーネントを Composable へ変換 | 任せる | 差分が小さく、目視レビューが現実的な範囲に収まる |
| tier の最終決定 | 任せない | これからの予定は git になく、自分の頭の中にしかない |
| 「据え置き」画面の削除判断 | 任せない | 収益や問い合わせへの影響を、コードからは判定できない |
境目は「判断材料がリポジトリの中に全部あるか」だと私は考えています。churn の算出は全部あります。tier の決定にはありません。この線引きを曖昧にしたまま「移行計画を立てて」と丸ごと投げると、もっともらしい順序が返ってきて、その根拠が自分の事情と合っているかを検証できなくなります。
エージェントに変換作業を任せる際の差分レビューの単位については、並列エージェントの変更を監査可能な単位に切る設計でも触れています。1 画面ずつ、レビュー可能な粒度で回すのが結局は近道でした。
出す前に見る数字
移行した画面を含むビルドは、段階公開で出します。壁紙アプリでは 5% → 25% → 50% → 100% の順で広げ、各段階で Crash-free users が 99.7% 以上、ANR が 0.20% 未満であることを確認してから次に進めています。
UI の基盤を差し替える変更で、この数字を見る意味は普段より大きくなります。Compose 移行で出る不具合は、機能が動かないのではなく、特定の端末や特定の OS バージョンでだけ描画が崩れる形で出ることが多いからです。手元の端末では、まず気づけません。
私自身、Android 6.0.1 の利用者だけが落ちる不具合を、段階公開の初期で見つけて 1 行の設定追加で塞いだことがあります。あのとき 100% で出していたら、気づくのは星 1 のレビューが並んでからでした。撤退線は、公開する前に決めておく方が心が穏やかです。
次の一歩
screen_churn.py を自分のリポジトリで --since 12 で一度だけ回してみてください。出てきた「先に移す」の画面が 1 つか 2 つなら、それが今月の移行対象です。
3 つ以上出たなら、--since を 6 に縮めてもう一度回します。期間を縮めるほど、直近の関心が濃く出ます。
私自身、View 資産の移行はまだ途中です。全面移行の計画を立てては流れる、を何年か繰り返してきました。今回ようやく着手できているのは、順番が決まったからというより、やらない画面が決まったからだと感じています。共に少しずつ進めていけたら嬉しいです。
お読みいただきありがとうございました。