How I Decide Between Jetpack Compose and Views in a Legacy Codebase
· 4 min read

The question isn't whether Jetpack Compose is better than the View system. It is, for most new work. The real question is harder: when you're staring at a codebase with hundreds of XML layouts, custom Views, and Fragment-based navigation, how do you decide what gets rewritten and what stays?
I've navigated this decision multiple times. Here's the framework I use.
The Default Rule
New screens get Compose. Existing screens stay as Views unless there's a specific reason to migrate. This sounds simple, but it requires discipline. The temptation to rewrite existing screens "while you're in there" is strong and almost always a mistake.
When to Migrate an Existing Screen
I migrate a View-based screen to Compose only when all three of these conditions are true:
- The screen needs significant changes anyway. If I'm rebuilding 60% or more of a screen, rewriting in Compose costs roughly the same as modifying the XML.
- The screen is well-tested. Migration introduces risk. Tests give me confidence that the behavior is preserved.
- The team can review it. If nobody else on the team knows Compose well enough to review the PR, the migration creates a knowledge silo.
// Decision tree in practice
fun shouldMigrateToCompose(screen: Screen): Boolean {
val changeMagnitude = screen.plannedChangePercentage
val hasTests = screen.testCoverage > 0.6
val teamCanReview = team.composeExperienceLevel >= INTERMEDIATE
return changeMagnitude > 0.6 && hasTests && teamCanReview
}When Views Are Still the Right Choice
Compose isn't always better. These are the situations where I stick with Views:
Custom drawing and Canvas work. Compose Canvas is capable, but complex custom Views with intricate touch handling, custom layout algorithms, or heavy Canvas drawing are often better left as Views. Wrapping them with AndroidView is perfectly fine.
Performance-critical lists with complex items. RecyclerView with ViewHolder recycling is still more predictable for very long lists with heterogeneous, complex item types. Compose LazyColumn is great for most cases, but when you need precise control over prefetching, item animations, and recycling behavior, RecyclerView gives you more knobs.
Tight deadlines. If the team is more productive in Views and the deadline is real, writing Views is the pragmatic choice. Tech debt is a real thing, but missing a launch is worse.
The Interop Strategy
The key insight is that you don't have to choose one or the other. Compose and Views coexist well. The interop layer is solid.
// Compose inside a Fragment (gradual migration)
class ProfileFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
AppTheme {
ProfileScreen(viewModel = viewModel())
}
}
}
}
}
// View inside Compose (using existing custom views)
@Composable
fun VideoPlayer(videoUrl: String) {
AndroidView(
factory = { context ->
CustomVideoPlayerView(context).apply {
setVideoSource(videoUrl)
}
},
update = { view ->
view.setVideoSource(videoUrl)
}
)
}The migration doesn't have to be all-or-nothing. Ship one Compose screen. Let it run in production for a week. Then ship another. The incremental approach is slower on paper but faster in practice because you catch integration issues early.
The Mistake I See Most Often
Teams that try to migrate everything at once. They create a "Compose migration" epic, estimate it at three months, and then spend six months fighting edge cases they didn't anticipate. Meanwhile, feature work stalls.
The better approach: migrate opportunistically. Every new feature is Compose. Every major screen rewrite is Compose. Everything else stays as Views until it naturally comes up for changes. In two years, most of the codebase is Compose. No dedicated migration sprint needed.
Patience is the strategy. Compose will win by default if you let it.