Most Android apps I get asked to work on were not born in Compose. They have years of XML layouts, Fragments, RecyclerView adapters and custom Views that still ship revenue every day. So when a team or a client says "we want to move to Compose," the real question is not whether, it's how to do a Jetpack Compose migration without freezing feature work or breaking production. After 8+ years of Android development, including my time as a Senior Android Engineer at CodeBrew Labs and my current freelance and remote work, my answer is almost always the same: migrate incrementally, one boundary at a time.

This post is the playbook I follow. It covers the interop APIs that make incremental migration possible, how I decide what to migrate first, how to keep theming consistent across two UI systems, and the mistakes that tend to bite teams halfway through.

Why Jetpack Compose Migration Should Be Incremental

The tempting option is the big rewrite: create a new module, rebuild every screen in Compose, flip a switch. I've seen this plan look great in a kickoff meeting and then stall for months. The reasons are predictable:

  • Feature work doesn't stop. Product still needs new screens and bug fixes, so you end up maintaining two versions of the same UI.
  • Hidden behavior lives in old code. Legacy screens carry edge cases nobody documented: accessibility tweaks, analytics events, odd keyboard handling. A rewrite tends to lose them.
  • Risk is concentrated. One giant release that changes every screen is hard to test and harder to roll back.

Compose was designed with interoperability in mind. You can put Compose inside a View hierarchy and Views inside a composition. That means you can treat the migration like any other refactor: small, reviewable, shippable steps where the app works after every merge.

Freelance angle

When a client asks for a full rewrite, I usually propose an incremental plan instead. It's easier to estimate, easier to bill in milestones, and the client sees Compose screens in production early instead of waiting on a big-bang release. That kind of risk reduction is often what wins the trust of a long-term client.

ComposeView and AndroidView: The Interop Building Blocks

Two APIs do most of the heavy lifting. ComposeView lets you host composables inside an existing View or Fragment. AndroidView lets you embed a classic View inside a composable. Learn both well, because a real migration uses them in both directions for a long time.

Hosting Compose inside a Fragment

This is the most common first step: keep the Fragment, keep the navigation graph, keep the ViewModel, and replace only the UI layer.

class OrdersFragment : Fragment() {

    private val viewModel: OrdersViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View = ComposeView(requireContext()).apply {
        // Tie the composition to the Fragment's view lifecycle,
        // not the Fragment itself, to avoid leaks on back stack.
        setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
        )
        setContent {
            AppTheme {
                val state by viewModel.uiState.collectAsStateWithLifecycle()
                OrdersScreen(
                    state = state,
                   
                   
                )
            }
        }
    }
}

Notice what didn't change: the ViewModel still exposes a StateFlow, and the Fragment still participates in the existing navigation. The XML layout is simply gone. If your ViewModels already expose a single UI state object, this step can be surprisingly quick. If they push state imperatively into Views, refactor the ViewModel first so the composable can be a pure function of state.

Embedding a legacy View inside Compose

The other direction matters just as much. Some Views are expensive to rewrite: a custom chart, a map, a video player, a third-party SDK widget. Wrap them and move on.

@Composable
fun SalesChart(
    points: List<ChartPoint>,
    modifier: Modifier = Modifier
) {
    AndroidView(
        modifier = modifier
            .fillMaxWidth()
            .height(220.dp),
        factory = { context ->
            // Runs once: create and configure the View.
            LineChartView(context).apply {
                setGridEnabled(true)
            }
        },
        update = { chart ->
            // Runs on recomposition when inputs change.
            chart.setData(points)
        }
    )
}

The rule I follow: factory creates, update syncs state. Don't read Compose state inside factory expecting it to update later, because it won't. Anything that changes over time belongs in update.

Planning Your Jetpack Compose Migration Order

Order matters more than speed. A good Jetpack Compose migration plan builds confidence early and saves the riskiest screens for when the team is fluent. Here's the sequence I typically recommend:

  1. Set up the foundation. Add the Compose BOM, enable Compose in the relevant modules, and create an AppTheme composable. No user-facing change yet.
  2. Build shared components. Buttons, text fields, list rows, empty and error states. These are low-risk and become the vocabulary for everything that follows.
  3. Migrate new screens first. Any brand-new feature is written in Compose from day one. This stops the XML surface from growing.
  4. Migrate leaf screens. Settings, about pages, detail screens with simple state. They have few dependencies and are easy to verify.
  5. Tackle complex lists and forms. Replace RecyclerView adapters with LazyColumn and multi-step forms once the team is comfortable with state hoisting.
  6. Move navigation last. Switching from Fragments to Navigation Compose is a structural change. Do it when most destinations are already composables, not before.

Doing navigation last is the one I push back on most often. Teams want to adopt Navigation Compose immediately, but while most screens are still Fragments, the existing navigation graph works fine with Compose-hosting Fragments. Swapping navigation early forces you to touch every screen at once, which is exactly the big rewrite you were avoiding.

Don't migrate and redesign at once

If the designer wants a visual refresh, do it in a separate step. Migrating a screen to Compose while also changing its look makes QA impossible: nobody can tell whether a difference is a bug or an intended change. Match the existing UI first, then redesign.

Bridging Themes Between XML and Compose

During a migration, users will move between XML screens and Compose screens constantly. If colors, typography or shapes drift between them, the app feels broken even when it isn't. The fix is to have a single source of truth for design tokens.

On most projects I keep the color and dimension resources as the source of truth while XML still dominates, and read them from the Compose theme:

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    // Resource colors already handle night mode via values-night,
    // so XML and Compose screens stay visually in sync.
    val colors = if (darkTheme) {
        darkColorScheme(
            primary = colorResource(R.color.brand_primary),
            surface = colorResource(R.color.surface),
           
        )
    } else {
        lightColorScheme(
            primary = colorResource(R.color.brand_primary),
            surface = colorResource(R.color.surface),
           
        )
    }

    MaterialTheme(
        colorScheme = colors,
        typography = AppTypography,
        content = content
    )
}

Once most of the UI is in Compose, I flip the relationship: the Kotlin theme becomes the source of truth and the remaining XML screens are the ones that adapt. The older theme adapter libraries that automatically read XML themes into Compose have been deprecated, so an explicit mapping like this is the more future-proof path.

Pitfalls I Watch For in Real Codebases

These are the issues I check for in code reviews whenever a team is mid-migration.

Wrong composition strategy

The default ViewCompositionStrategy disposes the composition when the View detaches from the window. Inside Fragments, that can keep compositions alive longer than the Fragment's view, especially on the back stack. Use DisposeOnViewTreeLifecycleDestroyed for Fragments. For ComposeView inside RecyclerView items, recent Compose versions handle pooling sensibly by default, so don't override it without a reason.

Two sources of truth for state

A half-migrated screen where an XML toolbar and a Compose body both read and write the same value through different paths is a bug factory. Hoist the state to the ViewModel and let both sides observe it. Neither the View nor the composable should own it.

Lost accessibility and test hooks

XML views often carry contentDescriptions and IDs that UI tests rely on. When migrating, add semantics and testTags deliberately so Espresso tests can be replaced by Compose tests rather than silently deleted. Hybrid screens can be tested with createAndroidComposeRule, which lets you interact with both Views and composables in the same test.

Wrapping everything in AndroidView forever

AndroidView is a bridge, not a destination. I keep a short list of wrapped Views in the project README or tracking board, with a note on why each is still wrapped. Some will stay forever (a map SDK, for example), and that's fine. The goal is visibility, not purity.

Measuring nothing

Compose can introduce performance regressions if state reads trigger unnecessary recomposition. Before and after migrating a heavy screen, check frame timing and recomposition counts with the Layout Inspector. A migration should not make the app feel slower, and you want evidence rather than vibes when someone asks.

A migration is successful when users never notice it happened and the team ships faster afterward. If either of those is false, slow down.

One last practical note: write a one-page migration guide for the team. It should cover which composition strategy to use, where the theme lives, how state flows from ViewModel to composable, and the definition of "done" for a migrated screen. On remote teams especially, that document prevents every developer from inventing their own pattern.

Key Takeaways

  • Treat Jetpack Compose migration as a series of small, shippable refactors rather than a rewrite, so the app works after every merge.
  • Master ComposeView and AndroidView; you'll use both directions of interop for a long time, and that's normal.
  • Start with the theme and shared components, write all new screens in Compose, and move navigation last.
  • Keep one source of truth for design tokens and UI state so XML and Compose screens never drift apart.
  • Preserve accessibility, tests and performance as you go, and keep migration and redesign as separate steps.