Skip to content

Android Rendering

Published: at 11:10 AM
Modified: (10 min read)

Android View Rendering

We have following sequence for rendering

requestLayout()
      ↓measure pass
      ↓layout pass
      ↓draw pass

which can seen as

ViewRootImpl.performTraversals()
    ↓performMeasure()
    ↓performLayout()
    ↓performDraw()

Where it can be related to

Measure = How big am I?
Layout = Where am I?
Draw = What do I look like?

requestLayout

As per documentation

Call this when something has changed which has invalidated the layout of this view. This will schedule a layout pass of the view tree. This should not be called while the view hierarchy is currently in a layout pass (isInLayout(). If layout is happening, the request may be honored at the end of the current layout pass (and then layout will run again) or after the current frame is drawn and the next layout occurs. Subclasses which override this method should call the superclass method to handle ==possible request-during-layout errors== correctly.

RequestLayout is called when

What it communicates is that my size or position may have changed, please re-measure and re-layout me. So android schedules measure, layout, draw again

invalidate

We call this when we want the view to be redrawn, why we want view to be redrawn? It could be because of

From docs

Invalidate the whole view. If the view is visible, onDraw(Canvas) will be called at some point in the future. This must be called from a UI thread. To call from a non-UI thread, call postInvalidate().

==When we call inValidate function, Android OS checks what has changed, size? position? pixel?== and hence call apt functions to update view

==In case of==

onLayout

decides where children go

When phone switches from portrait to landscape, or from landscape to portrait then onLayout is called.

When in linear layout, lets say 1st child is textView and its size increased then parent calls onLayout to re-compute child placement

So when size changes, or position changes call requestLayout. But if only appearance changes call only inValidate

param changed: Boolean,

it tells Did my bounds change since last layout?

requestLayout()onLayout()
A request for a new layout passThe actual layout callback
Can be called by any ViewCalled by Android framework
Schedules measure + layout + drawPlaces children inside a ViewGroup
Happens before layoutHappens during layout
User/framework initiatedFramework initiated
requestLayout()
    = "Please perform layout again."
onLayout()
    = "I'm currently performing the layout."```

# onMeasure

```kotlin
override fun onMeasure(
    widthMeasureSpec: Int,    heightMeasureSpec: Int) {}

it is essentially asking:

Parent → ChildHow big do you want to be,given these constraints?

The parent passes those constraints through MeasureSpec.

val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)

Mode

Android Compose Rendering

Compose has 3 independent phases

1. Composition (build UI tree)
2. Layout
   ├─ Measure   └─ Place3. Draw

Unlike view system, compose can invalidate these phases independently.

Logging the various phases

In correct way

fun Modifier.debugRendering(
    tag: String): Modifier {

    return this        .layout { measurable, constraints ->
            Log.d(tag, "Measure")
            val placeable =                measurable.measure(constraints)
            layout(                placeable.width,                placeable.height            ) {
                Log.d(tag, "Layout")
                placeable.place(0, 0)            }        }        .drawWithContent {
            Log.d(tag, "Draw")
            drawContent()        }}

Canvas(
    modifier = Modifier        .size(200.dp)        .debugRendering(            "CircularProgressCompose"        )) { .... }

Why this will give wrong logs:

The implementation has two chained modifier nodes:

Because they live at different positions in the modifier chain, they don’t atomically represent the same composable’s render pass. In a draw-only invalidation, Compose walks the chain and the draw phase passes through the outer layout node (it’s transparent to draw) and hits drawWithContent separately. The result: they log from different “perspectives” of the chain, not the same one.

The correct way

// Two chained modifiers (.layout{} then .drawWithContent{}) are TWO separate nodes at different
// positions in the modifier chain. Measure/Layout are intercepted outer, Draw is intercepted inner —
// they don't track the same composable's render pass atomically.
//
// Fix: a single Modifier.Node implementing both LayoutModifierNode + DrawModifierNode so all
// three phases are intercepted at the exact same position in the chain.

fun Modifier.debugRendering(tag: String): Modifier = this.then(DebugRenderingElement(tag))

private data class DebugRenderingElement(val tag: String) : ModifierNodeElement<DebugRenderingNode>() {
    override fun create() = DebugRenderingNode(tag)    override fun update(node: DebugRenderingNode) {        node.tag = tag    }}

private class DebugRenderingNode(var tag: String) :
    LayoutModifierNode, DrawModifierNode, Modifier.Node() {
    override fun MeasureScope.measure(        measurable: Measurable,        constraints: Constraints    ): MeasureResult {        Log.d(tag, "Measure")        val placeable = measurable.measure(constraints)        return layout(placeable.width, placeable.height) {            Log.d(tag, "Layout")            placeable.place(0, 0)        }    }
    override fun ContentDrawScope.draw() {        Log.d(tag, "Draw")        drawContent()    }}

Why the fix is correct:

DebugRenderingNode implements both LayoutModifierNode and DrawModifierNode as a single node at one position in the chain. All three phases (Measure → Layout → Draw) are now
intercepted at the exact same chain position, giving accurate, consistent logs that correctly reflect when Compose skips measure/layout (draw-only updates) vs. runs the full pipeline.

UI Examples

In upcoming sections we would be creating same animations and views in both XML-views and compose.

CircularProgressBar

See code for CircularProgressBar implementation in repo here, read comments on overriden render functions

Image

XML views

On progress change, inValidate was called , and only onDraw was called again because no size change and no layout arrangement was changed and hence android view framework never called them.

Compose

on progress change, recomposition happened again, and only draw phases happened again.

Here XML and compose is same. Simple example

Are both equivalently smarter?

The circular progress bar case is a flat, fixed-size, single-view scenario. Both frameworks are equally smart there because there’s no layout complexity to expose the difference.

Compose wins in three specific scenarios:

1. Multiple measure passes in nested layouts

In XML, many ViewGroup types call onMeasure twice — once with AT_MOST to probe size, then again with EXACTLY once the parent knows its own size. Nesting amplifies this exponentially:

LinearLayout (wraps)
└── RelativeLayout (wraps) → 2 passes at this level
└── LinearLayout (wraps) → ==2×2 = 4 passes at this level==
└── your View → ==2×2×2 = 8 measure calls==

Compose guarantees single-pass measurement at every level — no exceptions. The FlowChipLayout you just built with Compose’s Layout {} will always measure each chip exactly once regardless of nesting depth.

2. requestLayout() propagates up to root

==This is the biggest difference and your chip layout example actually demonstrates it perfectly:==

XML path when you toggle maxChipsPerRow:
FlowChipLayout.requestLayout()
→ invalidates FlowChipLayout
→ invalidates LinearLayout parent
→ invalidates ScrollView
→ invalidates the Fragment’s root
→ all the way up to DecorView (window root)
→ entire tree re-measures from the top down

Compose path when maxChipsPerRow state changes:
Only FlowChipLayout recomposes
→ measures its own children (chips)
→ reports new height to its direct parent (Column item)
→ only the Column’s slot for this item re-layouts
→ scroll container adjusts scroll bounds only

The XML system can’t know which parts of the tree are unaffected by the child’s change, so it must re-examine everything from the root. Compose tracks the dependency graph — it knows exactly which parent cares about FlowChipLayout’s new size.

3. Scoped recomposition vs. full subtree invalidation

==In XML: if a parent calls invalidate(), every child redraws. If a parent calls requestLayout(), every child re-measures.==

In Compose: state change recomposes only the composables that read that state. Siblings are untouched.

// Only the Text that reads `count` recomposes —
// the other Text and Button are completely skipped
var count by remember { mutableStateOf(0) }
Column {
  Text("Static label")   // ← never recomposes when count changes  Text("Count: $count")  // ← recomposes only this  Button(onClick = { count++ }) { Text("Tap") }}

The equivalent XML has no mechanism to skip sibling redraws when one child’s data changes — notifyDataSetChanged() or requestLayout() blasts everything.

Flowable Chips

See code here, put logs

Image

XML views Implementation

Few observations, and reasoning for it

Compose Implementation

On toggle of max col change we get

Recomposition maxChipsPerRow=2
Measure
Layout
Draw

Why all 3 phases are unavoidable here

When maxChipsPerRow toggles, two things fundamentally change:

  1. Chip positions change — each chip moves to a different x,y coordinate
  2. Total height changes — fewer rows → smaller height, more rows → taller height

Because the size of FlowChipLayout itself changes, Compose cannot skip any phase:
maxChipsPerRow state changes
→ recomposition new value flows into Layout {}
→ Measure new totalHeight calculated (different from before)
→ Layout/placement each chip placed at new coordinates
→ Draw new positions must be painted

Compose can only skip phases when the reported size stays the same. Since height changes here, the parent composable (Column) must also be informed of the new size — which means all three phases run.

Shared displayList? 🤔

There is a StaticDebugLabel its’ draw phase also get executed on column config change, which tells that its a shared displayList.

By default, composables do not have their own isolated draw layer. They share the parent’s draw layer — in this case, the Column’s display list.

When FlowChipLayout changes size and redraws, Compose must reconstruct the parent layer’s display list. Every draw instruction in that display list re-executes — including StaticDebugLabel's draw — even though its pixels haven’t changed.

Think of the display list as a single recorded script for the whole layer:
[draw StaticDebugLabel text]
[draw FlowChipLayout chips] ← changed
[draw Button]

When anything in the script changes, Compose re-records the entire script. StaticDebugLabel’s draw instruction re-runs as part of that.


How to truly isolate draw

Wrapping StaticDebugLabel in Modifier.graphicsLayer {} gives it its own hardware-backed texture. Compose can then composite that cached texture without re-executing its draw
commands. Draw would be genuinely skipped.

Without graphicsLayer, the composable’s draw is only skipped when nothing in its entire shared layer changed — which never happens when a sibling is actively toggling layout.


The important take-away

Compose’s “smart” optimisation is primarily about avoiding recomposition (re-running Kotlin code) and remeasure. Drawing at the display list level is still layer-scoped, not
per-composable. This is why graphicsLayer exists — it’s the explicit opt-in for truly isolated draw caching.

Audio Visualiser

See code here for it

Image

Views implementation

we have in XML as following

ScrollView -> AudioVisualizerView

We get 2 onMeasure classes with following logs

onMeasure modeW=EXACTLY modeH=EXACTLY
onMeasure modeW=EXACTLY modeH=EXACTLY

Certainly its a view 2 layout pass thing

doTraversal()
    └── performTraversals()
          ├── measureHierarchy()          ← negotiate window size
          │     └── performMeasure()      ← your onMeasure #1

          ├── [WindowManager confirms size]

          └── performMeasure()            ← your onMeasure #2 (final)
                └── performLayout()
                      └── performDraw()

This is Android’s irreducible baseline. It lives in ViewRootImpl — above every ViewGroup, above ContentFrameLayout, above everything you can touch. You cannot eliminate these 2 calls. Every View in every Android app gets onMeasure called at least twice on startup because of this two-step window negotiation.

Compose receives the same two ViewRootImpl passes via ComposeView::onMeasure, but absorbs them without re-running your Layout {} blocks — because Compose tracks whether inputs to each node actually changed between passes.

Double Taxation problem

ViewGroupWhyPasses
RelativeLayoutSeparate horizontal + vertical constraint solving passes×2
LinearLayout with layout_weightPre-weight UNSPECIFIED pass + allocation EXACTLY pass×2
FrameLayout with wrap_contentSecond pass for match_parent children once own size is known×2
ConstraintLayout with chainsWRAP_CONTENT chains + spread/spread_inside triggers second pass to distribute remaining space×2

Compose implementation

Recomposition
Measure
Layout
Draw