After Compose-First: Choosing Which View Screens to Migrate, Ranked by Churn Instead of Count
Google has declared Android development Compose-first. Here is how I rank View-based screens for migration using git history rather than screen counts, with the scoring script I actually ran and the three places partial migration quietly duplicates state.
When I read that Google had declared Android development Compose-first, the first thing on my mind was not the migration steps. It was the ordering.
My wallpaper apps still have View-based screens that have been running unchanged for years. Nothing is broken, so migration has always lost to "not right now." I have made that call many times myself.
But once new APIs and tooling are designed around Compose, the shape of the problem changes. The View side will not break. It just stops having room to grow. The cost of postponing does not spike; it accumulates quietly.
So: which screen goes first? That is the only question this article answers.
What Compose-first actually means for a small team
Compressed down to the part that hurts an indie developer, the declaration says this:
Your View-based screens will not break tomorrow. But the features arriving next will only arrive on one side.
That matters less for "should we migrate" and more for "when is the deadline." And the deadline is not something Google announces. It shows up locally, the moment the next feature you want to build requires a Compose-first API.
Which means migration priority comes from your own roadmap and your own change history — not from a blog post from Mountain View.
Counting screens tells you nothing
My first instinct was to count the View-based screens. It takes about one attempt to see why that fails.
Against a verification tree that mirrors my own layout (3 Activities, 1 Fragment, 1 DialogFragment, 1 settings screen — six screens total), scoring by View dependency alone produced this:
Screen
View dependency
MainActivity
3
DetailFragment
2
CategoryActivity
2
PaywallDialog
2
SettingsActivity
2
AboutActivity
2
Five of six screens tied. That is the expected outcome: dependency only measures how much View API a file touches. It cannot tell PaywallDialog, which sits directly on revenue, apart from AboutActivity, which users open once a year.
Rank by a metric that cannot separate things, and you end up with two options: migrate everything, or migrate nothing. I picked the second one for years, and now I understand why.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can turn a vague someday-we-will-migrate backlog into a specific decision about which screen to start on this week
✦You'll be able to run a churn-based scoring script against your own repository and get a ranked migration order out of it
✦You'll know the three places partial migration duplicates state, and how to set an exit line using staged rollout numbers
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
What you actually need to know is whether you will keep touching a screen. That is a fact about the future, but past change history is a surprisingly honest proxy. In my experience, a screen you have not touched in six months rarely becomes next month's active work.
So I wrote a script that ranks by churn (the number of commits touching a file in a window) multiplied by View dependency.
#!/usr/bin/env python3"""Rank View-based screens for Compose migration using git history.Usage: python3 screen_churn.py <repo> --since 12Prints each screen's churn (commits in the window), its View dependency,and the product of the two as a priority score."""import argparseimport reimport subprocessimport sysfrom collections import Counterfrom datetime import date, timedeltafrom pathlib import Path# Traces of View-based code. These are the lines that disappear under 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"),)# Screens already on Compose, or already bridged via interop, are out of scope.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.stdoutdef view_score(path: Path) -> int: """View dependency. Marker occurrences are the weight, as-is.""" 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 # already migrated; don't count it again 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="months to look back") ap.add_argument("--glob", default="*.kt") args = ap.parse_args() repo = Path(args.repo).resolve() since = (date.today() - timedelta(days=30 * args.since)).isoformat() # Only count files actually touched in the window. # A screen that merely exists lands at 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 # no View surface, or already on Compose c = churn.get(rel, 0) rows.append((c * v, c, v, rel)) if not rows: print("No View-based screens found") 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 = "move " if ratio >= 0.5 else ("watch " if ratio >= 0.15 else "leave ") 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)} screens / {moved} to move first" f" / window {args.since} months (since {since})")if __name__ == "__main__": main()
Zeroing out files that hit COMPOSE_MARKERS keeps half-migrated screens from resurfacing at the top on every run. If migrated screens keep counting as "still has View code," the list stops reflecting progress, and a list that does not reflect progress gets ignored within a week.
--glob is separate so projects with leftover Java can run *.java as its own pass. Mixed into one run, the ranking drifts toward whichever language happens to have longer files.
Add churn and the ranking splits open
Same six screens, now with twelve months of history:
The five-way tie is gone. Top score 36, bottom score 2 — an 18x spread across screens that a dependency count called equivalent.
And the answer came out as: of six screens, start with one.
That number is what unlocked this for me. Migrating six screens is a plan I abandon before I finish estimating it. Migrating one is something I can start with an evening. A large share of why migrations stall is not technical. The unit of work is simply too big to pick up.
Reading the tiers — and why "leave" is the important one
Tiers are cut on a ratio against the top score, not on absolute numbers, because commit granularity varies wildly between projects. A team that squashes and a developer who commits every save cannot share a threshold.
Tier
Ratio
What it means
What to do
move
0.5 and up
You will keep touching this
Migrate now. Rebuild in Compose
watch
0.15 to 0.5
Touched occasionally
Migrate when the next feature lands here
leave
Below 0.15
Effectively dormant
Don't migrate. Let it sit until it breaks
Making "leave" an explicit, named outcome is the part of this table I care about most. Migration plans fail on the things you decide not to do, not the things you decide to do. Rewriting AboutActivity in Compose delivers nothing to a single user.
Three things churn does not see
Being honest about the gaps in this score:
It cannot see your plans. Churn is entirely backward-looking. If you have already decided to rework billing next month, PaywallDialog gets promoted to "move" by hand, churn of 3 or not. The script's output is a starting order for a decision, not the decision.
It cannot tell dormant from dead. Git does not distinguish "stable" from "abandoned." For anything in the leave tier, I now ask whether the screen should exist at all before asking whether to migrate it. Migrating a screen you could have deleted is a loss twice over.
It cannot see coupling across screens. Migrating one screen often forces changes to how arguments reach the Fragment it navigates to. Churn is per-file and misses that blast radius. Trace the transitions out of a screen by eye before you start.
Where to put the seam
Once the "move" screen is chosen, start with ComposeView interop rather than a full rewrite — an island of Compose inside the existing XML layout.
// Assumes activity_main.xml contains:// <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 { // Dispose with the host's lifecycle. The default // (DisposeOnDetachedFromWindow) keeps the composition alive // while the page sits in ViewPager2's offscreen pool. setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { // Apply the Compose theme explicitly. The island does not // inherit the XML Theme.AppCompat — skip this and only the // island changes color. WallpaperTheme { val items by viewModel.items.collectAsStateWithLifecycle() WallpaperGrid( items = items, onFavorite = viewModel::toggleFavorite ) } } } }}
I don't leave setViewCompositionStrategy at its default because it has cost me time. The default disposes when the view leaves the window, so on a screen where users swipe horizontally between categories, offscreen pages keep their composition — and their state — alive behind you.
WallpaperTheme is there for the same class of reason. A Compose island inherits nothing from the XML theme. Forget it and you get the most confusing failure mode available: correct everywhere except inside the island.
While View and Compose coexist, what I lost track of was not functionality. It was configuration living in two places.
Where
Symptom
Fix it upfront
Theme and color
Dark mode works everywhere except the island
Keep XML color definitions as the single source; have Compose read from them
Dimensions
The same "16dp" exists twice; only one gets updated
Reference dimens.xml through dimensionResource()
Back behavior
Island state doesn't unwind on back press
Make the precedence between BackHandler and the existing OnBackPressedDispatcher explicit
The third one bites hardest. Nested back-press handling settles its own precedence implicitly, and implicit precedence is miserable to debug six weeks later. After getting burned by exactly this in an ad gate, I write back handling as flat, independent checks rather than nesting them. I apply the same rule when adding Compose's BackHandler.
What to hand an agent, and what to keep
Of the work above, the split between what Antigravity's agents can take and what they cannot is unusually clean.
Task
Delegate?
Why
Computing churn scores
Yes
Input is git history alone; output is mechanically checkable
Converting one XML component to a Composable
Yes
Small diff, reviewable by eye
Finalizing tiers
No
Your roadmap isn't in the repo. It's in your head
Deleting "leave" screens
No
Revenue and support impact aren't visible from code
The dividing line is whether every input to the judgment lives in the repository. For churn, it does. For tiers, it does not. Blur that line by handing over "plan my migration" wholesale and you get back a plausible-looking order whose reasoning you cannot check against facts the agent never had.
Ship the migrated screen through a staged rollout. On the wallpaper apps I go 5% → 25% → 50% → 100%, confirming crash-free users at or above 99.7% and ANR under 0.20% before widening each step.
Those thresholds matter more than usual when you're swapping out a UI foundation. Compose migration bugs rarely show up as "the feature doesn't work." They show up as rendering breaking on one device family, or one OS version, which your own handset will never reveal.
I once found a crash affecting only Android 6.0.1 users early in a rollout and closed it with a single build configuration line. Had I shipped at 100%, I'd have learned about it from one-star reviews instead.
Where to start
Run screen_churn.py against your repository once, with --since 12. If one or two screens come back in the "move" tier, that's your migration work for the month.
If three or more come back, rerun with --since 6. A shorter window weights recent attention more heavily, and recent attention is the better predictor.
I'm still mid-migration on my own View code. I've drafted and abandoned full-migration plans for years. What finally got me started wasn't deciding the order — it was deciding which screens I'm never going to touch.
Thanks for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.