Migrating a mature Android app to Jetpack Compose is almost never the weekend rewrite people imagine. In any app that is actually shipping, it is a migration you live with: one screen at a time, while the old View-based code keeps delivering features right next to the new Compose code. The encouraging part is that the two toolkits were designed to coexist. You can drop Compose into a View hierarchy, embed a legacy View inside Compose, share a single design system across both worlds, and move piece by piece with the app fully working the entire time.
That is the whole strategy in one line. The rest of this post is about the seams, because the seams are where an incremental migration is won or lost.
The short version
If you only take four things away:
- Interop runs both ways.
ComposeViewputs Compose inside Views; theAndroidViewcomposable puts a View inside Compose. You will use both, often in the same screen. - One design system, two renderers. The fastest way to make a half-migrated app look broken is to let XML themes and the Compose theme drift apart. Keep a single source of truth.
- The boundary is where state lives. Hoist state at the interop edge and collect flows in a lifecycle-aware way, so the old and new halves of a screen never disagree about what to show.
- The pitfalls are predictable. Double theming, nested scrolling, and composition lifetime inside recycling containers account for most of the pain, and all three have standard fixes.
Two directions of interop
An incremental migration is not a single door between the two worlds; it is two doors, and you walk through both.
ComposeView: Compose inside the View world
ComposeView is a regular Android View that hosts a composition. You can declare it in XML and populate it from your Fragment or Activity. This is the workhorse of the "migrate a screen's header first, then the list, then the whole screen" approach.
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_header"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
class ProfileFragment : Fragment() {
private var _binding: FragmentProfileBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentProfileBinding.inflate(inflater, container, false)
binding.composeHeader.apply {
// Tie the composition's lifetime to the fragment's view lifecycle.
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
AppTheme {
ProfileHeader()
}
}
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
Two details in there are not optional, and I will come back to the composition strategy below.
AndroidView: a legacy widget inside Compose
The other direction matters just as much. When a screen is mostly Compose but still needs a widget you have not rebuilt yet — a custom chart, a map surface, a signature pad, an ad view — the AndroidView composable hosts it. The trick is understanding its two lambdas:
@Composable
fun LegacyChart(points: List<DataPoint>, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier.fillMaxWidth(),
factory = { context ->
// Runs once, on the UI thread: construct and do one-time setup.
LineChartView(context).apply {
axisStyle = AxisStyle.Minimal
}
},
update = { view ->
// Runs after factory, then on every recomposition that reads `points`.
view.submitPoints(points)
}
)
}
factory runs exactly once to create the view and do constant setup. update runs right after the factory and again on each recomposition where the state it reads has changed. If you accidentally create the view in update, you rebuild it on every frame; if you put state-dependent mutation in factory, it never updates. When a change really must rebuild the view from scratch, wrap the call in key(...) { } rather than fighting the update block. The honest answer in 2026 is that it depends on which overload you use: the classic factory/update AndroidView holds a single view instance that you mutate for the whole lifetime of the composition, so treat it as one real view. But since Compose UI 1.5.0 there is an overload that also takes onReset (and onRelease), and that one can be reused and pooled — when you host it inside a lazy layout like LazyColumn or LazyRow, Compose recycles the hosted view across items and calls onReset to wipe per-item state before update runs again. So the "one instance you mutate" rule holds for the plain overload; opt into onReset precisely when you want pooling.
One design system, two renderers
This is the part teams underestimate. For as long as the migration lasts — which can be a year in a large production app — your buttons, colors, and typography are rendered by two independent systems: the XML theme (styles.xml, Material Components) and the Compose MaterialTheme. If those two drift, a user scrolling one screen sees a primary color that is subtly wrong the moment the content switches from a View to a ComposeView.
The honest answer in 2026 is to stop trying to bridge at runtime. The old theme adapters (Mdc3Theme and the compose-theme-adapter family) are deprecated. The recommended path is to make one set of design tokens the source of truth and generate both representations from it — Google's Material Theme Builder will emit a matching XML theme and a Compose theme from the same inputs — or to define your Compose AppTheme by hand so it mirrors the XML values exactly. Either way, the rule is simple: the tokens are the source of truth, the two themes are outputs. Then you wrap every single Compose entry point in that AppTheme, with no exceptions, so a composition never falls back to Compose's default Material colors.
If this sounds like the kind of boundary discipline I keep insisting on, it is the same instinct I described when I split a production eID SDK into strict reading, verification, and integration layers: keep the responsibilities from leaking into each other and you can change one side without breaking the other.
Living inside Fragments and Navigation
Most incremental migrations happen inside an existing Fragment + Navigation setup, so the interop has to respect the Fragment's view lifecycle rather than the Fragment instance.
That is exactly what DisposeOnViewTreeLifecycleDestroyed buys you in the earlier snippet. It disposes the composition when the view tree's LifecycleOwner is destroyed, which for a Fragment means its view lifecycle. Without it, a ComposeView can outlive the view it belongs to and leak. If you host several ComposeViews in one XML layout, give each a stable id so state restoration works.
Navigation itself can stay a hybrid for a long time. You can keep the Fragment-based graph and simply let some destinations be Fragments whose entire content is a ComposeView, then introduce navigation-compose subgraphs later once enough of a flow is Compose. What you should not do is scatter navigation decisions across both worlds. Pick one layer to own "where do we go next," and let the other layer only emit intent.
Back handling is a good example of a seam that quietly changes under you. In a hybrid screen, who intercepts the system back gesture — the Fragment or the composable — has to be decided deliberately, especially with predictive back in the picture. I wrote about the modern, lifecycle-aware way to own that event in the move from onBackPressed to OnBackPressedDispatcher, and the same reasoning applies at the interop edge: one owner, bound to the right lifecycle.
Hoisting state at the boundary
The interop edge is where the two worlds agree on what to display, so it is where state belongs. The pattern that keeps a half-migrated screen coherent is unglamorous: a ViewModel exposes a single stream of UI state, and both the Compose side and any surviving View side read from the same source.
@Composable
fun ProfileHeader(viewModel: ProfileViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ProfileHeaderContent(
state = state,
onEditClick = viewModel::onEditClicked
)
}
collectAsStateWithLifecycle lives in androidx.lifecycle:lifecycle-runtime-compose and uses repeatOnLifecycle under the hood, so collection pauses when the app goes to the background and resumes in the foreground — the Compose equivalent of the lifecycle-aware Flow collection I leaned on when moving a project from RxJava to Kotlin Coroutines. The inner ProfileHeaderContent is a stateless composable that takes state down and sends events up. That split — a thin stateful wrapper at the boundary, stateless content underneath — is what lets you later lift the whole screen into Compose without touching the UI logic.
For the reverse direction, the update block of an AndroidView is itself a state boundary: read your hoisted state there and push it into the legacy view's setters, and Compose will re-run update whenever that state changes.
The pitfalls that actually bite
Double theming
Covered above, but it deserves to be named as the number-one cosmetic bug of a hybrid app. Symptoms: ripple colors, elevation, or font weights that change the instant content crosses a View/Compose boundary. Cause: a composition that is not wrapped in your AppTheme, or an AppTheme that no longer matches the XML tokens. Fix: single source of truth for tokens, and wrap every entry point.
Nested scrolling
A LazyColumn dropped into a screen whose outer container is a CoordinatorLayout (think collapsing toolbars) will not drive the toolbar unless you connect the two scroll systems. Compose has nested scrolling on by default, but the cooperating View parent has to implement NestedScrollingParent3. For that case, use rememberNestedScrollInteropConnection() and hand it to Modifier.nestedScroll:
setContent {
AppTheme {
val nestedScroll = rememberNestedScrollInteropConnection()
LazyColumn(
modifier = Modifier.nestedScroll(nestedScroll)
) {
items(feedItems, key = { it.id }) { item ->
FeedRow(item)
}
}
}
}
This only helps when the parent genuinely cooperates — CoordinatorLayout does. A RecyclerView or ViewPager2 does not implement NestedScrollingParent3, so a seamless continuous scroll from a Compose child up into those parents is not something the interop connection solves for you; redesign the scroll ownership instead of trying to force it.
Composition lifetime in recycling containers
Putting a ComposeView into each row of a RecyclerView is a common intermediate step, and the default composition strategy is wrong for it. A recycled row is detached and reused, so you want the composition disposed when the view is detached or returned to the pool — ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool. Get this wrong and you keep live compositions for rows that scrolled off screen, which shows up as memory pressure and jank. As a rule, match the composition strategy to the host's real lifetime: the view lifecycle inside Fragments, the pool lifecycle inside recyclers.
Measuring before you worry
A ComposeView carries a one-time setup cost, so dozens of tiny ComposeViews sprinkled through a legacy layout can cost more than one larger Compose island. The fix is boundary placement, not panic: prefer fewer, larger Compose surfaces over many micro-islands, and let the profiler — not intuition — tell you when a boundary is too chatty.
Where to start
A migration order that has served me well: start with a leaf — a self-contained screen or a component with few dependencies and no complicated scroll behavior, so the interop surface is small. Stand up the shared theme before that first screen, so you are never retrofitting tokens. Keep each migrated screen's state in a ViewModel both worlds can read. And resist the urge to migrate navigation early; it is the last thing to convert, not the first.
An incremental Compose migration is not really about Compose or Views. It is about being honest that for a long stretch your app is two apps wearing one skin, and designing the seam between them on purpose — shared tokens, one state owner per boundary, and a composition lifetime that matches its host. Do that, and the migration stops being a risky rewrite and becomes what it should be: ordinary, boring, shippable progress.