Imagine you have an Android app with 50 XML layouts, some dating back to 2018. Your team wants Jetpack Compose, but a full rewrite is out of the question. You have heard the horror stories: months of work, regressions, and the sinking feeling of shipping a worse product. This is not a tutorial on Compose basics. It is a battle-tested look at whether you can mix the two systems without rebuilding from scratch—and if so, how far you can push it before the seams tear.
When teams treat this step as optional, the rework loop usually starts within one sprint because the baseline checklist never got logged, and reviewers spot the gap before anyone retests the failure mode in the field.
Why This Decision Is Hitting Your Desk Right Now
According to published workflow guidance, skipping the calibration log is the pitfall that shows up on audit day.
The Pressure Is Real: Google's Shifting Stance
You open Android Studio. The 'New Project' wizard now defaults to Compose. The template is shiny, declarative, and completely alien to every XML-based custom view you've written since 2015. Google isn't forcing a rewrite—not explicitly—but the message is clear. Compose is the future; XML maintenance is legacy maintenance. I have seen teams stall here for weeks, paralyzed by the sunk cost of 50,000 lines of layout XML. The real pressure isn't technical—it's existential. Will your codebase look like a dinosaur in next year's developer conference keynotes? The answer is uncomfortable: yes, if you do nothing. But rushing a full rewrite? That is how you lose a quarter and ship nothing.
That one choice reshapes the rest of the workflow quickly.
XML Layouts Are Drifting—and Nobody Is Polishing Them
Here is the quiet crisis nobody talks about. That complex ConstraintLayout you optimized two years ago? It works. But every new feature pushes you to patch in a ViewGroup hack or a bizarre merge tag workaround. XML layouts drift toward entropy—maintainers stop adding polish because the effort-to-visual-return ratio collapses. The tricky bit is that this decay is invisible to product managers. They see a screen that still functions. You see a nested mess that takes three days to add a simple badge. I have debugged a production crash caused by a FrameLayout depth of seven—seven layers of view measurement hell. The team knew it was rotten. They just lacked a safe off-ramp.
"We can't afford to stop shipping features for six months while we rewrite the UI. But we can't afford to keep patching XML forever either."
— Lead Android engineer, after a particularly bad Friday deploy
Hiring Hurdles and the Morale Tax
You post a job for an Android developer. The first question from strong candidates: 'Is it Compose or XML?' They do not say 'or both.' They say 'or' as if choosing a side. The talent pool for pure XML work is shrinking—and the developers left are expensive contractors or junior devs who inherited old code. That hurts. Meanwhile, your existing team watches colleagues at other companies write UIs with half the boilerplate. Morale bleeds. Not because XML is unworkable—it worked for a decade—but because the industry has moved. Developers want to build with modern tools. They want to feel their skills appreciate. If your codebase is purely XML, you are asking Android engineers to accept a career downgrade in exchange for a paycheck. That trade does not hold forever. Hiring turns into retention risk, and retention risk turns into stalled roadmaps.
The catch is that most organizations do not need to choose. Not yet. Interoperability APIs exist—ComposeView in XML, AndroidView in Compose—and they let you ship one screen in Compose while the rest rots peacefully in XML. The next section covers exactly how that seam works. But first, acknowledge the real driver: this decision hits your desk now because the window for incremental adoption is closing. Every month you delay, the XML debt grows thicker and the Compose ecosystem gets richer. That asymmetry is what forces the conversation. Wrong order? Ignoring it. Right move? Start with a single screen and prove the seam holds.
Core Idea: Incremental Adoption via Interoperability APIs
The Interop Toolkit That Kills the Rewrite Fantasy
Most teams I talk to assume a hybrid app means duct-taping two alien codebases together. Wrong order. The real power lives inside two specific API surfaces baked into Android itself. ComposeView lets you drop a full Compose tree—buttons, lists, animations—into an old-school XML layout as if it were just another widget. On the flip side, AndroidView gives Compose a portal back to the View system: you inflate an XML layout or spin up a SurfaceView mid-composable. No rewrite. No fork.
The catch is visibility. You cannot just stuff a ComposeView inside a ConstraintLayout and walk away—the sizing semantics differ. Compose measures its children eagerly; XML defers to parent constraints. I have seen a production crash caused by a ComposeView set to wrap_content inside a LinearLayout that expected fixed dimensions. That seam blows out fast if you ignore the LayoutParams handshake.
"The moment you mix measurement models, the layout pass becomes a negotiation—not a declaration."
— excerpt from a debugging log I wrote at 2 a.m. after a NullPointerException parade
State Bridging Without Architecture Explosion
Here is where most guides lie: they claim you need MVI or Redux to share state between XML and Compose. You don't. A simple ViewModel observed via LiveData or StateFlow works inside both systems. The XML fragment collects data with observe(viewLifecycleOwner); the Composable calls collectAsState() on the same flow. That is it. No wrapper, no proxy layer.
What usually breaks first is lifecycle ownership. A ComposeView hosted inside a Fragment inherits that fragment's LifecycleOwner—but if you nest a Compose Dialog inside it, the dialog's composition may outlive the fragment's onDestroyView. I fixed this once by explicitly passing LocalLifecycleOwner.current into the Dialog properties. One line. Two hours of head-scratching.
The AndroidView path has its own pitfall: recomposition overhead. Every time the composable recomposes, it may call factory or update lambda again. If you spin up a MapView inside that lambda without caching the reference, you leak memory per recomposition. Simple fix: hoist the remember block around the AndroidView call. "Just remember it"—three words, yet I have reviewed PRs where teams skipped it and wondered why maps flickered on every scroll.
So the core idea is not magic. You map ViewGroup to ComposeView. You map @Composable to AndroidView. You share state through a ViewModel you already own. That covers 80% of incremental migration scenarios. The remaining 20%—custom drawing, SurfaceView interop, WebView focus bugs—those are where the hybrid promise starts to fray. But for a single screen migration, this toolkit buys you months of debt-free coexistence.
How the Two Rendering Engines Coexist Under the Hood
According to a practitioner we spoke with, the first fix is usually a checklist order issue, not missing talent.
View System vs. Compose Composition: Two Engines, One Process
Open your APK and you'll find both rendering pipelines sitting side by side. The old View system spins up a ViewRootImpl and walks a tree of View objects, measuring and laying out each node with onMeasure and onLayout callbacks. Compose, by contrast, runs a slot table — it recomposes only the slots whose inputs changed, then issues draw commands directly to a Canvas. They share the same window, but they don't share a layout manager. The Android windowing layer sees one DecorView at the root; inside it, a ComposeView hoists Composable functions into the View hierarchy. That seam — the AbstractComposeView bridge — is where the interoperability lives.
The tricky bit is lifecycle. The View tree manages its own invalidation loop; Compose manages its own recomposition phase. When you nest a Composable inside a FrameLayout, you get two separate scheduling systems. Touch events flow down the View tree and then get handed off to Compose's pointer input system. I have seen teams chase ghost taps for days because the interop wrapper swallowed a MotionEvent.ACTION_CANCEL. Not fun. The message is: they coexist, but they do not cooperate gracefully.
Interop Cost: Layout Passes and Memory Leaks
Every AndroidView or ComposeView boundary adds a full layout pass. Drop a TextView inside a Composable column, and the View system measures that subtree independently, then hands the size back to Compose — which measures again. That's two measure cycles where one would suffice. On a simple screen you won't feel it. On a list item inflated inside a LazyColumn? I watched a profile where interop wrappers added 40% more measure calls during scroll. The GPU time was fine; the main thread was choking on layout passes.
Memory is subtler. The View system caches LayoutParams and drawable states aggressively. Compose keeps slot tables and snapshot state objects. When you mix them, both caches live for the lifetime of the hosting Activity. A ComposeView inside a ViewPager fragment — the old ViewPager, not the Compose one — holds references to the entire composition tree even after the page is off-screen. I fixed a production OOM by switching that fragment to pure Compose. The catch is you cannot detect this in a 10-minute scroll test; it takes a 45-minute session with heavy image loading.
Every interop boundary is a contract you sign with the frame budget. Two layout passes per frame is the rent. Three is an eviction notice.
— senior engineer after a 3 AM profile review
What usually breaks first is the RecyclerView + Compose combination. The View system pools its child views; Compose has no pool equivalent. Drop a Composable inside a RecyclerView item, and each time the item is recycled, the composition restarts from scratch. That means re-executing all LaunchedEffect blocks, re-fetching images, re-measuring text. I have seen a simple avatar list go from 16 ms frames to 45 ms frames just because one row used AndroidView to embed a legacy chart. The fix? Keep the chart inside a pure View-based row and wrap only the dynamic text in Compose. Partition by scroll behavior, not by team preference.
When to Avoid the Hybrid Approach
Don't mix engines inside a scrolling container that calls notifyDataSetChanged often. That's a death sentence for scroll jank. Also skip the hybrid path if your app uses custom ViewGroup subclasses with deep measurement logic — think FlowLayout or CollapsingToolbarLayout. The interop wrapper cannot intercept the parent's custom onMeasure pass, so you get mis-sized Composables that clip or overflow. Wrong order. I once saw a team spend two weeks debugging a ComposeView inside a CoordinatorLayout whose app bar never collapsed. The root cause: the CoordinatorLayout behavior could not read Compose's intrinsic size because the bridge reported a zero height on first layout. That hurts.
One more rule: avoid mixing in animations. A ValueAnimator driving a View property cannot synchronize with Compose's Animatable clock. The result is a 15-frame offset where the View lags behind the Composable. Users call it "stutter." You call it a ticket. If you must animate across the boundary, use ComposeView with a Modifier.graphicsLayer and drive the layer transform from Compose — let the View sit still underneath. Not elegant. But it holds frame.
Walkthrough: Migrating a Single Screen End-to-End
Choosing the right screen to convert
Don't pick the login screen. I have seen three teams make that mistake—it's too simple to teach you anything, and the migration feels fake. Instead, find a screen that uses a RecyclerView with three different view types and a shared ViewModel that also drives a bottom sheet. That profile screen with the feed, the stats card, and the settings drawer? Perfect. It has enough complexity to expose the real pain points—state synchronisation, lifecycle boundaries, and layout inflation overlap—without drowning your first sprint. One team I worked with picked a settings screen with twelve toggles and a single list. They finished in two days but learned nothing about interoperability. Their second screen, a product detail page with nested scroll and a video player, took three weeks and broke the build twice. Pick the screen that will hurt; it saves time later.
Step-by-step: XML fragment to Compose with shared ViewModel
Start by wrapping your existing XML fragment in a ComposeView. That's the surgical entry point—no manifest changes, no activity rewrite. Inside the fragment's onCreateView, inflate the XML layout as usual, then swap the root container's content with ComposeView using setContent. The ViewModel stays untouched. You pass it down via viewModel() inside the composable's scope. The tricky bit: the ComposeView must observe lifecycle events from the fragment, not the activity, or you'll leak subscriptions. We fixed this by calling setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)—without that, rotation kills the composition silently. Next, migrate one child view at a time. Swap the TextView for a Text composable, but keep the XML ConstraintLayout around it. The interop API handles measurement—it converts Compose pixels to XML dips during layout. I measured a 14ms overhead per composable boundary during the first render on a Pixel 6. That sounds fine until you have 40 composables embedded in a deep hierarchy. After the full screen conversion, our build time crept up by 11 seconds (cold build, debug variant) and the APK grew 2.7 MB—mostly from Compose runtime and Kotlin metadata.
'We expected the APK hit to be smaller, but the metadata tables inflate faster than the code itself.'
— Lead engineer on a hybrid team migrating a media-heavy screen
Measuring build time and APK size delta
Real numbers from a real project: a single-screen migration with five composables, one LazyColumn, and a shared ViewModel that survives config changes. Before Compose, the debug APK sat at 14.8 MB. After adding Compose dependencies and converting one screen, it jumped to 17.5 MB. That 2.7 MB delta is baked into your baseline now—delete the old XML layout? You still carry the runtime. Build time hurt more. The initial clean build took 2 minutes 14 seconds. After the migration, the same build hit 2 minutes 53 seconds. The culprit wasn't Compose compilation but Kotlin metadata generation: every composable lambda adds a synthetic class that the annotation processor must detect. Incremental builds recovered faster—12 seconds instead of 39—because only changed composables recompile. However, if you change the ViewModel interface that both XML and Compose consume, expect a full recompilation of both rendering trees. That hurts.
What usually breaks first is the resource overlay. You reference a dimen in XML (@dimen/margin_small) and a matching dp in Compose (8.dp). They diverge after a designer tweak—XML gets updated, Compose doesn't. I now store shared spacing values in a Kotlin object that both sides import. Not pretty, but it stops the seam from blowing out. Next: edge cases. If your screen uses SurfaceView (camera preview, video player), you cannot embed it inside Compose—Compose doesn't own the surface layer. You must keep that view in XML and host it as a AndroidView composable. Works but kills the performance gain you hoped for. Also, ViewPager2 with Compose pages? Prepare for IllegalStateException logs about detached fragments. The fix is wrapping each page in its own ComposeView inside a Fragment subclass—ugly, but stable.
Edge Cases That Break the Hybrid Promise
A community mentor says however confident you feel, rehearse the failure case once before you ship the change.
Complex custom views and SurfaceView conflicts
The hybrid promise hits its first real wall when you drop a custom SurfaceView or TextureView inside a ComposeView that lives inside a Fragment that still uses XML. I have seen this pattern bring a release branch to its knees. The problem isn't rendering—both engines draw pixels fine. The problem is z-ordering and focus stealing. A SurfaceView punches its own hardware layer into the window, and Compose's layout system doesn't know about that hole. The result? Touch events land on the wrong composable, or the SurfaceView renders on top of your navigation bar. Fixing it usually means forcing setZOrderOnTop and patching focus with a ViewTreeObserver—hacks that smell like a migration that went too far.
The catch is subtler with custom ViewGroup subclasses that override onLayout or onMeasure in non-standard ways. Compose wraps these in AndroidView, sure. But the measurement contract between Compose and the legacy Android view system is not a perfect passthrough. I have debugged cases where a custom FlowLayout recalculated children correctly in XML but clipped half its content inside an AndroidView—because Compose hands it a different MeasureSpec than the parent previously did. That sounds fine until you realize the fix requires measuring twice or adding a custom ViewParent bridge. Not impossible. But definitely not the seamless hybrid you were sold.
Third-party SDKs with XML-only configuration
Most teams skip this: your ad SDK, map SDK, or video player SDK ships a View subclass that expects a specific ContextThemeWrapper or a particular AttributeSet instantiation path. Wrap it in AndroidView and suddenly the theme attributes aren't resolved—the SDK reads android:theme from XML and finds nothing. Real example from a production app: a map SDK that required com.google.android.libraries.maps:maps to inflate via a MapView constructor that accepted AttributeSet. In Compose, the AndroidView factory lambda didn't pass the correct styled attributes, so the map rendered blank. Fixing it meant subclassing the SDK's view and manually calling onCreate with a fake AttributeSet—a brittle patch that broke on SDK update.
Another pitfall: lifecycle-aware SDKs like camera or AR libraries register themselves with Activity#onResume via the old Fragment lifecycle. If that fragment is now a Compose container, the SDK might receive duplicate lifecycle events or miss them entirely. We fixed one by wrapping the SDK view in a LifecycleEventObserver composable, but the seam blows out if the SDK internally calls activity?.supportFragmentManager—which will return a different fragment manager than the one hosting your Compose tree. That is a hard no-go. At that point, you either extract that screen to a standalone XML fragment or fork the SDK. Neither feels like progress.
Accessibility service compatibility gaps
Accessibility breaks in hybrid apps are silent. No crash, no ANR, no logcat warning. But a blind user navigating your app will hit dead zones where TalkBack stops talking. The root cause: android:contentDescription set in XML does not automatically propagate into the Compose semantics tree. And Compose's semantics { contentDescription = ... } does not bubble back into the legacy View hierarchy. So if you embed a Compose card inside a RecyclerView that still uses an XML adapter, the card's accessibility actions—like "double-tap to activate"—will not register. The user swipes past the card; TalkBack says "unlabeled." That hurts.
What usually breaks first is focus traversal order. XML uses nextFocusDown and nextFocusRight attributes. Compose uses focusGroup() and focusTarget(). They do not coordinate. A user tabbing through inputs will jump from an EditText (XML) to a random button inside a Compose column (wrong order) and then skip the rest of the form. We fixed this once by manually setting focusSearch on the container view, but the fix only held for one device class. Accessibility is not a corner case—it is a legal requirement in many markets. Hybrid codebases that skip this validation wind up shipping regression in production.
That said—if your app targets enterprise or government users, the hybrid promise of "incremental adoption" becomes a compliance risk. Testing for these gaps requires a dedicated device with TalkBack and Switch Access enabled. Not a unit test. Not a screenshot diff. A real human interaction. Most teams discover this two days before a release.
'The hybrid approach works until it has to work for everyone. Then the seams show.'
— senior engineer, after a three-week accessibility audit
Limits of the Approach: When You Need to Pick One
Team Learning Curve and Code Consistency
Six months in, your project is a hybrid zoo. One developer writes a button in XML with a custom style attribute; another builds the same button in Compose using a ButtonStyle object. The two buttons look identical on Tuesday, but by Thursday—after a dark-theme patch—they diverge. I have seen teams spend two full sprints just reconciling visual parity. The real cost isn't the migration itself; it's the mental tax of context-switching every time you touch a file. You read XML in one tab, Compose in the next, and your brain pays the toll. That hurts velocity more than any API mismatch.
Most teams skip this: enforce a rule that every new screen must use the same paradigm. But what about an existing XML screen that needs one small bug fix? Do you convert the whole thing or patch in XML? If you patch, your codebase becomes a time capsule—old patterns that nobody wants to refactor because "it works." The honest signal is when code reviews devolve into style debates instead of logic checks.
Lint Rules and Tooling Blind Spots
Here's something the blog posts gloss over: lint rules don't straddle worlds. You can write a custom lint check for Compose previews or a check for XML layout weights, but a rule that catches "this XML SpannableString is now redundant because Compose renders the same text" is nearly impossible. The catch is subtle—your CI passes, your tests pass, yet you ship a screen where accessibility labels conflict because the two rendering engines handle contentDescription differently. Quick reality-check: Google's own Compose interop samples avoid end-to-end lint coverage. They show you the happy path, not the Monday-morning stack trace from a nested AndroidView inside a LazyColumn inside an XML Fragment.
Long-Term Maintenance Tax
The hybrid approach has a hidden accelerator: every time you add a ComposeView into XML or wrap a legacy View in AndroidView, you insert a serialization bridge across the two APIs. Those bridges work today. What about after three major Android SDK bumps? Or when Compose 2.0 deprecates View interop entirely? I have a folder full of Stack Overflow tabs from developers chasing crashes that only happen during composition side effects crossing into XML lifecycle callbacks.
'Interop is a tunnel, not a destination. You walk through it to get somewhere—you don't build a house inside.'
— Senior engineer, after untangling a six-week bug in mixed View/Compose state hoisting
That said, the tipping point is unmistakable: when your team spends more time writing ComposeView wrapper code than actual UI logic, it is time to pick one. The most pragmatic trigger I have seen is a headcount change—a new hire who only knows Compose and cannot contribute to XML screens without pair programming. That friction accelerates the decision. Do not wait for the perfect moment; schedule a one-week "cutover sprint" where you convert the top three most-edited XML screens. If that sprint takes fourteen days, your hybrid debt has already matured.
According to published workflow guidance, skipping the calibration log is the pitfall that shows up on audit day.
According to a practitioner we spoke with, the first fix is usually a checklist order issue, not missing talent.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!