This article is the second in the Compose series. In my first article, I’ve explained the benefits of Compose, the problems it addresses, the reasons behind some of the design decisions, and how they can help developers. I also discussed the Compose mental model, how you should think about writing code with Compose, and how to create your own API. In this article, I’ll look at the mechanism behind Compose. But before I start, I want to emphasize that using Compose doesn’t necessarily require you to understand how it’s implemented. What follows is written purely to satisfy your intellectual curiosity.
What does @Composable annotation mean?
If you already know about Compose, you’ve probably seen the @Composable annotation in some code examples. An important thing to note here — Compose is not an annotation handler. Compose relies on the Kotlin compiler plug-in during the Kotlin compiler’s type detection and code generation phases, so you can use it without annotating the processor.
This annotation is closer to a language keyword. As an analogy, consider Kotlin’s suspend keyword:
// Declare the function
suspend fun MyFun(a){... }/ / lambda statement
val myLambda = suspend{... }// Function type
fun MyFun(myParam: suspend() - >Unit){... }Copy the code
Kotlin’s suspend keyword applies to processing function types: You can declare a function, a lambda, or a function type suspend. Compose works the same way: it can change function types.
// Declare the function
@Composable fun MyFun(a){... }/ / lambda statement
val myLambda = @Composable{... }// Function type
fun MyFun(myParam: @Composable() - >Unit){... }Copy the code
The point here is that when you annotate a function type with @Composable, it causes its type to change: the same function type that is not annotated is incompatible with the annotated type. Similarly, suspend functions take the call context as an argument, which means that you can only call suspend functions in other suspend functions:
fun Example(a: () -> Unit, b: suspend() - >Unit) {
a() / / allow
b() / / not allowed
}
suspend
fun Example(a: () -> Unit, b: suspend() - >Unit) {
a() / / allow
b() / / allow
}
Copy the code
Composable works the same way. This is because we need a call object across all contexts.
fun Example(a: () -> Unit, b: @Composable() - >Unit) {
a() / / allow
b() / / not allowed
}
@Composable
fun Example(a: () -> Unit, b: @Composable() - >Unit) {
a() / / allow
b() / / allow
}
Copy the code
Execution mode
So, what exactly is the invocation context we are passing? And why do we need to pass it on?
We call this “Composer.” The Composer implementation includes a data structure closely related to the Gap Buffer, which is commonly used in text editors.
A gap buffer is a collection containing the current index or cursor. It is implemented in memory using a flat array. The flat array is larger than the data set it represents, and the unused Spaces are called gaps.
A Composable hierarchy that is executing can use this data structure, and we can insert something into it.
Let’s assume that the execution of the hierarchy is complete. At some point, we rearrange things. So we reset the cursor back to the top of the array and iterate through the execution again. When we execute, we can choose to just look at the data and do nothing, or update the value of the data.
We might decide to change the structure of the UI and want to do an insert. At this point, we will move the gap to the current position.
Now we are ready to insert.
In understanding this data structure, it is important to note that except for the move gap, all its operations — get, move, INSERT, delete — are constant time operations. The time complexity of moving clearance is O(n). We chose this data structure because UI structures don’t usually change very often. When we work with dynamic UIs, their values change, but the structure usually doesn’t change very often. When they do need to change their structure, they are likely to need to make large changes, and an O(n) gap shift is a reasonable tradeoff.
Let’s look at an example counter:
@Composable
fun Counter(a) {
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1})}Copy the code
This is the code we wrote, but we’re going to look at what the compiler does.
When the compiler sees the Composable annotation, it inserts additional arguments and calls into the body of the function.
First, the compiler adds a call to the composer. Start method and passes it an integer key generated at compile time.
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
$composer.end()
}
Copy the code
The compiler also passes the Composer object to all composable calls in the body of the 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()
}
Copy the code
When this Composer is executed, it does the following:
- Start is called and a group object is stored.
- Remember inserts a group object
- The value of mutableStateOf is returned, and the state instance is stored
- Button stores a group based on each of its parameters
Finally, when we reach Composer. End:
The data structure now holds all the objects from the composite, and the nodes of the entire tree are arranged in the execution order of the depth-first walk.
Now, all these group objects already take up a lot of space, why do they take up space? These group objects are used to manage possible movements and inserts in the dynamic UI. The compiler knows which code changes the structure of the UI, so it can conditionally insert these groups. In most cases, the compiler does not need them, so it does not insert too many groups into slot tables. To illustrate this point, look at the following conditional logic:
@Composable fun App(a) {
val result = getData()
if (result == null) {
Loading(...)
} else {
Header(result)
Body(result)
}
}
Copy the code
Within the Composable function, the getData function returns some results and in one case draws a Loading Composable function; In the other case, it draws the Header and Body functions. The compiler inserts a separator keyword between each branch of the if statement.
fun App($composer: Composer) {
val result = getData()
if (result == null) {
$composer.start(123)
Loading(...)
$composer.end()
} else {
$composer.start(456)
Header(result)
Body(result)
$composer.end()
}
}
Copy the code
Let’s assume that the first execution of this code results in null. This inserts a group into the gap and runs the load interface.
The second time the function executes, let’s assume that its result is no longer null, so the second branch executes. This is where it gets interesting.
Calls to Composer. Start have a group with key 456. The compiler will see in the slot table that the key is 123 and the group doesn’t match, so it will know that the structure of the UI has changed.
The compiler then moves the gap to the current cursor position and makes it expand where the previous UI was, effectively eliminating the old UI.
At this point, the code will execute as normal, and the new UI — header and body — will be inserted.
In this case, the overhead of the if statement is a single entry in the slot table. By inserting a single group, we can implement the flow of control arbitrarily in the UI, while enabling the compiler to manage the UI so that it can take advantage of this class-cached data structure when working with the UI.
This is a concept that we call Positional Memoization, and one that has run through Compose since its inception.
Positional Memoization
In general, when we say global memorization, we mean that the compiler caches the result based on the input of the function. Here’s an example of a function performing a computation for location memorization:
@Composable
fun App(items: List<String>, query: String) {
val results = items.filter { it.matches(query) }
// ...
}
Copy the code
This function takes a list of strings and a string to look for, and then filters the list. We can wrap this calculation in a call to the remember function — the remember function knows how to take advantage of the slot list. The remember function looks at the string in the list, but it also stores the list and queries it in the slot table. The filter calculation is run later, and the remember function stores the results before they are passed back.
The second time the function executes, the remember function looks at the new value passed in and compares it to the old one, and if none of the values have changed, the filtering operation returns the previous result while skipping. This is location memorization.
Interestingly, the overhead of this operation is very low: the compiler must store a previous call. This calculation can take place anywhere in your UI, and since you’re storing it based on location, it will only be stored for that location.
Here’s the function signature for Remember, which can take as many inputs as you want with a Calculation function.
@Composable
fun <T> remember(vararg inputs: Any? , calculation: () ->T): T
Copy the code
However, there is an interesting degenerate situation when there is no input. We can intentionally misuse this API by memorizing a calculation like Math.random that does not produce stable results:
@Composable fun App(a) {
val x = remember { Math.random() }
// ...
}
Copy the code
Using global memorization to do this would make no sense, but using positional memorization instead would eventually take on a new semantics. Every time we use the App function in the Composable hierarchy, we will return a new math.random value. However, each time the Composable is reassembled, it will return the same math.random value. This feature makes persistence possible, which in turn makes state possible.
Storage parameters
Next, let’s use the Google Composable function to show how the Composable stores the parameters of the function. This function takes a number as a parameter and draws the Address by calling the Address Composable function.
@Composable fun Google(number: Int) {
Address(
number=number,
street="Amphitheatre Pkwy",
city="Mountain View",
state="CA"
zip="94043")}@Composable fun Address(
number: Int,
street: String,
city: String,
state: String,
zip: String
) {
Text("$number $street")
Text(city)
Text(",")
Text(state)
Text("")
Text(zip)
}
Copy the code
Compose stores the parameters of the Composable function in the slot table. In this case, we can see some redundancy: the “Mountain View” and “CA” added to the Address call are stored again in the following text call, so the strings are stored twice.
You can eliminate this redundancy by adding static parameters to the Composable function at the compiler level.
fun Google(
$composer: Composer,
$static: Int,
number: Int
) {
Address(
$composer,
0b11110 or ($static and 0b1),
number=number,
street="Amphitheatre Pkwy",
city="Mountain View",
state="CA"
zip="94043")}Copy the code
In this case, the static parameter is a bit field that indicates whether the runtime knows that the parameter will not change. If you know that a parameter will not change, you do not need to store it. So in this example of a Google function, the compiler passes a bit field to indicate that none of the parameters will change.
Next, in the Address function, the compiler can do the same and pass arguments to text.
fun Address(
$composer: Composer,
$static: Int,
number: Int, street: String,
city: String, state: String, zip: String
) {
Text($composer, ($static and 0b11) and (($static and 0b10) shr 1), "$number $street")
Text($composer, ($static and 0b100) shr 2, city)
Text($composer, 0b1.",")
Text($composer, ($static and 0b1000) shr 3, state)
Text($composer, 0b1."")
Text($composer, ($static and 0b10000) shr 4, zip)
}
Copy the code
This bit-operation logic is hard to read and confusing, but we don’t need to understand it: compilers are good at it, but humans aren’t.
In the example of the Google function, we see that there is not only redundancy, but also some constants. Turns out, we don’t need to store them either. In this way, the number parameter determines the entire hierarchy and is the only value that needs to be stored by the compiler.
Depending on this, we can go a step further and generate code that understands that number is the only value that changes. The following code can skip the entire body of the function if the number has not changed, and we can instruct Composer to move the current index to where the function has already been executed.
fun Google(
$composer: Composer,
number: Int
) {
if (number == $composer.next()) {
Address(
$composer,
number=number,
street="Amphitheatre Pkwy",
city="Mountain View",
state="CA"
zip="94043"
)
} else {
$composer.skip()
}
}
Copy the code
Composer knows how far to fast-forward to where you need to recover.
restructuring
To explain how reorganization works, we need to go back to the counter example:
fun Counter($composer: Composer) {
$composer.start(123)
var count = remember($composer) { mutableStateOf(0) }
Button(
$composer,
text="Count: ${count.value}",
onPress={ count.value += 1 },
)
$composer.end()
}
Copy the code
The code the compiler generates for the Counter function consists of one composer. Start and one compose. End. Every time Counter executes, the runtime understands that when it calls count.value, it reads the properties of an AppModel instance. At run time, whenever we call compose. End, we have the option to return a value.
$composer.end()? .updateScope { nextComposer -> Counter(nextComposer) }Copy the code
Next, we can use a lambda on the return value to call the updateScope method, which tells the runtime how to restart the current Composable if necessary. This method is equivalent to the lambda parameter that LiveData receives. The reason for using the question mark here — the reason it’s nullable — is that if we don’t read any model objects during the execution of Counter, there’s no reason to tell the runtime how to update it, because we know it never will.
The last
The important thing to remember is that most of these details are just implementation details. The Composable function has a different behavior and functionality than the standard Kotlin function. Sometimes it is useful to understand the implementation, but in the future the behavior and functionality of the Composable function will not change, and the implementation may change.
Similarly, the Compose compiler can produce more efficient code in some situations. We also look forward to optimizing these improvements over time.