Better UI building with Compose

Author:Leland Richardson

The original link

This is the second half of the Compose series, in which I explained the advantages of Compose, the problems it solves, the reasons behind our design ideas, and how they can help developers. We also discussed the Compose programming mind model, how you should think about writing Compose code, and how to design your own API.

Now let’s take a look at how Compose actually works inside. Before I begin, I want to emphasize that using Compose does not require you to understand how it is implemented. What follows will be purely for your curiosity.

What does @composable even mean

If you’ve seen Compose, you’ve probably seen the @composable annotation in many of the sample code. It is important to note that Compose is not an annotation processor. Compose works in the type checking and code generation phases of the Kotlin compilation plug-in, so it doesn’t need to use the annotation processor.

This annotation is more like a language keyword. Like the suspend keyword in Kotlin.

// function declaration
suspend fun MyFun(a){... }// lambda declaration
val myLambda = suspend{... }// function type
fun MyFun(myParam: suspend() - >Unit){... }Copy the code

Kotlin’s suspend keyword is used for function types. You can define a function, a lambda, or a type to suspend. The same goes for Compose, which can change the type of the function.

// function declaration
@Composable fun MyFun(a){... }// lambda declaration
val myLambda = @Composable{... }// function type
fun MyFun(myParam: @Composable() - >Unit){... }Copy the code

It is important to note that when you annotate a function type with @composable, you change its type. That is, the same function type without annotations and with annotations are incompatible. Similarly, a suspend function requires a calling context, meaning that the suspend function can only be called from within another suspend function.

fun Example(a: () -> Unit, b: suspend() - >Unit) {
   a() // allowed
   b() // NOT allowed
}
 
suspend 
fun Example(a: () -> Unit, b: suspend() - >Unit) {
   a() // allowed
   b() // allowed
}
Copy the code

Composable works the same way. Because the object that has a call context runs through the call.

fun Example(a: () -> Unit, b: @Composable() - >Unit) {
   a() // allowed
   b() // NOT allowed
}
 
@Composable 
fun Example(a: () -> Unit, b: @Composable() - >Unit) {
   a() // allowed
   b() // allowed
}
Copy the code

Execution model

So, what is this context that we’re passing around? And why do we need to do this?

Let’s call this object “Composer.” The Composer implementation contains a data structure very similar to the Gap Buffer. This data structure is often used in text editors.

A Gap Buffer represents a set of indexes and cursors. It’s just a flat array in memory. The flat array will be larger than the actual data represented, and the unused space is called the gap.

Now, an executing Composable hierarchy inserts data with this data structure.

Let’s assume that we are done inserting data into this hierarchy. Sometimes we need to re-compose, so we need to reset the cursor to the top of the array and iterate over the entire array again. When we are executing we can see if we need to update the value based on the data.

Maybe because the UI structure has changed and we want to insert data, we move the gap to the current position.

Now we can insert data.

It’s important to understand that all operations on this data structure — get,move,insert,delete — are constant time operations, except for moving gap, which is O(n). The reason we chose this data structure is because we think that, on average, the UI structure doesn’t change very much. Dynamic UIs are usually based on changes in data values, while structural changes do not occur very often. If a structural row change does occur, it is usually a large chunk of the change, so performing a gap shift of O(n) complexity is a reasonable trade-off.

Let’s look at an example of counter:

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

We do that when we write code, but what does the compiler do?

When the compiler sees the Composable annotations, it inserts additional arguments and calls them in the function body.

First, the compiler adds a call to Composer. Start and passes an integer keyword 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 function bodies that contain a Composable.

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 a Composer executes, it does the following:

  • Composer. Start is executed and a group object is saved
  • Remember to insert the group object
  • The value returned by the state instance mutableStateOf is stored
  • Button also stores a group object after each parameter

Finally we come to Composer. End.

The data structure now holds all of the composition’s objects, sorted in order of execution of the entire tree, essentially a depth-first traversal of the entire tree.

Now all of these groups of objects take up a lot of space, so what is it for? The purpose of these group objects is to manage moves and inserts that might occur in a dynamic UI. The compiler knows what the code looks like that will change the UI structure, so it can insert these groups based on conditions. In most cases, the compiler does not need these groups, so it does not insert as many groups into slot tables. To demonstrate this, let’s 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, the getData function returns results that render a loading Composable in one case and a header and body in another. The compiler inserts two different keywords into the conditional 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 initial code execution returns null. Insert a group into the GAP array and the screen starts loading.

We then assume that the result returned is no longer NULL, so the second branch of the if statement is executed. That’s what makes it interesting.

The Composer. Start execution passes a group with the keyword 456. The compiler finds that this group does not match 123 in the table, so it knows that the structure of the UI has changed.

The compiler then moves the gap to the current location and widens the gap with the old UI, effectively abandoning the original UI.

At this point, the code executes normally, and the new UI — header and body — is inserted.

In this case, the if statement is just a slot entry in the slot table. By inserting a group that allows us to manipulate the flow of control of the UI, it allows the compiler to manage and use these cache-like data structures when executing the UI.

This is a concept we call “location-based memoization,” and one that Compose has been using since its inception.

Location-based memoization

In general, we have a general memoization meaning that the compiler caches the result of a function based on the parameters entered by the function. To illustrate location-based memoization, we saw a Composable function that performs calculations.

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

This function passes in a list of strings and a query string, and then performs a filter calculation on the passed list. We can wrap this calculation in a call to a record (which means knowing how to make a request to a table). Before returning, the filter computes and logs the results.

The second time the function is executed, the new value is compared to the historical record. If nothing changes, the filter operation is skipped and the previous result is returned. This is Memoization.

Interestingly, this operation is very inexpensive, and the compiler only needs to store previous calls. This calculation might happen throughout the ENTIRE UI, because you’re storing by location, and only at that location.

Below is the signature of the remember function, which is a function that takes arguments and evaluates functions.

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

Here’s an interesting degenerate case, where there are no parameters. One thing we can do is deliberately misuse this API. We can deliberately pass dirty data for calculation, such as math.random ().

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

It doesn’t make sense if you’re doing a global memoization. But with location-based memoization, it’s going to be a new semantics. Math.random returns a new value every time we use the Composable hierarchy. However, math. random returns the same value each time the Composable is re-compose. So you can persist, and persistence can be state managed.

Storage parameters

To demonstrate how the parameters of the Composable function are stored, we use Google’s Composable function, which takes a Number parameter of type Int, calls a Composable called Address and renders an Address.

@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 a table. If so, there is some redundancy in the above example, “Mountain View” and “CA”, which are added to the address are stored again as the text is called, so the strings are stored twice.

We can avoid this redundancy at compile time by adding static parameters.

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 static is a byte field that indicates whether the runtime knows if the parameter has changed. If the parameters do not change, then there is no need to save data. So in the Google example, the compiler passes a byte field to see if the argument will change.

So in Address, the compiler can do the same thing, pass it to the string.

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

The logic here is a little bit opaque and a little bit confusing, but we don’t need to understand it, it’s something compilers are good at, we’re not good at dealing with.

In the Google example, we see redundant information, but also some constants. We don’t have to store them. So the whole hierarchy is determined by the number of arguments and that’s the only thing the compiler has to store.

And because of that, we went a step further and generated the code to understand that that number is the only thing that changes. This code will run like this, if the number does not change, the whole function will be skipped, and you can then direct the current index of Composer to the next location as if the function had 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 which step needs to be fast-forward to resume execution.

restructuring

To save on how reorganization works, let’s 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 automatically generates for this counter example is composer. Star and Composer. End. Whenever counter is executed, it knows the value of count by reading the properties of the APP model instance. At run time, no matter what we call composer. End, we can choose whether to return the value or not.

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

We can then use this value to call the updateScope method, which passes a lambda to tell the runtime how to restart the Composable if necessary. This is the same as LiveData receiving a lambda. Here we use void (?) The reason is that the return is nullable. Why is it nullable? Because if we don’t read any model objects when Counter is running, we can’t tell the runtime how to update because we know it’s never going to update.

conclusion

What you need to know is that most of these details are just implementation details. The Composable functions in the standard Kotlin library have different behaviors and capabilities. It is sometimes helpful to understand how they are implemented, but behaviors and capabilities do not change. Implementations can change.

Similarly, compose’s compiler can generate more efficient code in certain situations. In the future, we hope to optimize it further.