Understanding Jetpack Compose: Internal Implementation and Working

Sagar MalhotraSagar Malhotra
8 min read

This article is for you, if you are desperate to see what is going on inside the functions(Composables) you are using to show your app's UI, update the UI states, show animations, and even more. You can use the knowledge in this article to understand compose in a more familiar and better way and create a more performant UI.

We will understand the working of a Jetpack compose function in three levels of technical depth.

Note: The internal implementations might change and improve in the future for optimizing the performance, but understanding them now will give you more thought clarity in your user journey.

Level 1:

As a developer, you understand the workings of a normal function. It is first called, then it starts the execution, reads instructions line by line, and ends, right?

Also, this Jetpack compose is just a function, so it works the same…

But, with some extra functionality, functionality that repeats itself (like we are using recursion), functionality that automatically updates the UI when just one variable is updated.

So, how does it work? I already answered this… we use Recursion.

In XML, we had to manually trigger some event/function and update every view in the UI with the updated values, but in Compose it is simple…

  1. First, when the app starts, we call the composable function(initial Composition), and then your UI is shown with the initial data/values you are passing.

  2. If you are using any compose state(mutableState), and with any event(Button click or similar) you are updating that state, then that will trigger the composable function again(Recursion- here we call recomposition) and update your UI.

@Composable
fun Greeting() {
  var currentName by remember { mutableStateOf("") }//Composable State
  Column {
    Text(text = "Hello")
    Button(onClick = { currentName = "Sagar" }) {//OnClick will change the currentName state and trigger recomposition(recursion)
      Text(text = "Change")
    }
  }
}

Here is the complete flow:
You start the app → You call the Compose function → It shows the initial UI with data → You trigger some event → Event updates the Compose state/data → Recursion happens → Updated UI is displayed → Leaves the composition when the app is closed.

Level 2:

At this level, we’ll understand the Phases of a Jetpack compose function, how it goes through different states/phases, and finally draw any content in your UI.

Understanding this properly can also help you optimize your performance by reducing unwanted recompositions.

1. Composition:

It determines “What to Show” by managing all the composables in a tree structure.

2. Layout:

It determines “Where to show” and takes all the composable from the tree to layouts them on the screen, according to their expected position.

3. Draw:

Here, it simply draws the content in all the layouts on one screen.

Here comes the interesting part, the main time-consuming phase is the Composition phase, as it will go through our whole composable tree and re-structure them every time.

So, we can optimize our performance by skipping the composition phase when nothing is changed in our tree structure but only the Layout has changed, or just the content we are drawing is changed.

You should not have to recompose, just to re-layout the screen.

One optimization tip will be to use Modifiers efficiently, as they are immutable, when anything is changed in them, they will be added to the composition tree every time and hence trigger re-composition. (Use lambda Modifiers to pass any changeable values wherever possible)

Better understand with the video example.

Level 3:

Let’s again start with the basics,

A composable is a function, but what makes it different from other functions? The @Composable annotation!!

Note that, Compose is not an annotation processor, it just uses this Composable annotation to differentiate the compose functions from normal ones(Also to generate relevant code at the compilation phase).

It closely resembles the “suspend” keyword.

// function declaration
suspend fun MyFun() { … }

// lambda declaration
val myLambda = suspend { … }

// function type
fun MyFun(myParam: suspend () -> Unit) { … }
// function declaration
@Composable fun MyFun() { … }

// lambda declaration
val myLambda = @Composable { … }

// function type
fun MyFun(myParam: @Composable () -> Unit) { … }

Normal functions accept neither “suspend” nor “Compose” functions.

This is because there is a calling context object that we need to thread through all of the compose invocations.

So, what is this calling context that is present in all composables we’re passing around and why do we need to do it?

We call this object the “Composer”.

Let’s see with a compose code example:

@Composable
fun Counter() {
 var count by remember { mutableStateOf(0) }
 Button(
   text="Count: $count",
   onPress={ count += 1 }
 )
}

Because of this Composable annotation, the compiler inserts additional parameters and calls into the body of this function.

fun Counter($composer: Composer) {
 $composer.start(123)
 var count by remember($composer) { mutableStateOf(0) }
 Button(
   $composer,
   text="Count: $count",
   onPress={ count += 1 },
 )
 $composer.end()
}

First, the compiler adds a call to the composer.start method and passes it a compile time generated key integer. It also passes the composer object into all of the composable invocations in the body of this function.

The implementation of the Composer contains a flat array in memory, closely related to a Gap Buffer.

Once the composition begins, it will start inserting all required things(compose function and states) into this array.

When we have to perform recomposition, we will always start from the beginning of the array (i.e. reset the current index) and either update the item or skip it according to the states changed.

If in any case, your compiler finds that the structure of the UI has changed (you might be showing some extra conditional UI/Composables), at this point we move the gap to the current position.

And now, add the new items in the array…

As you might have also observed, moving the gap is a heavy operation, i.e. O(n) and all other operations — get, move, insert, and delete — are constant time operations.

The reason we chose this data structure is because we’re making a bet that, on average, UIs don’t change structure very much. When we have dynamic UIs, they change in terms of the values but they don’t change in structure nearly as often. When they do change their structure, they typically change in big chunks, so doing this O(n) gap move is a reasonable trade-off.

Now, let’s see this array thing with our previous Counter example:

fun Counter($composer: Composer) {
 $composer.start(123)
 var count by remember($composer) { mutableStateOf(0) }
 Button(
   $composer,
   text="Count: $count",
   onPress={ count += 1 },
 )
 $composer.end()
}

When this composer executes it does the following:

  • Composer.start gets called and stores a group object

  • remember inserts a group object(it is required to skip resetting the values)

  • the value that mutableStateOf returns, the state instance is stored(as it triggers the recomposition).

  • Button stores a group, followed by each of its parameters(all the nested things(in a Depth-first traversal) are also inserted in the array).

  • and finally composer.end

The 123, 456, or 789 are the compiler-generated keys used to define/identify the structure of the UI. If the call to composer.start has a group with the key 456, but in the array, it was previously stored with 123, then the compiler knows that the UI has changed its structure.

Next, how does this remember work? Let’s take an example:

@Composable
fun App(items: List<String>, query: String) {
 val results = items.filter { it.matches(query) }
 // ...
}

The function takes in a list of string items and a query and then performs a filter computation on the list. We can wrap this computation in a call to remember: remember is something that knows how to appeal to the array. remember looks at items and stores the list and query in the array/slot table. The filter computation then runs and remember stores the result.

The second time the function executes, remember looks at the new values being passed in and compare them with the old values. If neither of them has changed, then the filter operation is skipped and the previous result is returned, this is something called Positional memorization. and this is the concept that Compose is built around.

This is the signature of the remember function, it can take any number of inputs and then a calculation function.

@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T

Now, let’s get clarity with a different example:

@Composable fun App() {
 val x = remember { Math.random() }
 // ...
}

Here, what will happen according to our understanding?
remember will simply store the initial randomly generated value and never run the Math.random function in any future recompositions.

Recomposition:

I already told you recomposition works with Recusrion, but where is the code for it? We are not writing any recursion logic, so it should be generated based on some conditions and not infinitely repeat the function.

Yes, here is the generated logic…At runtime, whenever we call compose.end, we optionally return a value with the updated composer. This is equivalent to the lambda that LiveData.observe would otherwise receive.

$composer.end()?.updateScope { nextComposer ->
 Counter(nextComposer)
}

If there are no model objects or mutableState used in the composable then we will never recompose.


This was all you needed to get a better clarity on the workings of Jetpack compose. There are a lot of things that internal working and implementation contain but it is not relevant and possible to explain all the things here, or maybe I don’t know all of them :], and that is completely fine.

This article was to make you more familiar with Jetpack compose so that you can write better Functions with minimum recompositions.

Here are the referenced articles and videos.

Make sure to clap clap clap and hit the follow button to get the latest updates from me. Happy composing.

10
Subscribe to my newsletter

Read articles from Sagar Malhotra directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sagar Malhotra
Sagar Malhotra