Sep 10, 2025·8 min read

Jetpack Compose migration for a shipped Android app

Jetpack Compose migration does not require a full rewrite. Learn how to mix Compose and XML screens, ship features safely, test changes, and reduce risk.

Jetpack Compose migration for a shipped Android app

Why teams get stuck on UI rewrites

A full UI rewrite sounds clean on a whiteboard. Real apps are messier. You already have users, deadlines, bug fixes, release dates, and product requests that do not stop because the UI layer is changing.

Teams usually get stuck when they treat Jetpack Compose migration like a single switch. That turns a normal roadmap into a freeze. New features wait. Small fixes pile up. Product managers start asking why the team is rebuilding screens that already ship while customer requests sit untouched.

The problem is not only speed. A big rewrite removes a lot of certainty at once. When a team replaces many screens in one pass, it gets harder to spot what caused a crash, a broken layout, or a jump in memory use. Code reviews get huge. QA has to retest too much. If the release goes wrong, rollback gets messy.

This is where many teams make the wrong call. They assume old and new UI cannot live together, so they plan an all-or-nothing move. Android does not force that choice. XML and Compose can sit in the same app for a long time. You can place Compose inside an existing screen with ComposeView, and you can keep older Views inside Compose when needed. That gives you room to move screen by screen, or even block by block.

That matters in shipped apps because old screens often carry years of hidden fixes. A settings page may look simple, but it may include permission edge cases, accessibility tweaks, and analytics events that nobody wants to relearn during a rushed rewrite. Replacing all of it at once is rarely a smart bet.

A better goal is boring, and that is good. Keep shipping. Move the UI in small pieces. If the team rebuilds one profile header in Compose and leaves the rest of the screen alone, users still get new work, and the app keeps moving forward.

Steady delivery beats a clean rewrite plan that never reaches production.

What to migrate first

Start where the app already changes often. A screen that the product team touches every sprint is a better first candidate than a stable screen nobody wants to edit. That way, your Jetpack Compose migration pays off right away instead of sitting in a branch that drifts for months.

Put every screen on a simple map: how much business value it has, and how often the team changes it. A checkout flow, signup step, or plan selection page usually sits near the top because small UI changes there affect revenue or conversion. An old legal screen might look ugly, but if nobody edits it, it can wait.

Then look for shared UI pieces that show up everywhere. Buttons, input rows, error states, loaders, top bars, and small cards are good early wins. If you build those in Compose first, later screens move faster because the basic parts already exist. You also avoid rewriting the same pattern five times.

Another smart place to start is the screen that gives your team the most layout pain. Think of the screen with nested RecyclerViews, too many view types, or endless XML includes. Compose often makes those cases easier to read and cheaper to change. Pick the pain that slows real work, not the pain that only annoys engineers once a quarter.

A good first target stays small. Settings, profile edit, a support form, or a single tab inside a larger feature usually works well. The team can ship it, learn from it, and keep release speed steady.

Use a short filter before you choose:

  • People use the screen often or the business cares about it.
  • The team changes it more than once in a while.
  • The layout is awkward in XML today.
  • You can measure success in plain terms, like fewer UI bugs or faster feature edits.

One practical example: do not start with the app home screen if it mixes feeds, ads, deep links, and experiments. Start with the filter sheet or details panel attached to it. You get real Compose experience, but the blast radius stays small.

If you need a rule, use this one: pick the smallest screen that your team already wants to improve this month. That choice usually beats the most ambitious one.

Set rules before you touch code

A Jetpack Compose migration gets messy when every team member makes different choices. One screen uses Fragment arguments, another pulls state straight from a repository, and a third mixes XML colors with Compose theme values. You can avoid that drift if you set a few rules before the first screen moves.

Keep one navigation flow working the whole time. Do not split the app into a "new" app and an "old" app. Keep the same nav graph, the same deep links, and the same back stack rules. If a product details screen moves to Compose, users should still reach it from the same places and return the same way.

Pick one place where Compose enters the app first. In most shipped apps, leaf screens are safer than shared shells. A settings page, profile editor, or order details screen is usually easier than the home screen, bottom navigation, or a screen that five teams touch every week. New features also make good entry points because you avoid rewriting code that already works.

State needs a clear owner. The easiest rule is simple: the ViewModel owns screen state, and the UI reads it. XML and Compose can both observe the same state during the transition, but only one UI layer should control rendering for a given screen. If a Fragment hosts a ComposeView, treat Compose as the screen UI. Do not keep half the widgets in XML and half in Compose unless you have a short-term reason and an end date.

Theming needs the same discipline. Write down your colors, spacing, type styles, and component rules once, then map them to both XML and Compose. If the old app uses one shade of blue and Compose uses a slightly different one, the app will feel patched together even when the code is clean.

Also decide what stays in XML for now. Write it down so nobody "maybe migrates" it by accident. Good candidates to leave alone for a while include:

  • screens tied to WebView flows
  • payment or identity steps with strict QA rules
  • large RecyclerView screens that already perform well
  • low-traffic admin screens nobody plans to change soon

A short written migration contract saves rework later. It also makes code reviews faster because the team can judge changes against rules instead of personal taste.

How to mix old and new screens step by step

The safest Jetpack Compose migration starts small. Pick one screen that changes often, or even one part of a screen, and leave the rest alone. Teams get in trouble when they rewrite too much at once and then spend weeks fixing details that users never asked for.

If an existing screen already works, keep the XML layout and add a ComposeView only where it helps. A new promo card, filter row, settings block, or comment composer is often enough. Navigation, permissions, analytics, and deep links can stay in the old screen while the new piece proves itself in production.

Before you replace more UI, move screen state out of the view layer. Put loading, error messages, form data, and button actions in a ViewModel or presenter. Then both XML views and composables can read the same state, which keeps behavior consistent and stops business rules from splitting into two versions.

A low-risk order usually looks like this:

  • Replace one isolated widget or section first.
  • Share state between XML and Compose.
  • Move the next section only after the first one is stable.
  • Convert the whole screen when most of the UI is already in Compose.

The reverse path matters too. If a new Compose screen still depends on an old widget, use AndroidView and keep moving. This is common with WebView, MapView, camera previews, ad SDK views, or a custom chart your team does not want to rewrite yet. Mixing Compose and XML is often faster than forcing a perfect rewrite.

A small example makes this clearer. Say your checkout screen is still XML, but the discount picker changes every week. Put that picker in ComposeView, keep the payment form and tracking code where they are, and watch error rates for a release or two. If that goes well, move the order summary next.

For screens tied to revenue, add a feature flag. Checkout, paywalls, onboarding, and account recovery do not need drama. A flag lets you ship to internal users first, then a small share of customers, and turn the new UI off fast if metrics drop.

That is the pattern that usually works: one seam at a time, shared state first, old widgets kept only where they still earn their place.

A realistic migration example

Audit Your Next Screens
Choose the next three migration targets with a clear business and engineering case

Picture an app that people already use every day. The login flow is steady. Users can sign in, reset a password, and get through two-factor checks with very few complaints. The settings page is where the pain lives. It grew over time, picked up odd layout hacks, and now every small change turns into a half-day fix.

That split gives you a sensible starting point for Jetpack Compose migration. Keep login in XML. It already works, and changing it only adds risk to the part of the app that blocks every user.

Move the settings page first instead. It usually has lots of small UI pieces, clear states, and fewer scary edge cases than auth.

Keep the logic, swap the screen

The safest version of this move changes the UI and leaves the business logic alone. If the app already has a SettingsViewModel, keep using it. The same saved state, repository calls, validation rules, and analytics events can stay where they are.

The old fragment can still own navigation and lifecycle. Inside it, add a ComposeView and render the new settings UI there. Users see a fresh screen, but the app still talks to the same ViewModel as before. That cuts a lot of risk.

If one part of settings still depends on an older custom view, keep it for now. Mixing Compose and XML is normal during an incremental Android UI rewrite. You can place Compose inside the screen with ComposeView, and if needed, pull an old widget into Compose with AndroidView.

A realistic settings page might include:

  • notification toggles
  • theme selection
  • profile details
  • connected accounts
  • a danger zone for sign-out or account deletion

After release, watch support tickets for a week or two. Do people still get confused by notification options? Do they stop reporting broken spacing, clipped text, or controls that feel inconsistent? That feedback matters more than whether every screen has moved to Compose.

If the new settings page stays calm in production, the team has proof that mixed screens work. Login can stay in XML until there is a real reason to touch it.

How to test each step without slowing down

A slow test plan can kill an incremental rewrite just as fast as a full rewrite. Keep the checks narrow and tied to the screen you changed. If one team member swaps an XML card for a Compose version, they should not need a week of QA to ship it.

Screenshot tests help most with visual drift. Use them for stable states that people notice right away: empty, loading, error, and filled content. If you migrate one part of a shipped screen, such as a product list row inside a Fragment, compare the old and new versions side by side before you merge.

Keep the fast checks small

A short release check beats a giant test suite nobody trusts. For each migrated screen, run the same compact set every time:

  • Compare screenshots for the common UI states
  • Open the screen on at least one small phone and one larger device
  • Test scroll, text input, focus changes, and the on-screen keyboard
  • Check startup time and memory against the last release

Real devices matter more than people expect. Compose and XML can behave differently when the keyboard opens, when focus jumps between fields, or when a nested scroll view meets a lazy list. Emulators catch a lot, but a real phone will show the annoying bugs first.

A simple example: you move a search form to Compose with ComposeView, but the results list stays in RecyclerView. The screen may look fine in a screenshot test and still break when the keyboard pushes content up and hides the submit button. Five minutes on a real device usually finds that.

Watch production after release

After each small migration, check app startup and memory, not just UI behavior. One new screen will not usually sink the app, but several small regressions add up. Track the numbers after every release so you can spot the exact change that caused the jump.

Crash tracking should stay close to the screen level. Tag reports with the screen name, app version, and whether the user saw XML, Compose, or a mixed screen. When a crash appears, you want to know "profile_edit_compose" failed, not just "Android crash rate went up."

That habit keeps migration work boring in the best way. Small change, small test, clear signal.

Mistakes that create more work later

Bring In Fractional CTO Help
Get senior support on Android architecture, product tradeoffs, and delivery pace

Most migration pain starts when one small screen rewrite turns into three hidden projects. A team swaps XML for Compose, changes navigation, replaces theme tokens, and rewires screen state in the same pull request. When bugs show up, nobody knows which change caused them.

Keep each move boring. Rewrite the UI first, or move navigation first, or clean up state first. One change per screen feels slower for a week and saves a lot of rework later.

If one pull request changes several of these at once, split it:

  • screen layout
  • navigation route or back stack behavior
  • state source and data loading
  • theme, spacing, and component rules

Another mistake is carrying old habits into new composables. If the XML screen depended on giant fragments, shared mutable state, or copy-pasted formatting logic, Compose will not fix that by itself. You just get the same mess in a newer syntax.

Use the migration to trim obvious waste. Put reusable UI into small composables, keep business rules out of them, and remove duplicate view code while the screen is still fresh in your mind.

State ownership needs one clear home. When a Fragment owns part of the screen state and a composable owns the rest, bugs hide in the gap between them. You get stale values, double network calls, and buttons that reset after rotation.

Pick one owner for each screen, usually the existing ViewModel. Let the composables render state and send events up. If a Fragment hosts a ComposeView, that Fragment should not also keep its own side version of the same UI state.

Teams also lose time on easy but low-value screens. Rewriting an about page may feel safe, but it rarely teaches you much about the hard parts. A busy list screen, a form with validation, or one shared component usually pays back faster because you can reuse the result on other screens.

Visual cleanup often hides accessibility problems. A screen may look cleaner in Compose and still work worse for real users. Check text scaling, color contrast, touch target size, focus order, and TalkBack labels before you call the work finished.

One common failure looks like this: a team migrates a checkout screen and also moves to Compose Navigation and a new design system in the same sprint. The screen looks newer, but back behavior breaks, analytics events disappear, and error states drift between the Fragment and the composable. If they had kept the old navigation, reused the same ViewModel, and changed only the rendering layer, they could have shipped with far less risk.

Quick checks before each release

Get an Outside Architecture Review
Use a fresh review to spot weak seams before you touch checkout, login, or onboarding

Ship only when the new screen handles the main task as cleanly as the old one. With Jetpack Compose migration, small releases win. If a change feels hard to explain, hard to test, or hard to undo, it is probably too big for this sprint.

A short release check keeps the team honest. It also stops a UI rewrite from quietly turning into a product freeze.

  • Make sure the team can still ship a normal feature this sprint. If the migrated screen blocks unrelated work, cut the migration into smaller pieces.
  • Give one person clear ownership of each migrated screen until it is stable in production. Shared ownership often means small bugs sit around because everyone assumes someone else will fix them.
  • Cover the main user path with tests. For most screens, that means open, enter data, save, handle one failure case, and go back without losing state.
  • Let support and product see the change before release. A few screenshots, a short note on what changed, and one known limitation save a lot of confusion after launch.
  • Keep a simple rollback path. A feature flag, the old Fragment behind a switch, or one release where both versions still exist can save hours if crash reports show up.

One small example: imagine you moved the profile edit screen to Compose, but the old XML version still works. Release the new one behind a flag for a small group first. If users hit a state bug when they rotate the phone or leave and return, you can switch back fast while the team fixes it.

Do one manual pass on a real device before each release. Emulators catch a lot, but they do not show every layout edge case, keyboard issue, or animation glitch. That ten-minute check often finds the annoying stuff users notice first.

If you cannot answer yes to all five checks, do not force the release. Ship a smaller slice, keep delivery moving, and let the incremental Android UI rewrite stay incremental.

Next steps for your team

A full rewrite only makes sense when the app already needs a deep product reset, the release schedule is loose, and the team can absorb months of churn. Most shipped apps do not have that luxury. In that case, Jetpack Compose migration usually works better as a series of small, boring moves that keep releases going.

That means you add Compose where it gives you a clear win, leave stable XML screens alone for now, and keep both approaches under the same design and state rules. Done well, users barely notice the transition. The team does, because new work gets easier instead of piling more debt on top of the old UI.

Pick the next three screens with a reason, not by gut feel. A simple roadmap is enough:

  • Start with a low-risk screen such as settings or profile, where the team can set up theme, navigation, and basic patterns.
  • Move to a screen that is already changing for product work, so migration supports delivery instead of delaying it.
  • Finish with a screen that has messy custom views or adapter logic, where Compose can remove obvious friction.

After those three screens, pause and review what happened. Look at crash reports, UI bugs, preview usefulness, code review speed, and how often developers had to bridge between ComposeView and AndroidView. If the team still fights the same boundary problems, fix that now before you spread them across the app.

Outside help often saves time at this point. A good review can spot issues that teams miss when they are busy shipping: split state between old and new UI, theme drift, unclear navigation ownership, or reusable components that should exist before more screens move over.

If you want that kind of review, Oleg Sotnikov can look over an Android migration plan as part of Fractional CTO or advisory work. He works with startups and small to mid-sized businesses on product architecture, lean engineering setups, and practical modernization plans, so the goal stays simple: keep shipping, cut avoidable rework, and migrate the app in an order that makes sense.