Decomposing Jetpack Compose

Baiqin Wang on 2024-12-22

Decomposing Jetpack Compose

Jetpack Compose is Android’s recommended modern toolkit for building native UI. It simplifies and accelerates UI development on Android. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs.

The Compose framework has significantly simplified UI development by abstracting away much of the state management, allowing developers to write apps in a declarative fashion. However, this simplicity is a double-edged sword. In traditional View-based UI development, developers needed to write code to manage app states, which naturally required a deeper understanding of the View framework. With Compose, much of the heavy lifting is handled by the framework itself. This means developers can create visually appealing UIs without needing to understand the underlying machinery.

While this abstraction is a boon for productivity, I often find it counterproductive when issues arise. Debugging Compose code can be more challenging compared to the traditional View-based framework because of the higher level of abstraction it provides. This has been a recurring pain point for me, motivating my efforts over the past two months to build a tool to visualize the internals of a Compose app. My hope is that this tool will make debugging Compose issues significantly easier. At the time of writing, the tool is functional and aligned with my original vision. You can find it hosted here: Decomposer[1]. In this article, I’ll walk through the various components of the tool and touch on some related Compose topics along the way.

[1] https://github.com/composexy/decomposer

The pain points

  1. The Compose framework relies on a Kotlin compiler plugin to rewrite and add metadata to the Kotlin codebase in your project. Most of the plugin’s work occurs at the IR (Intermediate Representation) stage of the Kotlin compilation pipeline, a transient data structure in the compiler’s backend. This plugin is far from trivial, and the result is that the semantics of the IR tree deviate significantly from the original Kotlin source code. For debugging purposes, it would be incredibly helpful to view the IR tree in a Kotlin-like fashion.
  2. The Compose runtime uses an internal data structure called the SlotTable to store composition data. Although the SlotTable holds a tree structure, its payload is flattened into two arrays to reduce runtime memory allocations. In the old View-based framework, developers could attach a debugger to investigate the View tree structure. However, with Compose, debugging the UI tree has become much more challenging due to its reliance on internal data structures like the SlotTable. A tool that provides a visual representation of the composition tree within a user-friendly UI would be invaluable.

The above screen capture shows several visualized IR trees in the Jetsnack app. The tool converts the IR tree into a roughly readable Kotlin-like format. While I won’t delve deeply into the workings of the Compose compiler in this blog, let’s briefly explore what the Compose compiler plugin does, as demonstrated in the screen capture.

In the Grid.kt file, you’ll notice a @ComposableInferredTarget annotation added by the Compose plugin. In Compose, there are two composable targets: UiComposable and VectorComposable.

The Compose plugin modifies the value parameters of functions like VerticalGrid:

  1. Default Value Handling The columns parameter initially had a default value in the original Kotlin code, but the plugin removes it during the IR stage. In Compose, the semantics of default values differ from standard Kotlin. The plugin rewrites the handling of default values directly into the body of the composable function.
  2. Lambda Transformation The content parameter, originally a lambda annotated with @Composable, is transformed into a higher-order lambda. The modified lambda takes two additional parameters:Composer instance: Passed in to manage the composition.changed flag: Indicates the state of the arguments to enable skipping execution when applicable.
  3. Additional Parameters Three additional parameters are added to the function value parameters:
  1. Group Creation The first line calls the startRestartGroup function with an integer function key. The Compose runtime uses these startXX functions (from the Composer interface) to insert groups into the SlotTable. Specifically, startRestartGroup creates a group that can recompose automatically, making VerticalGrid a recomposable scope.
  2. Source Metadata The next line adds source information for the VerticalGrid function into the SlotTable. This metadata enables tools like the Decomposer to link back to the IR viewer. For example, clicking on certain nodes in the composition viewer navigates back to the corresponding IR representation.
  3. Control Flow Preamble A block of code follows that uses $dirty and $changed flags for bitwise operations. This preamble is added by the Compose compiler to skip executing the function body when possible. These operations depend on several factors:
  1. @StabilityInferred Annotation This annotation is added by the Compose compiler to mark inferred stability.
  2. $stable Fields These fields are generated to represent the stability of classes, helping the Compose runtime optimize recompositions. Similar to the control flow logic in function bodies, these stability features rely on bitwise operations.
sample app composition tree

The screen captures above illustrate the composition tree while running the “Simple State Reader” sample in the decomposer sample app. The composition tree consists of two main components:

  1. Groups Groups define the parent-child relationships within the tree structure. Each group is represented with a group icon (a caret surrounded by a circle) as a prefix. You can expand or collapse the subgroups by clicking on this icon.
  2. Data Slots Data slots are attached to groups and hold additional information relevant to each group. These can be expanded or collapsed by clicking the single up or down caret icons within the group.

Code for “Simple State Reader”:

private var alphaFirst by mutableStateOf(0.5f)
private var alphaSecond by mutableStateOf(0.9f)
private var alphaThird by mutableStateOf(0.8f)

@Composable
fun SimpleStateReader() {
    Column {
        Box(modifier = Modifier.graphicsLayer {
            alpha = alphaFirst
        }.size(240.dp).background(Color.Yellow))
        Box(modifier = Modifier.graphicsLayer(
            alpha = alphaSecond
        ).size(240.dp).background(Color.Blue))
        val showThird: Boolean by remember {
            derivedStateOf { alphaThird > 0.5f }
        }
        if (showThird) {
            Box(modifier = Modifier.graphicsLayer {
                alpha = alphaThird
            }.size(240.dp).background(Color.Gray))
        }
    }
}

As demonstrated, even a simple composable can result in a surprisingly complex composition tree. To enhance the usability of the composition viewer, I’ve added various filters to the composition panel.

Clicking on the root application node opens a subwindow displaying all the state reads detected by the decomposer. Each state item in the list can be expanded to reveal three key rows:

  1. Value of the State This shows the current value of the state.
  2. Reader Kinds This indicates the type of state reader associated with the state.
  3. Dependencies If the state is of type DerivedState, it includes a list of dependencies used to calculate the state’s value. These are the elements upon which the state depends.

Actually in general it is not valid to look at the source code and tell which states are read because state reading and recomposition is a compose runtime concept that is not to be statically determined. You can only say at a given time, which states is this composable scope reading. When the app starts, the compose runtime sets up initial composition by invoking the composables and recording which states are read in which composable scopes. The initial composition runs on UI thread as soon as app starts. After the initial composition, the runtime schedules potential recompositions on each frame signal with the help of “Choreographer” class. If in between the frame signals, some events like user interaction causes state writes, the composable scopes that read those states will be invalidated. And until the next frame signal comes and recomposition sets up state read tracking again, these invalidated scopes don’t read any states.

However, the above code snippet just creates some states that will never change so the state reading will never change. Let’s look at the screen capture, you can see that “alphaSecond” and “alphaThird” are read in composition but “alphaOne” is not. That means the “SimpleStateReader” scope is not tracking the state “alphaOne”. When the compose runtime sets up state tracking, it calls a composable function and if the property getter of a state is triggered along the way, the composable scope is considered to read that state. If you look at the first Box, the “alphaOne” state is inside a lambda that’s passed into “graphicsLayer”, so invoking “SimpleStateReader” will never trigger the property getter, so this state is never read in “SimpleStateReader”. This lambda block version of “graphicsLayer” in compose ui package is written this way intentionally to avoid unnecessary recompositions during animation.

But if the value of “alphaOne” changes, the ui does reflect the change. If the “SimpleStateReader” composable scope doesn’t track this state, how does the ui gets updated? Well this is where the reader kind kicks in. In this code snippets, there are three core cooperative but decoupled components: The compose runtime, the compose ui and the snapshot state system. The runtime calls the composable functions to set up recompose scopes and state tracking. However, recompose scopes are compose runtime concepts, compose ui knows nothing about it. Instead, compose ui tracks state changes via a utility class called “SnapshotStateObserver” in the snapshot system. These ui related states are not relevant for compose runtime, so the reader kind of them should be only “SnapshotStateObserver” not Composition. In another word, only compose ui should read these ui related states not the compsable functions.

For more complex programs, having these state table in the composition viewer could make discovering state reading issues easier.

composition tree of sub-compositions

The composition viewer has some filters to filter out unwanted groups in the composition tree to make tree navigation easier.

The “Content group only” option trims the groups outside of the Content lambda in AbstractComposeView. The “User groups only” trims the groups that not defined by the app’s code:

// androidx.compose.ui.platform.ComposeView.android.kt

abstract class AbstractComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
    // If you select "Content group only", the groups outside of this
    // Content() will be trimmed and the Content() becomes the root in
    // the composition viewer.
    @Composable
    @UiComposable
    abstract fun Content()
}

// My app

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // If you select "User groups only", the groups outside of
            // this lambda will be trimmed, and the group corresponding
            // to this lambda becomes the root in the composition viewer.
        }
    }
}

“Hide empty groups” option will hide groups that have no child groups and attached data slots. One typical source of this kind of group is composition local reads like “MyCompositionLocal.current”.

“Hide leaf groups” option will hide groups that have no child groups but do have attached data slots. One typical source of this kind of groups is the “remember” calls.

At the bottom of the screen there are three options to show only a subtree of the composition tree.

“RecomposeScope” will only show groups that are recompose scopes. These groups are created by compose runtime via the “startRestartGroup” call and they can be invalidated individually. If you select the “RecomposeScope” filter and expand the data slot of any of the filtered group, you will notice each of such a group has a “RecomposeScopeImpl” data slot under it. These objects are created by “startRestartGroup” and they are what the compose runtime uses to look for recompose scopes and invalidate them. If a composable function is an inline function, itself will not form a recompose scope. Instead, these groups will get invalidated when their parents get invalidated. For example, Column and Row in compose are marked as inline. The compose compiler adds a “startReplaceGroup” call instead of a “startRestartGroup” for these groups. In this case, a “RecomposeScopeImpl” object won’t be created and inserted to the group’s data slots.

“ComposeNode” option will only show a subtree that consists of node groups in the composition tree.

// androidx.compose.ui.layout.Layout.kt

@Suppress("ComposableLambdaParameterPosition")
@UiComposable
@Composable
inline fun Layout(
    content: @Composable @UiComposable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    val compositeKeyHash = currentCompositeKeyHash
    val localMap = currentComposer.currentCompositionLocalMap
    val materialized = currentComposer.materialize(modifier)
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor,
        update = {
            set(measurePolicy, SetMeasurePolicy)
            set(localMap, SetResolvedCompositionLocals)
            @OptIn(ExperimentalComposeUiApi::class)
            set(compositeKeyHash, SetCompositeKeyHash)
            set(materialized, SetModifier)
        },
        content = content
    )
}

@Suppress("NONREADONLY_CALL_IN_READONLY_COMPOSABLE")
@Composable
inline fun <T : Any?, reified E : Applier<*>> ReusableComposeNode(
    noinline factory: () -> T,
    update: @DisallowComposableCalls Updater<T>.() -> Unit,
    content: @Composable () -> Unit
) {
    if (currentComposer.applier !is E) invalidApplier()
    currentComposer.startReusableNode()
    if (currentComposer.inserting) {
        currentComposer.createNode(factory)
    } else {
        currentComposer.useNode()
    }
    Updater<T>(currentComposer).update()
    content()
    currentComposer.endNode()
}

The compose runtime creates a composition tree of many groups and layers. However the upper layer components (compose ui for example) only cares about the subtree of the node groups of this composition tree. These node groups are declared by the “ReusableComposeNode” or “ComposeNode” functions and they are capable of emitting data that compose ui uses to render ui. These data are emitted through the Applier interface. In compose, there are two kinds of such data that compose ui framework uses to render ui, “LayoutNode” for raster based ui and “VNode” for vector based ui.

The “Composition” option shows you a tree of sub-compositions in the app. In a typical android app, there are multiple compositions existing at the same time. These subcompositions form a tree of compositions and that’s what the “Composition” filter option is for. These sub-compositions maintain a parent-child tree structure via a class called “CompositionContext”. In compose, there are two main source where sub-composition are created:

  1. Through “SubcomposeLayout” and it is how composables like “LazyList” and BoxWithConstraints are built upon. In the above screen capture, you can see that there are many compositions when I opened the “Simple LazyColumn” sample. Basically each row in the column is an individual composition. There are more compositions in the tree than the number of visible rows because of group reusing. Group reusing is a internal mechanism in compose runtime and extensively used by “LazyLayout”. Basically the “LazyLayout” keeps a pool of several reusable groups for faster compositions. “LazyLayout” needs to use sub-compositions because it cannot determine how many children to compose until itself is measured. Later when “LazyLayout” is measured, it will calculate the number of items to compose and use “SubcomposeLayout” to compose them.
  2. Through view interops which mean creating a new “ComposeView” and hence a new tree of compositions. Typically, it happens when you create a “Dialog” or “Popup”. In compose ui, dialog and popups are create via wrapping the implementations of corresponding classes in the traditional View framework.