Sitemap

Android Rendering Pipeline: From Compose State to Pixels

6 min read4 days ago

--

When we update a State value in Jetpack Compose, the UI changes almost immediately. A button changes its color, a loading indicator appears, or a list displays new data.

But have you ever wondered what actually happens between changing a Kotlin variable and seeing new pixels on the screen?

There is quite a lot happening behind the scenes.

Android doesn’t simply take your composable function and draw it directly onto the display. Compose, the Android rendering system, and the GPU work together to turn application state into the final image.

Let’s follow that journey step by step.

It Starts With a State Change

Consider a simple Compose screen:

@Composable
fun CounterScreen() {
var count by remember {
mutableIntStateOf(0)
}
    Column {
Text(text = "Count: $count")
Button(
onClick = { count++ }
) {
Text("Increment")
}
}
}

When the user clicks the button, count changes.

count++

This is where the rendering process begins.

Compose automatically tracks state reads. Because the Text composable reads count, Compose knows that this part of the UI depends on that value.

When the value changes, Compose schedules recomposition.

An important detail: Compose doesn’t necessarily execute the entire screen again. It can skip composables whose inputs haven’t changed.

The Three Main Rendering Phases

Jetpack Compose processes UI updates through three main phases:

  1. Composition
  2. Layout
  3. Drawing

Each phase has a different responsibility.

Phase 1: Composition

Composition answers one question:

What should the UI look like?

Compose executes composable functions and creates or updates its internal UI tree.

For example:

Text(text = "Count: $count")

When count changes from 0 to 1, Compose updates the description of the UI to reflect the new value.

It doesn’t directly draw pixels at this stage.

It determines what needs to be displayed.

Phase 2: Layout

Once composition is complete, Compose needs to determine where everything belongs.

The layout phase has two important steps:

  • Measurement: Determine the width and height of each element.
  • Placement: Determine the position of each element on the screen.

For example, consider a Column containing three Text elements.

Compose measures the children, calculates the required dimensions, and positions them vertically.

The result is a layout tree containing the size and position information required for drawing.

Phase 3: Drawing

Now Compose knows what to display and where to display it.

The drawing phase is responsible for rendering the actual visual content.

Text, images, backgrounds, shapes, and custom Canvas operations are translated into drawing operations.

These operations are recorded into rendering commands that Android can process.

This is where your UI starts becoming something the graphics system can display.

What Happens After Compose Finishes Drawing?

This is where things get more interesting.

Compose handles the UI description, layout, and drawing instructions. Android’s graphics pipeline takes those instructions further.

A simplified rendering flow looks like this:

Let’s understand the important components.

RenderThread

Android uses a dedicated rendering thread to handle graphics-related work.

It helps prepare and process rendering operations without putting all the work on the application’s main thread.

This separation is important because the main thread is also responsible for input handling and application logic.

GPU (Graphics Processing Unit)

The GPU handles graphics operations such as:

  • Rendering shapes and images.
  • Applying transformations.
  • Processing layers.
  • Handling graphical effects.
  • Compositing visual content.

Modern Android devices use hardware acceleration to make these operations more efficient.

SurfaceFlinger

Even after your application has rendered its content, the frame still needs to become part of the final screen image.

Android uses SurfaceFlinger as its system compositor.

It combines application surfaces with other system surfaces, such as the status bar and navigation bar, to produce the final display output.

The Hardware Composer can also assist with combining display layers.

This is why rendering a Compose screen involves more than just Compose itself.

Why Recomposition Doesn’t Always Mean Redrawing Everything

One common misunderstanding about Jetpack Compose is that every recomposition causes the entire screen to be redrawn.

That’s not how it works.

Get Syed Ovais Akhtar’s stories in your inbox

Join Medium for free to get updates from this writer.

Compose tracks state reads and uses this information to determine which phases need to run again.

For example:

var backgroundColor by remember {
mutableStateOf(Color.Red)
}
Canvas(
modifier = Modifier.size(100.dp)
) {
drawRect(backgroundColor)
}

Here, backgroundColor is read inside the drawing phase.

When its value changes, Compose can restart drawing without repeating composition and layout.

This can save unnecessary work.

The same principle applies to layout-related state.

For frequently changing values, such as animation offsets, you can defer state reads to the layout phase.

var offsetX by remember {
mutableIntStateOf(0)
}
Box(
modifier = Modifier.offset {
IntOffset(offsetX, 0)
}
)

The lambda-based modifier allows the state to be read during placement instead of composition.

The result is that Compose can update the position without necessarily recomposing the composable.

The important principle is simple: Read state in the latest phase that makes sense for your use case.

How to Avoid Unnecessary Recomposition

Consider an animated alpha value.

A straightforward implementation might look like this:

val alpha by animateFloatAsState(
targetValue = targetAlpha,
label = "Alpha"
)
Box(
modifier = Modifier.alpha(alpha)
)

This reads the animated value during composition.

For frequently changing animation values, you can defer the read using graphicsLayer:

val alpha by animateFloatAsState(
targetValue = targetAlpha,
label = "Alpha"
)
Box(
modifier = Modifier.graphicsLayer {
this.alpha = alpha
}
)

Now the alpha value is read inside the graphics layer lambda.

This allows Compose to update the drawing properties without triggering unnecessary composition and layout work for every animation frame.

This technique is particularly useful for:

  • Alpha animations.
  • Scale transformations.
  • Rotation.
  • Translation.
  • Visual effects.

It doesn’t mean every state read should be moved out of composition. Text changes, conditional UI, and structural changes still belong in composition.

The goal is to avoid doing more work than necessary.

What Causes UI Jank?

Android generally needs to produce frames within the display’s refresh interval.

For example:

  • Refresh rate is 60 Hz and frame budget is 16.67 ms
  • Refresh rate is 90 Hz and frame budget is 11.11 ms
  • Refresh rate is 120 Hz and frame budget is 8.33 ms

If the rendering pipeline takes longer than the available frame budget, the application can miss a frame.

This is what users experience as jank.

Common causes include:

  • Expensive composable calculations.
  • Excessive recomposition.
  • Deep or unnecessarily complex layout hierarchies.
  • Expensive custom drawing.
  • Large image decoding operations.
  • Heavy work on the main thread.
  • GPU-intensive effects.
  • Poorly optimized scrolling lists.

A screen can look perfectly fine during development but perform poorly on lower-end devices.

That’s why rendering performance should be measured rather than judged by appearance alone.

How to Debug Rendering Performance

Android provides several tools to investigate rendering problems.

1. Layout Inspector

Use Android Studio’s Layout Inspector to inspect your Compose UI hierarchy and understand how composables are structured.

2. Compose Compiler Reports

These reports help identify composables that are restartable, skippable, or have stability-related issues.

They can help you understand why certain composables are being recomposed.

3. Android Studio Profiler

Use the CPU Profiler to investigate expensive operations and identify unnecessary work.

4. Perfetto

Perfetto provides detailed system traces that help investigate frame timing, scheduling, rendering, and performance bottlenecks.

5. Macrobenchmark

Use Macrobenchmark to measure real application interactions, such as scrolling, navigation, and app startup.

These tools help identify whether the bottleneck comes from composition, layout, drawing, or the broader Android rendering pipeline.

Final Thoughts

Jetpack Compose makes UI development much simpler, but understanding its rendering pipeline helps us build better applications.

A state change is only the beginning.

Compose determines what needs to change, calculates the layout, and produces drawing operations. Android’s graphics system then processes those operations and combines the resulting surfaces for display.

As Android developers, we don’t need to understand every internal implementation detail to build good applications. However, knowing how these stages work makes it much easier to diagnose performance issues.

The next time your Compose screen drops frames, don’t immediately blame recomposition.

Look at the entire rendering pipeline.

--

--

Senior Software Engineer | Android, Kotlin & Mobile System Design | Sharing practical insights on architecture, performance, and building reliable mobile apps.

See more from me