I resisted Compose for about a year after it went stable. Had too many "1.0" frameworks burn me in the past. But eventually I tried it on a small project, and I haven't written a new XML layout since.
That doesn't mean Compose is perfect. After using it in production for two years now, here's my honest assessment.
What Compose Gets Right
No More UI Update Bugs
This is the big one. In XML-land, you have state in one place and UI in another. Keeping them in sync is your problem:
// The old way - state and UI are separate
class CounterActivity : AppCompatActivity() {
private var count = 0
private lateinit var textView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)
textView = findViewById(R.id.countText)
val button = findViewById<Button>(R.id.incrementButton)
button.setOnClickListener {
count++
textView.text = "Count: $count" // Forgot this once. Bug took hours to find.
}
}
}
I can't count how many bugs I've fixed where someone updated state but forgot to update the UI, or updated the UI twice, or updated it with stale data.
Compose eliminates this entire category of bugs:
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
// UI updates automatically. No way to forget.
}
When count changes, the UI updates. There's no step to forget.
Lists Are So Much Simpler
RecyclerView is powerful but tedious. Every list needs an adapter, a ViewHolder, an XML layout, and careful management of view recycling. I've spent way too much of my career writing RecyclerView boilerplate.
The old way:
// XML layout file
// Adapter class with onCreateViewHolder, onBindViewHolder
// ViewHolder class with findViewById for each view
// DiffUtil callback for efficient updates
// Item decorations for spacing
// About 80-100 lines total for a simple list
Compose:
@Composable
fun UserList(users: List<User>) {
LazyColumn {
items(users) { user ->
Column {
Text(user.name)
Text(user.email)
}
}
}
}
That's it. Ten lines. Animation and efficient recycling are built in.
Everything Is Just Kotlin
No more switching between XML and Kotlin. No more learning which attributes go in XML vs code. No more app: vs android: namespace confusion.
Your entire UI is Kotlin functions. If you know Kotlin, you can read any Compose code. Styles, themes, animations, layouts - all the same language.
Previews That Actually Work
Android Studio's XML preview was always flaky. Half the time it wouldn't render, especially with custom views or complex layouts.
Compose previews work reliably:
@Preview(showBackground = true)
@Composable
fun UserCardPreview() {
MaterialTheme {
UserCard(User("John Doe", "[email protected]"))
}
}
You can have multiple previews - light mode, dark mode, different data states - all visible at once. This changed how I develop UI. I actually use the preview now instead of running the app for every change.
Animations Are Easy
Animations in the View system required understanding multiple APIs (ObjectAnimator, ValueAnimator, LayoutTransition, MotionLayout). Most developers avoided animations because they were hard to get right.
Compose has simple animation APIs:
val alpha by animateFloatAsState(if (visible) 1f else 0f)
Box(modifier = Modifier.alpha(alpha)) {
Content()
}
I add animations to things now that I never would have bothered with in XML.
Where Compose Still Struggles
The Learning Curve Is Real
I picked up Compose quickly because I already knew Kotlin and React. For developers coming from Java and XML, it's a bigger jump. The mental model is completely different.
Concepts that trip people up:
- Recomposition: Understanding when and why your composable reruns
- State hoisting: Knowing where state should live
- Remember vs rememberSaveable: When each is appropriate
- Side effects: LaunchedEffect, DisposableEffect, SideEffect - when to use which
Budget time for learning. Don't expect the team to be productive immediately.
Build Times
Compose compilation is slower than XML layouts. On my machine:
- Clean build with Compose: noticeably longer
- Incremental builds: usually fine
- Large Compose codebases: can get slow
Kotlin 2.2.21 improved this with better configuration cache support. Still not as fast as pure XML, but the productivity gains outweigh the build time cost for me.
APK Size Increase
Compose adds to your APK:
- Compose runtime: ~200-500 KB
- Material 3: additional overhead
- Compose compiler output tends to be larger than XML
For most apps this doesn't matter. If you're optimizing for APK size in emerging markets, measure the impact.
Some Libraries Haven't Caught Up
Most popular libraries work with Compose now, but occasionally you hit one that only provides XML views. You can use AndroidView to embed traditional views in Compose, but it's awkward.
Debugging Can Be Tricky
Debugging Compose is different from debugging Views. The Layout Inspector has improved, but understanding recomposition behavior sometimes requires reading compiler output or using the composition local inspector.
Real-World Comparison: Same Feature, Both Approaches
Here's a user profile component in both systems:
XML + Activity:
// layout_user_profile.xml - 30 lines
// UserProfileActivity.kt:
class UserProfileActivity : AppCompatActivity() {
private val viewModel: UserProfileViewModel by viewModels()
private lateinit var nameText: TextView
private lateinit var emailText: TextView
private lateinit var avatarImage: ImageView
private lateinit var loadingProgress: ProgressBar
private lateinit var errorText: TextView
private lateinit var retryButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.layout_user_profile)
nameText = findViewById(R.id.nameText)
emailText = findViewById(R.id.emailText)
avatarImage = findViewById(R.id.avatarImage)
loadingProgress = findViewById(R.id.loadingProgress)
errorText = findViewById(R.id.errorText)
retryButton = findViewById(R.id.retryButton)
retryButton.setOnClickListener { viewModel.retry() }
viewModel.state.observe(this) { state ->
loadingProgress.visibility = if (state.isLoading) View.VISIBLE else View.GONE
errorText.visibility = if (state.error != null) View.VISIBLE else View.GONE
errorText.text = state.error
retryButton.visibility = if (state.error != null) View.VISIBLE else View.GONE
state.user?.let { user ->
nameText.text = user.name
emailText.text = user.email
Glide.with(this).load(user.avatarUrl).into(avatarImage)
}
}
}
}
Compose:
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
when {
state.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.error != null -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(state.error!!)
Button(onClick = { viewModel.retry() }) {
Text("Retry")
}
}
}
state.user != null -> {
Column(modifier = Modifier.padding(16.dp)) {
AsyncImage(
model = state.user!!.avatarUrl,
contentDescription = null,
modifier = Modifier.size(100.dp).clip(CircleShape)
)
Text(state.user!!.name, style = MaterialTheme.typography.headlineSmall)
Text(state.user!!.email)
}
}
}
}
The Compose version is shorter, but more importantly, it's clearer. The when/else structure makes the states obvious. No visibility toggling scattered throughout.
Should You Use Compose?
Use Compose for:
- New projects
- New features in existing apps
- Teams comfortable with Kotlin
- Apps where developer velocity matters more than APK size
Stay with XML for:
- Apps already working fine with XML
- Teams with limited Kotlin experience
- Apps targeting extremely low-end devices where you need every byte
- Cases where you need libraries that don't support Compose
Hybrid approach:
You can mix them. I have apps with Compose screens and XML screens coexisting. Use ComposeView to add Compose to XML, or AndroidView to add XML views to Compose.
Migration Strategy
If you're migrating an existing app:
- Don't rewrite everything at once. That's how projects fail.
- Start with new screens. Write new features in Compose.
- Migrate leaf screens first. Simple screens with no shared components.
- Create Compose versions of shared components. As you need them.
- Migrate complex screens last. When you're comfortable.
I migrated one app over 6 months, screen by screen, while shipping features. Much less risky than a big-bang rewrite.
Patterns That Work Well in Compose
State hoisting:
// Lift state up so composables are reusable
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit
) {
TextField(value = query, onValueChange = onQueryChange)
}
// Parent controls the state
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
}
Using remember correctly:
// Cache expensive calculations
val sortedItems = remember(items) {
items.sortedBy { it.name } // Only recalculates when items changes
}
// Store state across recomposition
var expanded by remember { mutableStateOf(false) }
Side effects:
@Composable
fun UserScreen(userId: String, viewModel: UserViewModel) {
// Load data when userId changes
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
val user by viewModel.user.collectAsState()
// UI
}
Final Thoughts
I'm not going back to XML for new development. The productivity gains are too significant. But I also don't regret learning the View system - that knowledge helps when debugging Compose internals or integrating legacy code.
If you're on the fence, try building one screen in Compose. That's what convinced me. The code is more readable, the development experience is better, and once you internalize the mental model, you'll wonder why you ever wrote XML layouts.
Just budget time for the learning curve. It's not trivial.