A Jetpack Compose Tutorial for Beginners – Understanding Composables & Recomposition

Jetpack Compose is Android‘s modern toolkit for building native UI. It simplifies and accelerates UI development by combining a reactive programming model with concise and idiomatic Kotlin code. If you‘re an Android developer, you‘ll find that Compose is a radical departure from the old View system – but a welcome one!

In this tutorial, we‘ll cover the fundamental concepts you need to understand to start building UIs with Jetpack Compose. Whether you‘re brand new to Android development or a seasoned pro looking to make the switch to Compose, this guide will give you a solid foundation.

We‘ll begin with an overview of how Compose differs from the traditional View system, then take a closer look at the core building blocks of Compose UI: Composable functions. You‘ll learn how to create your own Composables, work with state, and see how Compose‘s Hierarchy differs from the View Hierarchy.

Performance is crucial when it comes to UI, so we‘ll also dive deep into the topic of Recomposition – what triggers it, how to avoid unnecessary redraws, and best practices for keeping your Compose UI snappy.

Ready to modernize your Android UI development? Let‘s get started!

Jetpack Compose vs. The View System

In Android development, a View is the basic building block of UI. Views are typically defined in XML layout files or created programmatically. They encapsulate things like:

  • Display properties (position, size, color, etc.)
  • User interactions (click listeners, text changed listeners, etc.)
  • Behavior and state

Here‘s a simple example of a TextView defined in XML:

<TextView
    android:id="@+id/textView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello World!"
    android:textColor="@android:color/black"
    android:textSize="24sp"/>

Views are organized into a hierarchy with a single root View. This hierarchy defines the layout of the UI. Parent Views control the size and position of their children.

Jetpack Compose takes a fundamentally different approach. Instead of defining a tree of View objects, we call composable functions that emit UI hierarchy. This is a declarative approach – we simply describe what we want the UI to look like, and Compose takes care of the rest.

Here‘s the equivalent "Hello World" in Compose:

@Composable
fun Greeting(name: String) {
    Text(
        text = "Hello $name!",
        color = Color.Black,
        fontSize = 24.sp
    )
}

The @Composable annotation tells Compose that this is a function that describes a UI. Composable functions can only be called from within the scope of other composable functions.

In this example, we‘re using the built-in Text composable to display a simple string onscreen. The Text composable takes care of measuring and drawing the text with the specified styling.

This declarative approach has several benefits:

  1. It‘s more concise and readable. UI logic is colocated with the composable function, making it easy to see what‘s going on.

  2. It‘s more maintainable. Changes to the UI are localized to the relevant composable functions. There‘s no need to dig through sprawling XML files.

  3. It enables powerful features like hot reloading and animation by default.

Of course, this is just a simple example. Composable functions can get much more complex, with multiple levels of composition, state management, lifecycle effects, and more. But the basic idea remains the same: UI is built up from small, reusable pieces that are easy to reason about.

The Compose Hierarchy

Under the hood, Jetpack Compose maintains its own internal representation of the UI hierarchy. This is conceptually similar to the View hierarchy, but optimized for Compose‘s declarative approach.

The Compose hierarchy is managed by a core part of the Compose runtime: the Composer. Every composable function is ultimately just a description of what to add to the composition. The Composer takes these descriptions and translates them into actual UI elements on screen.

Here‘s a simplified example of how a composable function gets turned into Compose UI:

@Composable 
fun MyScreen() {
    Column {
        Text("Hello")
        Button(onClick = { /* ... */ }) {
            Text("Click me")
        }
    }
}

// Composition:
// MyScreen
//   Column
//     Text 
//     Button
//       Text

When MyScreen is called, it doesn‘t immediately create or mutate UI elements. Instead, it emits a description of the desired UI hierarchy to the Composer. The Composer then compares this description to the current composition, and makes the necessary changes to update the UI.

This diffing process allows Compose to be smart about only updating the parts of the UI that have actually changed. In the example above, if the button‘s onClick changes, only the button needs to be recomposed – the text above it can be left alone. This granular recomposition is key to building performant Compose UIs.

State in Compose

UI is all about displaying application data (state) on screen, and updating based on changes to that data. In Compose, state is typically modeled as a value that changes over time – a mutable value holder.

Composables that use state are called stateful composables. A simple example is a composable that displays a counter value and increments it when a button is clicked:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

Here, the current value of the counter is stored in a state variable called count. This is created using the mutableStateOf function and the remember helper.

Whenever count changes, the Counter composable will be recomposed to display the new value. This happens automatically thanks to Compose‘s state tracking mechanisms.

It‘s important to note that composable functions themselves should be stateless. They should take in any necessary data as parameters, and only use local state for things that don‘t need to be shared or persisted across recompositions.

Recomposition

We‘ve mentioned recomposition a few times now, so let‘s dive into the details. Recomposition is the process of Compose updating the UI in response to changes in state or other inputs.

A composable function can be recomposed for a few reasons:

  1. Its inputs (parameters) have changed
  2. Some state it reads has changed (like in the Counter example above)
  3. It‘s explicitly invalidated (using Recomposer.invalidate or a state mutation)

When a composable is recomposed, Compose will re-execute its body to get an updated UI description. This means any expressions in the composable will be re-evaluated.

However, recomposition is optimized such that only the composables that actually depend on the changed state will be recomposed. Let‘s look at an example:

@Composable
fun NamePicker() {
    var names by remember { mutableStateOf(listOf("Alice", "Bob", "Charlie")) }
    var selected by remember { mutableStateOf(0) }

    Column {
        Text("Selected: ${names[selected]}")

        Row {
            for (i in names.indices) {
                Text(
                    text = names[i], 
                    modifier = Modifier.clickable { selected = i }
                )
            }
        }

        Button(onClick = {
            names = listOf("Anna", "Bella", "Cora")
        }) {
            Text("Change names")
        }
    }
}

In this example, we have two pieces of state: the names list and the selected index. When either of these changes, Compose will recompose only the parts of the UI that depend on that state:

  • Changing selected only triggers a recomposition of the Text that displays the current selection, since that‘s the only part of the UI that uses selected.
  • Changing names triggers a recomposition of the whole NamePicker, since multiple parts depend on it.

This granular recomposition is what enables Compose UIs to scale. No matter how complex a composable gets, Compose ensures that only the minimum necessary set of composables will be redrawn on state changes.

Optimizing Recompositions

While Compose is designed to handle recomposition efficiently, there are still things you can do to optimize your composables further. After all, the most efficient recomposition is the one that doesn‘t happen at all!

Here are some tips for avoiding unnecessary recompositions in your Compose code:

  1. Use by when reading state in composables. This tells Compose to set up a dependency between the composable and that state, so it will only recompose when the state actually changes.

  2. Prefer small, focused composables. The more a composable does, the more likely it is to be recomposed due to unrelated state changes. Breaking your UI into smaller pieces helps localize recompositions.

  3. Avoid object allocations in composable bodies. Allocating new objects on every recomposition can add up to poor performance quickly. Instead, hoist object creation out of the composable using remember or other state mechanisms.

  4. Use keys to differentiate between dynamic content. When rendering a list or grid of items, provide a unique, stable key for each item. This allows Compose to accurately track which items have changed and avoid recomposing the entire list on every update.

  5. Avoid nesting recomposition scopes. Tools like LaunchedEffect, DisposableEffect, and SideEffect create their own recomposition scopes. Nesting these without care can lead to duplicate effects and degraded performance.

  6. Profile your app! Compose makes it easy to see what‘s being recomposed and why. Use the Compose Layout Inspector and other profiling tools to identify and fix recomposition bottlenecks.

Conclusion

Jetpack Compose is a powerful toolkit for building Android UIs, but it does require a shift in thinking from the traditional View system. By understanding how Composable functions work, how state drives the UI, and how recomposition is optimized, you‘ll be well on your way to creating efficient, scalable Compose UIs.

Remember, the key concepts to internalize are:

  1. UI is described by composable functions, which emit a hierarchy of UI elements.
  2. State is modeled as values that change over time, and used as inputs to composables.
  3. Recomposition is the process of updating the UI in response to state changes, and is optimized to minimize unnecessary work.

From here, the best way to really learn Compose is to dive in and start building! Experiment with different UI patterns, explore the various state APIs Compose provides, and most importantly, have fun with it.

Here are some great resources to continue your Compose journey:

Happy composing!

Similar Posts