On this page
- Quick verdict
- Imperative vs declarative: the core difference
- The same screen, built both ways
- Compose vs XML comparison table
- Performance: what's actually true
- Interop: you don't have to choose all-or-nothing
- Real-world decisions
- A practical migration path from XML to Compose
- Common mistakes when moving to Compose
- Frequently asked questions
- 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
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).
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
<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>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
@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), {}, {})
}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
| Aspect | Jetpack Compose | XML Views |
|---|---|---|
| Paradigm | Declarative, UI = f(state) | Imperative, mutate view objects |
| Language | Kotlin only | XML + Kotlin or Java |
| Lists | LazyColumn, a few lines | RecyclerView + Adapter + ViewHolder + DiffUtil |
| Custom components | A function | Custom View class, attrs.xml, measure/draw overrides |
| Animations | animate*AsState, AnimatedVisibility | Animators, transitions, MotionLayout |
| Previews | @Preview with any state you pass | Layout editor, limited for dynamic state |
| Theming | MaterialTheme in Kotlin, dynamic color | XML themes and styles |
| Testing | Semantics-based Compose test APIs | Espresso |
| Maturity | Stable since 2021, very active development | Over 15 years mature, stable, in maintenance |
| Where you'll see it | New apps and new screens | Existing 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.
// โ 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:
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() } }
}
}@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
AndroidViewinside 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
- Set up Compose and a shared theme. Add the Compose BOM and create a Compose
MaterialThemethat matches your XML theme's colours and typography. - Build new screens in Compose and host them with
ComposeViewor in a Compose navigation graph. - Migrate leaf components such as list items, cards and dialogs, starting with the simplest ones.
- Move whole screens when they're due for a redesign, keeping ViewModels unchanged.
- 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 asLaunchedEffect. - Forgetting
remember:var count = mutableStateOf(0)withoutrememberresets 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.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
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.