Jetpack Compose in-depth exploration series 1: Composable function

The meaning of Composable functions

If we just focus on the simple syntax, any standard Kotlin function can be made a composable function by annotating it as @Composable:

insert image description here

By doing this, we are essentially telling the compiler that the function intends to convert some data into a Node for registration into the composable tree. That is, if we think of composable functions as @Composable (Input) -> Unit, the input is data, but the output is not what most people think of as a function return, but a registration action that inserts an element into the tree. We can think of this as a side effect of function execution.

The so-called " registration action " here is usually called " emitting " in Compose . The emit action is performed when the composable function is executed, which happens during composition.

insert image description here
Composable functions are executed for the sole purpose of building or updating the in-memory representation state of the tree. This will keep it always up to date with the tree structure it represents, as the composable function will re-execute every time the data it reads changes. In order to keep up-to-date with the tree state, they can issue operations to insert new nodes (as above), but equally can delete, replace or move nodes. Composable functions can also read from or write state to the tree.

Properties of Composable Functions

Annotating a function as Composablehas other related implications. @ComposableAn annotation effectively changes the type of the function or expression it is applied to , and like any other type, it imposes some constraints or properties on it. These properties are very closely related to Jetpack Compose, as they unlock related functionality of the Compose library.

Compose runtimeA function is expected Composableto obey the above properties, so it can assume certain behaviors and take advantage of different runtime optimizations, such as parallel composition, arbitrary composition order based on priority, smart reorganization or location memory, and so on.

In general, runtime optimization is only possible when the runtime has some certainty about the code it needs to run, so it can assume specific conditions and behaviors from it. This unlocks opportunities for execution, or in other words, to "consume" this code with the aforementioned determinism in order to follow a different execution strategy or evaluation technique.

An example of these certainties are the relationships between different elements in the code. Do they depend on each other? Can we run them in parallel or in a different order without affecting the program? Can we interpret each atomic logical fragment as a completely isolated unit?

call context

Most properties of composable functions are enabled by the Compose compiler . Since it's a Kotlin compiler plugin , it runs in the normal compiler phase and has access to all the information that the Kotlin compiler can. This allows it to intercept and transform the IR (intermediate representation) from all our composable functions in order to add some extra information.

Among them, one thing that the Compose compilerComposable will add to each function is to append a Composerparameter at the end of the parameter list. This parameter is implicit, which means that the developer will not be aware of it when writing the code. Its instance is injected at runtime and forwarded to all child Composablecalls, so it can be accessed from all levels of the tree.

insert image description here

Suppose we have the following code:

insert image description here

Then the Compose compiler will translate it into the following:

insert image description here

We can see that Composerit is forwarded to all calls in the body Composable. On this basis, the Compose compiler imposes strict rules on composable functions: they can only be called from other composable functions . Since this actually requires a call context, it ensures that the tree is only composed of composable functions so that they Composercan be forwarded down.

ComposerCompose runtimeis the bridge between the composable code we write as developers and the Composable functions will use this to emit change operations on the tree, thereby notifying Compose runtimethe shape of the tree in order to build or update its in-memory representation state.

idempotent

Composable functions are idempotent with respect to the node trees they generate . That is: re-executing a Composable function multiple times with the same input parameters should result in the same tree . The Jetpack Compose runtime relies on this assumption for things like recomposition.

In Jetpack Compose, recomposition is the act of re-executing composable functions when their inputs change, so they can emit updated information and update the tree. Compose runtimeIt must be possible to recombine composable functions at any time and for various reasons.

The reorganization process will traverse the entire tree, checking which nodes need to be regrouped (repeatedly). Only nodes with input changes will be regrouped, while the rest will be skipped . Skipping a node is only possible if the Composable function representing it is idempotent , because the runtime can assume that given the same input, it will produce the same result. These results are already in memory, so Compose doesn't need to re-execute it.

get rid of uncontrollable side effects

A side effect is to escape control of the function that called it in order to do something unexpected. Reading data from a local cache, making a network request, or setting a global variable can all be considered side effects. They make the invocation of a function dependent on external factors that may affect its behavior: external state that may be written to from other threads, third-party APIs that may throw exceptions, etc. In other words, at this point the function does not depend solely on its input to produce a result.

Side effects cause functions to have ambiguous or non-deterministic sources of input . This is bad for Compose because the runtime expects composable functions to be predictable (i.e. deterministic) so that they can be safely re-executed multiple times.

If a composable function runs side effects, it may produce a different program state each time it executes, making it non-idempotent .

Suppose we make a network request directly from the body of a Composable function, like this:

insert image description here
This would be very dangerous, as the function could be re- Compose runtimeexecuted many times in a short period of time, causing network requests to fire multiple times and get out of hand. The reality is worse than that, because these executions may happen in different threads without any coordination.

Compose runtimeThe right to choose an execution strategy for composable functions is reserved. It can schedule reorganizations into different threads to take advantage of multiple cores to boost performance, or it can run composable functions in any order according to its needs or priorities (for example: combinations not displayed on screen can be assigned a lower low priority)

Another common side effect warning is that we can make one Composable function depend on the result of another Composable function, imposing an ordering relationship. We want to avoid this at all costs. for example:

insert image description here
In this code snippet, Header, ProfileDetailand EventListcan be executed in any order, even in parallel.
We should not write logic that assumes any particular order of execution, such ProfileDetailas reading Headerfrom external variables that are expected to be written to.

In general, side effects are not ideal in composable functions. We must try to make all Composable functions stateless , so that they take all inputs as parameters and only use them to produce results. This makes Composables simpler, more reliable, and highly reusable. However, side effects are necessary when writing stateful programs that need to run network requests, save information in databases, use in-memory caches, etc. So at some point we need to run them (usually at the root of the composable tree). For this reason, Jetpack Compose provides a mechanism for safely calling side-effect operations from Composable functions in a controlled environment: the Side Effect API .

The side-effect API makes side-effect operations aware of the Composable's lifecycle, so they can be bound/driven by it. They allow side-effect operations to be automatically canceled when the Composable is unloaded from the tree, re-triggered when the side-effect inputs change, and even keep the same side-effect (only called once) across multiple recompositions. They will allow us to avoid invoking side-effect operations directly from the Composable's body without any control. We'll cover side-effect handlers in detail in later chapters.

restartable

We have mentioned a few times that composable functions can be recomposed, so they are not like traditional standard functions in the sense that they are not called only once as part of the call stack.

Here's what a normal call stack looks like. Each function is called once, and it can call one or more other functions.

insert image description here
On the other hand, since composable functions can be restarted (re-executed, recomposed) multiple times, the runtime keeps references to them . Here's what a composable call tree looks like:

insert image description here

where , Composable 4and Composable 5are re-executed after the input changes.

Compose chooses which nodes in the tree to restart in order to keep its in-memory representation always up to date. Composable functions are designed to be reactive and able to re-execute based on the state changes they observe.

The Compose compiler finds all Composable functions that read some state and generates the necessary code to tell the runtime that it's time to restart them. Composable functions that don't read state don't need to be restarted, so there's no need to tell the runtime how to do so.

fast execution

We can think of composable functions and composable function trees as a fast, declarative and lightweight way to build a description of a program that will be kept in memory and interpreted/materialized at a later stage .

Composable functions don't build and return UI . They just emit data to build or update in-memory structures. This makes them very fast and allows the runtime to execute them multiple times without fear. Sometimes it happens very frequently, like in every frame of an animation.

Developers must be aware of this when writing code and try to meet this expectation as much as possible. Any computational operation that may cause a high time cost should be executed in a coroutine, and always wrap it in a side-effect API that is aware of the life cycle.

location memory

Memory of place is a form of memory of function . Function memoization is the ability of a function to cache its results based on its inputs so that it does not need to be recomputed every time it is called for the same inputs. As mentioned earlier, this is only possible with pure functions (deterministic), because we can be sure that they will always return the same result for the same input, so we can cache and reuse the value.

Function memoization is a well-known technique in the functional programming paradigm, where programs are defined as compositions of pure functions.

In function memory , a function call can be identified by a combination of its name , type , and parameter values . And these elements can be used to create a unique Key for storing/indexing/reading cached results in later calls. But in Compose, an additional element is considered: Composable functions have an invariant knowledge of their position in the source code . When the same function is called with the same parameter values ​​but at different locations, the runtime will generate differentid (unique in the parent function):

insert image description here
The in-memory tree will store three different instances of it, each with a different id.

insert image description here

The identity of the Composable is preserved during recomposition, so the runtime can use this identity to determine whether a Composable was called before, and skip it if possible.

Sometimes, Compose runtimeassigning a unique identifier is difficult for a user. A simple example is to generate a list of Composables from a loop:

insert image description here

In this case, it's called from the same placeTalk(talk) each time , but each Talkrepresents a different item on the list, and thus a different node on the tree. In this case, Compose runtimerely on the order of the calls to generate unique ones idand still be able to distinguish them.

When adding a new element to the end of the list, this code still works fine because the rest of the calls stay in the same place as before. But what if we add elements at the top or somewhere in the middle? Compose runtimewill regroup everything below that position Talksince they changed position, even if their input didn't change. This is very inefficient (especially for long lists), since these calls should have been skipped.

To solve this problem, Compose provides a keyComposable for setting , so we can manually assign an explicit one to the Composable call key:

insert image description here
In this example, we use talk.id(possibly unique) as each Talk, keywhich will allow the runtime to keep the identity of all items in the list, regardless of their position.

Memoization allows the runtime to remember composable functions by design. Any composable functions inferred to be restartable by the Compose compiler should also be skippable, and thus be automatically remembered . Compose is built on top of this mechanism.

Sometimes developers need to use this memory structure in a more fine-grained way than the scope of Composable functions. Suppose we want to cache the results of heavy computations that happen in Composable functions. The Compose runtime provides rememberfunctions for this:

insert image description here
Here we use rememberthe result of the cached operation to precompute the filter for the image. The key of the index cache value will be based on the call position in the source code, and the function input (in this case, the file path). rememberA function is just a Composable function that knows how to read and write to the memory structure that holds the state of the tree. It only exposes this " memory of location " mechanism to developers .

In Compose, memory is not at the Application level. When something is memoized, it is done in the context of the Composable that called it . In the example above, it is FilteredImage. In practice, Compose will look up the cached value from the range of slots in the memory structure that stores the Composable information. This makes it more like a singleton in this scope . If the same Composable is called from a different parent class, a new instance of that value is returned .

Similarities to Suspend Functions

Kotlin suspend functions can only be called from other suspend functions, so they also need a calling context. This ensures that suspending functions can only be chained together, and gives the Kotlin compiler an opportunity to inject and forward the runtime environment across all computation levels. This adds an extra parameter to the end of the argument list of each suspend function: Continuation. This parameter is also implicit, so developers don't need to know it. Continuations can be used to unlock some new and powerful features in the language.

It's pretty similar to what the aforementioned Compose compiler does, isn't it?

Continuations are similar to callbacks in the Kotlin coroutine system. It tells the program how to proceed.

For example, the following code:

insert image description here

It will be replaced by the Kotlin compiler with:

insert image description here

ContinuationContains all the information needed by the Kotlin runtime to suspend and resume execution from different suspension points in the program. This makes hanging another good example of how a call context can be used as a means of carrying implicit information across the execution tree. Information that can be used at runtime to enable advanced language features.

Similarly, we can also @Composableunderstand it as a language feature. It makes standard Kotlin functions restartable, reactive, etc.

At this point, a fair question is why the Jetpack Compose team didn't use suspendto achieve the behavior they want. Well, even though these two features are very similar in the patterns they implement, they both enable completely different features in the language.

The Continuation interface is very specific in terms of suspending and resuming execution, so it is modeled as a callback interface, for which Kotlin generates a default implementation that contains what is needed to execute jumps, coordinate different suspension points, share data between them, etc. all mechanisms. The Compose use case is very different, as its goal is to create an in-memory representation of a large call graph that can be optimized differently at runtime.

Once we understand the similarities between composable functions and suspending functions, it's interesting to consider the idea of ​​"function coloring".

Colors for Composable Functions

Composable functions have different restrictions and capabilities than standard functions. They come in different types (more on that later) and model very specific concerns. This distinction can be understood as a form of function coloring , since they somehow represent a separate class of functions .

Function coloring was introduced by Bob Nystrom of Google's Dart team in a blog post titled What Color Are Your Functions? He explains why async and sync don't play nicely together, because you can't call an async function from a sync function unless you make the sync async too, or provide an await mechanism that allows calling an async function and waiting their results. That's why Promiseand async/awaitis introduced by some libraries and languages. This is an attempt to bring composability back. Bob Nystrom refers to these two function categories as two different "function colors".

In Kotlin, suspendaims to solve the same problem. However, suspending functions are also colored because we can only call suspending functions from within other suspending functions. Composing programs using standard functions and suspend functions requires some special integration mechanisms ( coroutine launch points ). Integration is opaque to developers.

In general, this limitation is to be expected. We are actually modeling two classes of functions, which represent concepts of completely different nature. It's like we're talking about two different languages. We have two operations: a synchronous operation designed to compute a result that returns immediately, and an asynchronous operation that unfolds over time and eventually provides a result (which may take longer to complete).

In Jetpack Compose, the situation for composable functions is equivalent. We cannot transparently call composable functions from standard functions. If we want to do this, we need an integration point (eg: Composition.setContent). Composable functions have a completely different goal than standard functions. They are not used to write program logic , but to describe changes to the node tree .

This might seem ridiculous. We know one of the nice things about composable functions is that you can use logic to declare UI. This means that sometimes we need to call composable functions from standard functions. For example:

insert image description here
Here SpeakerComposable is forEachcalled from the lambda function of , but the compiler doesn't seem to report an error. How does this way of mixing different function colors work?

The reason is that forEachthe function is inlineinlined. Set operators are declared inlineas such that they inline the lambdas into the caller and make them valid as if there were no additional gaps. In the example above, Speakerthe call to Composable is inlined into it SpeakerList, which is allowed since both are Composablefunctions. By taking advantage of inlining, we can bypass the problem of function coloring to write combined logic. Eventually our trees will also only consist of composable functions.

But is the function coloring problem really a problem?

Well, maybe this would, if we needed to combine the two types of functions and keep jumping from one to the other. However, neither of them is the case for suspendor . @ComposableBoth mechanisms require an integration point, so we get a fully colored call stack (with any suspendfunction or Composablefunctions) beyond that point. This is actually an advantage, as it allows the compiler and runtime to treat colored functions differently, and enables some more advanced language features that are not possible with standard functions.

In Kotlin, suspendit is allowed to model asynchronous non-blocking programs in a very idiomatic and expressive way. The language is able to express very complex concepts in an extremely simple way: by adding suspendmodifiers to functions. On the other hand, @Composableit makes standard functions restartable, skippable and responsive, which are not available in standard Kotlin functions.

Types of Composable Functions

@ComposableAnnotations effectively change the type of a function at compile time. From a syntactic point of view, the type of a Composable function is @Composable (T) -> A, where Acan be Unit, or any other type (if the function returns a value, such as remembera function). Developers can use this type to declare composable lambdas, just like any standard lambda in Kotlin.

insert image description here

Composable functions can also have @Composable Scope.() -> Atypes, which are usually only used to scope information to a particular composable object. For example:

insert image description here
From a language perspective, types exist to provide information to the compiler in order to perform quick static verification, sometimes generate some handy code, and demarcate/refine how the data is used at runtime. @ComposableAnnotations change how functions are validated and used at runtime, which is why they are considered to have a different type than normal functions.

Summarize

  • The meaning of the Composable function is to send a LayoutNode node to the Composition combination and insert it into the composition tree during execution.

  • The @Composable annotation actually changes the type of the function or the type of the expression, and the Compose runtime makes runtime optimizations based on this, such as parallel composition, intelligent reorganization, and location memory.

  • The Compose compiler will add a Composer parameter to the end of the parameter list of each Composable function, which is implemented by modifying the IR at the compiler stage and is not visible to developers. Instances of Composer are injected at runtime and forwarded to all child Composables, accessible throughout the tree.

  • Compose compiler restriction rule: Composable functions can only be called from other Composable functions. The reason is precisely for added Composer parameters to be forwarded down.

  • Composable functions should avoid directly performing side-effect operations, which make the input non-deterministic, otherwise the runtime cannot guarantee that it can safely execute multiple recombinations. Side-effect operations should be performed using the side-effect API provided by Compose. The side effect API makes side effect operations aware of the Composable's lifecycle. They can be started when Composable is mounted from the tree, and automatically canceled when it is unmounted.

  • Composable functions have no order with each other. They will not be executed in the order in which the code is written, but may be executed in any order or concurrently, which is determined by the runtime. So you can't rely on its order to write code logic.

  • The Compose runtime holds a reference to the Composable function, so the Composable function is restartable, that is, it can be re-executed multiple times, which is different from the traditional function call stack.

  • Location memory: Compose runtime will generate a unique id (key) for each Composable function, which contains the location information of Composable in the source code, that is, the same function ids with the same parameter value called at different locations are different. We can assign an explicit key to a Composable by manually calling key() { }.

  • Composable functions are surprisingly similar to kotlin suspend functions. For example, suspend functions can only be called in other suspend functions. The Kotlin compiler will inject additional Continuation parameters into suspend functions.

  • Function coloring: For Kotlin suspend functions, it is about the distinction between asynchronous functions and synchronous functions. Ordinary functions and suspend functions represent two different function colors. For Composable functions, it's about the distinction between standard and composable functions.

  • We can call other Composable functions in some collection operation APIs inside Composable functions, which does not violate the rule of "Composable functions can only be called from other Composable functions", because these collection operation APIs are inline inline, that is, Any inline function call inside a Composable function can directly call other Composable functions.

  • The purpose of the suspend function is to suspend and resume, to solve the combination of asynchronous and synchronous functions, and the purpose of the Composable function is to solve the restartable, skippable, and responsive problems. It is to build or update the memory representation state of the tree. Both mechanisms require an integration point in their implementation.

Guess you like

Origin blog.csdn.net/lyabc123456/article/details/129116380#comments_27492523