๐ŸŽจ Jetpack Compose

Jetpack Compose vs XML: Which Should You Learn?

Compose and XML Views both build Android UIs, but they think about UI in opposite ways. This guide shows the same screen in both, explains recomposition, covers performance and interop, and tells you which to learn first.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 6 min read ๐ŸŽฏ Beginner
On this page
  1. Quick verdict
  2. Imperative vs declarative: the core difference
  3. The same screen, built both ways
  4. Compose vs XML comparison table
  5. Performance: what's actually true
  6. Interop: you don't have to choose all-or-nothing
  7. Real-world decisions
  8. A practical migration path from XML to Compose
  9. Common mistakes when moving to Compose
  10. Frequently asked questions
  11. Conclusion

If you open a five-year-old Android project, you'll find activity_main.xml, findViewById or View Binding, RecyclerView adapters and Fragments. Open a project started recently and you'll likely see @Composable functions and no layout XML at all. Both are "Android UI", but they work very differently.

This article is for beginners deciding what to learn and for developers on XML-based apps deciding whether and how to adopt Compose. You'll see the same screen built both ways, understand why Compose behaves as it does, and get a realistic view of performance, interop and migration.

Quick verdict

Which should you learn?

Learn Jetpack Compose first. Google recommends it for new Android UI, new Material 3 components and adaptive layout APIs are Compose-first, and it requires less code for most screens. Learn enough XML to maintain existing screens, because many production apps are partly migrated and will be for years.

Imperative vs declarative: the core difference

With XML Views you build a tree of View objects once (by inflating XML), then mutate them over time: textView.text = ..., button.isEnabled = false, progress.visibility = View.GONE. Your code is responsible for keeping every view consistent with the data. This is imperative UI.

With Compose you write functions that describe the UI for the current state. When state changes, Compose calls the affected functions again. This is called recomposition. It then updates only what changed. You never call setters on widgets. This is declarative UI: UI = f(state).

Imperative View updates compared with Compose recomposition Top: with Views, an event leads your code to manually update a TextView, a Button and a ProgressBar. Bottom: with Compose, an event changes state, Compose re-runs the composables that read that state, and the screen updates. XML Views (imperative) Event your code mutateseach view by hand textView.text button.enabled progress.visible easy to forget one โ†’ inconsistent UI Jetpack Compose (declarative) Event State changesisLoading = true Recompositionre-run readers of state UI = f(state)always consistent
With Views, consistency is your job. With Compose, the UI is derived from state, so a forgotten update can't leave a stale widget on screen.

The same screen, built both ways

Let's build a simple login form: email field, a button that is enabled only when the email looks valid, and a loading spinner while signing in.

XML + View Binding version

res/layout/activity_login.xmlXML
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <EditText
        android:id="@+id/emailInput"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/email"
        android:inputType="textEmailAddress" />

    <Button
        android:id="@+id/signInButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:enabled="false"
        android:text="@string/sign_in" />

    <ProgressBar
        android:id="@+id/progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:visibility="gone" />
</LinearLayout>
LoginActivity.ktKotlin
class LoginActivity : AppCompatActivity() {
    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.emailInput.doAfterTextChanged { viewModel.onEmailChange(it.toString()) }
        binding.signInButton.setOnClickListener { viewModel.signIn() }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    // Every view must be updated manually for every state.
                    binding.signInButton.isEnabled = state.canSubmit
                    binding.progress.isVisible = state.isLoading
                    binding.emailInput.isEnabled = !state.isLoading
                }
            }
        }
    }
}

Compose version

LoginScreen.ktKotlin
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    LoginContent(
        state = state,
        onEmailChange = viewModel::onEmailChange,
        onSignIn = viewModel::signIn,
    )
}

// Stateless and previewable: everything it shows comes from parameters.
@Composable
fun LoginContent(
    state: LoginUiState,
    onEmailChange: (String) -> Unit,
    onSignIn: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(modifier.fillMaxSize().padding(16.dp)) {
        OutlinedTextField(
            value = state.email,
            onValueChange = onEmailChange,
            label = { Text(stringResource(R.string.email)) },
            enabled = !state.isLoading,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
            modifier = Modifier.fillMaxWidth(),
        )
        Button(
            onClick = onSignIn,
            enabled = state.canSubmit,
            modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
        ) { Text(stringResource(R.string.sign_in)) }

        if (state.isLoading) {
            CircularProgressIndicator(Modifier.align(Alignment.CenterHorizontally).padding(top = 16.dp))
        }
    }
}

@Preview(showBackground = true)
@Composable
private fun LoginPreview() {
    LoginContent(LoginUiState(email = "dev@example.com", canSubmit = true), {}, {})
}
LoginViewModel.kt (shared by both versions)Kotlin
data class LoginUiState(
    val email: String = "",
    val canSubmit: Boolean = false,
    val isLoading: Boolean = false,
)

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

    fun onEmailChange(email: String) = _uiState.update {
        it.copy(email = email, canSubmit = Patterns.EMAIL_ADDRESS.matcher(email).matches())
    }

    fun signIn() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            delay(1_500) // stand-in for a real authentication call
            _uiState.update { it.copy(isLoading = false) }
        }
    }
}

The ViewModel is identical. That's a useful insight: good architecture is UI-toolkit independent. The difference is in the UI layer. The XML version spreads the UI across two files and requires manual syncing for every property. The Compose version keeps structure and behaviour together, and the if (state.isLoading) line is the visibility logic.

Compose vs XML comparison table

AspectJetpack ComposeXML Views
ParadigmDeclarative, UI = f(state)Imperative, mutate view objects
LanguageKotlin onlyXML + Kotlin or Java
ListsLazyColumn, a few linesRecyclerView + Adapter + ViewHolder + DiffUtil
Custom componentsA functionCustom View class, attrs.xml, measure/draw overrides
Animationsanimate*AsState, AnimatedVisibilityAnimators, transitions, MotionLayout
Previews@Preview with any state you passLayout editor, limited for dynamic state
ThemingMaterialTheme in Kotlin, dynamic colorXML themes and styles
TestingSemantics-based Compose test APIsEspresso
MaturityStable since 2021, very active developmentOver 15 years mature, stable, in maintenance
Where you'll see itNew apps and new screensExisting apps and older tutorials

Performance: what's actually true

Both can produce smooth, 60fps-or-better UIs, and both can be made slow. The failure modes are different:

  • XML problems are usually deep or nested layouts causing expensive measure passes, overdraw, and inefficient RecyclerView adapters.
  • Compose problems are usually unnecessary recomposition: reading rapidly changing state too high in the tree, unstable parameters that prevent skipping, or heavy work inside composable bodies.
  • Debug builds of Compose apps are noticeably slower than release builds, because of interpretation, debugging hooks and missing R8 optimisation. Always judge Compose performance on a release build, ideally with Baseline Profiles.
Avoiding unnecessary recompositionKotlin
// โŒ Reads scroll state on every frame at the top of the screen โ†’ whole screen recomposes while scrolling.
val showButton = listState.firstVisibleItemIndex > 0

// โœ… derivedStateOf only notifies readers when the boolean result actually changes.
val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

Interop: you don't have to choose all-or-nothing

Compose was designed for gradual adoption. You can put Compose inside a View hierarchy with ComposeView, and put a View inside Compose with AndroidView:

Compose inside an existing FragmentKotlin
class ProfileFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
        ComposeView(requireContext()).apply {
            // Dispose the composition when the Fragment's view is destroyed.
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent { MaterialTheme { ProfileScreen() } }
        }
}
A classic View inside ComposeKotlin
@Composable
fun LegacyChart(points: List<Float>, modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context -> LineChartView(context) },   // created once
        update = { view -> view.setPoints(points) },         // called when points change
        modifier = modifier,
    )
}

Real-world decisions

New app, small team

Situation
Greenfield product, Android only.
Choice
100% Compose.
Why
Less code, faster iteration with previews, modern components.
Trade-off
The team must learn state and recomposition properly to avoid performance traps.

Large XML app with a release every two weeks

Situation
Hundreds of XML screens, active feature work.
Choice
New screens in Compose; migrate old screens only when redesigned.
Why
Interop allows incremental adoption with no feature freeze.
Trade-off
Two UI systems and two theming setups for a while; keep design tokens in sync.

Complex custom View already working

Situation
A mature, tested custom chart or map View.
Choice
Keep it; wrap with AndroidView inside Compose screens.
Why
Rewriting working, complex drawing code adds risk without user benefit.
Trade-off
That component won't benefit from Compose tooling until rewritten.

A practical migration path from XML to Compose

  1. Set up Compose and a shared theme. Add the Compose BOM and create a Compose MaterialTheme that matches your XML theme's colours and typography.
  2. Build new screens in Compose and host them with ComposeView or in a Compose navigation graph.
  3. Migrate leaf components such as list items, cards and dialogs, starting with the simplest ones.
  4. Move whole screens when they're due for a redesign, keeping ViewModels unchanged.
  5. Finally migrate navigation to Navigation Compose once most screens are composables.

Common mistakes when moving to Compose

  • Treating composables like Views: trying to hold references to them or "update" them. Change state instead.
  • Doing work in the composable body: network calls, database queries or object allocation on every recomposition. Use the ViewModel, remember, or effects such as LaunchedEffect.
  • Forgetting remember: var count = mutableStateOf(0) without remember resets on every recomposition.
  • Hoisting no state at all, or all of it. Keep UI-element state (an expanded flag) local, and keep screen/business state in the ViewModel.
  • Judging performance in debug builds. Measure release builds.

Frequently asked questions

Is XML deprecated in Android?

No. XML Views are still supported and used in many apps. Google's recommendation for new UI development is Compose, and new UI features tend to arrive in Compose first.

Is Jetpack Compose production-ready?

Yes. Compose has been stable since 2021 and is used in many large production apps. Like any toolkit, it needs correct state handling to perform well.

Do I need to learn XML to get an Android job?

Often yes, at least to read and maintain it, because many company codebases are mixed. Learn Compose deeply first and XML enough to work on existing screens.

Does Compose increase APK size?

Compose adds library code. With R8 shrinking in release builds, the impact is usually moderate, and migrating fully can offset it by removing View-based dependencies. Measure your own app with the APK Analyzer.

Can Compose and XML share the same ViewModel?

Yes. ViewModels don't depend on the UI toolkit, which is why the login example above reuses the same ViewModel for both versions.

Conclusion

XML Views ask you to keep the UI in sync with data by hand. Compose derives the UI from state and keeps it in sync for you. For new work, that makes Compose the clear first choice. Your XML knowledge stays valuable for the many apps still in transition, and the interop APIs mean you never need a risky big-bang rewrite.

Next step: build the login screen above in Compose, then add an error message to LoginUiState and display it. It's the simplest way to feel the declarative model. Then read MVVM vs MVI to see how that state should be structured as screens grow.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Jetpack Compose clicks once you build real screens with real state. Practise modern Android UI development on projects that go beyond toy examples.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp