what is not visible to the developer with the naked eye

Hi all! My name is Danila, I am an Android developer in a team that is creating a super app WorksPad And mail client RuPost Desktop.

All new features in the project are written in Compose, so the question arose about improving one’s own skills. It was necessary to understand the nuances of use.

When I learned many of the subtle aspects of implementation, I wanted to share my new knowledge with the team – the initiative resulted in an internal meetup dedicated to working in Compose. The meeting was eventful, I received a lot of questions from colleagues. As a result, we came to the conclusion that we needed to make some kind of summary text guide, with which the developer would not have to think about how to use Compose.

The majority supported me in translating our meeting into article format. Everything you see below is that very guide. In it I will try to explain in simple words how the process of building a UI on Compose works:

  1. How recomposition works in Compose.

  2. What is the recomposition based on?

  3. How recomposition is optimized for the framework.

How does recomposition work in Compose?

Recomposition calling a composite function with new data. Each time data is updated in a Composable function, the function is called again with new data.

To understand recomposition, you need to understand the stages of Compose. There are only three iterations:

  1. Composition
    A tree of the graph of Composable functions is formed. At this stage, the set of layouts used and their order are determined.

Composition stage: code -> graph tree” title=”Composition stage: code -> graph tree” width=”1085″ height=”371″ data-src=”https://habrastorage.org/getpro/habr/upload_files/7b6/36a/edc/7b636aedcb7eaed49f893116e6b441bb.png”/></p><p><figcaption>Composition stage: code -> graph tree</figcaption></p></figure><ol start=
  • Layout
    Based on the graph tree, the arrangement of elements on the screen is formed. In other words, each element in the tree places its children in two-dimensional screen space.

  • Layout stage: graph tree -> 2d screen” title=”Layout stage: graph tree -> 2d screen” width=”1089″ height=”407″ data-src=”https://habrastorage.org/getpro/habr/upload_files/e6c/110/353/e6c110353fa259dc9ede9c7a442c5a81.png”/></p><p><figcaption>Layout stage: graph tree -> 2d screen</figcaption></p></figure><ol start=
  • Drawing
    Each tree element renders its own content.

  • Drawing stage: 2d screen -> Rendered UI” title=”Drawing stage: 2d screen -> Rendered UI” width=”1030″ height=”414″ data-src=”https://habrastorage.org/getpro/habr/upload_files/8af/b38/39d/8afb3839d126c94ad8a2bd0ede8a1266.png”/></p><p><figcaption>Drawing stage: 2d screen -> Rendered UI</figcaption></p></figure><p>The stages almost always happen sequentially.  As a result, the life cycle of a composable function looks like this:</p><figure class=Lifecycle of a Composable Function

    Lifecycle of a Composable Function

    Recomposition causes all stages to be processed with new data. The fewer recompositions are performed, the more efficient the application will be.

    Let's look at a small abstract example to make it easier to understand the problem:

    @Composable
    fun RecompositionExample(){
        /* Счётчик для обновления состояния. Вернёмся к разбору remember позже */
        var count by remember {
            mutableStateOf(0)
        }
    
        Column {
            ButtonCountWidget { count++ }
            TitleWidget(count.toString())
        }
    }
    
    /** Кнопка для обновления стейта для проведения теста */
    @Composable
    fun ButtonCountWidget(onClickAction: () -> Unit) {
        Button(onClick = onClickAction) {
            Text("Click on me")
        }
    }
    
    /** Виджет с текстом */
    @Composable
    fun TitleWidget(title: String) {
        Text(text = title)
    }

    As a small note: Compose works quite smartly with recomposition and can itself determine the moments when it needs to be done. Below is a table of the recomposition counter, where n is the number of recompositions, and skip is the number of skips of the recomposition.

    Each time I pressed the Compose button, I determined that it was necessary to recompose the function. TitleWidgetsince the value title has been changed. In its turn, ButtonCountWidget was not recomposed even once, since the data was not changed.

    What is the recomposition based on?

    Recomposition depends on the data that is provided to render the UI. In turn, we can represent the data as classes. We divide class types into Stable And Unstable.

    A stable class is an object that will not change, and if it changes, it will report it.

    According to the official documentation (<https://developer.android.com/reference/kotlin/androidx/compose/runtime/Stable#equals(kotlin.Any)>)) stable types in Compose include classes that:

    • Result equals always returns the same result for the same two instances.

    • When a public field changes, Compose will be notified about it.

    • All public properties are stable.

    By default, the following are considered stable:

    • Primitives;

    • String;

    • Enum;

    • Lambdas that capture stable types;

    • Classes with all stable attributes;

    • Classes from libraries that are compatible with Compose and implement stability support;

    • Generics, which are stable types.

    Everything else refers to unstable types that require special attention.

    For example: List, Set, Map and so on. They are not stable and cause recomposition whenever the parent composable function is updated.

    All interfaces are obviously unstable types. Compose cannot determine whether the element being changed is under an interface, since the implementations may be different.

    Example: The List implementation contains a MutableList that will change externally, but Compose will not know about it.

    Incoming data is divided into stable and unstable classes, a similar situation occurs with functions. They can be divided into skippable And restartable.

    Skippable functions can skip recomposition by using stable data. Restartable functions will always be restarted.

    Let's consider the peculiarity of working on abstract examples, where yes – the data has changed, and no – remained unchanged:

    Recomposition for unstable types without changing them

    Recomposition for unstable types without changing them

    Screen started recomposition. For function EventList the data hasn't changed, but it started recomposing because it has unstable types. Due to this, every time recomposition of a parent function starts, a child function with unstable types will be recomposed. But unnecessary recompositions are clearly useless here – they only waste the resources of the user’s device.

    Skip recomposition for unstable types

    Skip recomposition for unstable types

    Let's pay attention to the block Day. It has unstable types, but was recomposed because the parent function missed it. This Compose processed it myself correctly.

    To avoid unnecessary recompositions, you need to have as many skippable functions as possible. But in order to achieve this, stable classes are needed. How to break this vicious circle?

    There are several ways to solve the instability problem:

    1. Use kotlinx.ImmutableCollections is an alpha library that provides stable collection types.

    2. Using Annotations

    About the first option I will only say that this will add stable collections to your project. I suggest you figure it out on your own in the library, everything there is quite elementary.

    But let’s look at the annotations in detail and look at an example

    You can make a class stable by adding just one line. Let's look at an example data class with a list in one of the fields:

    /**
     * List не стабильный класс, поэтому при каждом обновлении Composer всегда его обработает
     *
     * Отчёт компилятора Сompose:
     *
     * unstable class IncorrectTestRecomposeState {
     *   unstable val list: List<Int>
     *   <runtime stability> = Unstable
     * }
     */
    data class RecomposeExampleState(
        val list: List<Int>
    )
    
    @Composable
    fun ListRecompositionExample(recompositionList: RecomposeExampleState) {
        Log.i("RecompositionExample", "ListRecompositionExample - $recompositionList")
    }

    A guide to getting the Compose compiler report is described by Google: https://developer.android.com/jetpack/compose/performance/stability/diagnose#compose-compiler

    Let's use this function in the context of the example discussed earlier:

    val recompositionList = RecomposeExampleState(listOf(1, 2, 3, 4, 5, 6, 7, 8, 9))
    
    @Composable
    fun RecompositionExample(){
        /* Счётчик для обновления состояния. Вернёмся к разбору remember позже */
        var count by remember {
            mutableStateOf(0)
        }
    
        Column {
            ButtonCountWidget { count++ }
            TitleWidget(count.toString())
            ListRecompositionExample(recompositionList)
        }
    }
    
    /** Кнопка для обновления стейта для проведения теста */
    @Composable
    fun ButtonCountWidget(onClickAction: () -> Unit) {
        Button(onClick = onClickAction) {
            Text("Click on me")
        }
    }
    
    /** Виджет с текстом */
    @Composable
    fun TitleWidget(title: String) {
        Text(text = title)
    }

    Every time a parent function is recomposed, a recomposition is triggered in ListRecompositionExample. This raises the question, “how can this behavior be corrected”?

    Solution: add an annotation to the data class @Immutable. There is also an annotation @Stablewhich has similar behavior, but the meaning of the terminology is different.

    @Immutable is an annotation that states that the value will not change without changing the instance.

    @Stable is an annotation that states that the value may change, but the class will report that the data has changed.

    Let me explain the work with @Immutable. When a class is annotated, then Compose will consider the class immutable, regardless of its attributes. And if a class is immutable, then it is classified as stable. Here you need to be as careful as possible, since if you change the data in the class without creating a new instance, Compose will not know about it and will not perform recomposition.

    Example: There is a data class with a parameter of type List, but during initialization the implementation was MutableList, which changes from outside. In this case, Compose will not process these changes and the UI will be incorrect.

    @Immutable
    data class RecomposeExampleState(
        val list: List<Int>
    )

    Now recomposition is not called when the data is the same. But it is important to understand that recomposition will only occur if a new instance is received RecomposeExampleState.

    Let's dive deeper and talk about how Compose determines that functions need to be recomposed.

    How does optimization work?

    To do this, let's look at what the generated function looks like:

    @Composable
    fun Example(value: String, $composer: Composer<*>, $changed: Int){
     $composer.startRestartGroup( /* id */ );
     
      /* Вызов функции */ 
      f(...)
     
      $composer.endRestartGroup()
    }

    I will explain the need for each of the function parameters.

    Composer — a kind of context for executing composable functions. It is passed to all child functions, so all nested functions will have an execution context and refer to it to obtain the necessary information.

    Changed — transmits the state of the parameters of the function being processed.

    Inside the function, all the code we wrote is divided into three types of groups, which cache the result of their operation according to the rules:

    1. Restartable group
      The default group that is used to restart composable functions.

    @Composable
    fun RestartableWidget(){
        Text(text = "Restartable Widget")
    }
    -------
    @Composable
    fun RestartableWidget($composer: Composer<*>){
     $composer.startRestartGroup( /* id */ )
      Text(text = "Restartable Widget", $composer)
      $composer.endRestartGroup()
    }
    1. Movable
      A group that is used to work with a sequence of composable functions. For example, working in a loop and calling composable functions inside. It preserves the state of the elements within the loop and allows you to change the order without additional cost.

    @Composable
    fun MovableWidget(){
      for (i in 0..100){
             Text(text = "Movable")
         }
    }
    -------
    @Composable
    fun MovableWidget($composer: Composer<*>){
     $composer.startRestartGroup( 1111 )
      for (i in 0..100){
             $composer.startMovableGroup( 2222 )
             Text(text = "Movable $i")
             $composer.endMovableGroup()
         }
      $composer.endRestartGroup()
    }
    1. Replaceable
      A group that is used to work with several different blocks by condition. For example, if there is “if” then, accordingly, the “if” blocks will be wrapped in Replaceable group.

    @Composable
    fun ReplaceableWidget(count: Int){
      if(count > 10){
             Text(text = "Replaceable 1")
         } else {
             Text(text = "Replaceable 1")     
         }
    }
    -------
    @Composable
    fun MovableWidget($composer: Composer<*>){
     $composer.startRestartGroup( 11)
      f(count > 10){
             $composer.startReplaceableGroup(22)
             Text(text = "Replaceable 1")
             $composer.endReplaceableGroup()
         } else {
             $composer.startReplaceableGroup( 33)
             Text(text = "Replaceable 2")     
             $composer.endReplaceableGroup()
      }
      $composer.endRestartGroup()
    }

    As a result of dividing into groups, the code for their use is stored in a slot table, which can be depicted like this:

    Type of storage of results of calculated group values

    Type of storage of results of calculated group values

    The calculated value of the composable function is cached into a table slot using positional memoization. Subsequent calls to the same function with the same data will not recalculate the value. It will be taken from the cache.

    Memoization — caching the calculated value to quickly obtain the result the next time the function is called.

    But why positional?

    It is necessary to take into account not only the parameters in the function, but also the place where it is called. If we are in different groups, this does not mean that we can pull the common value from the cache.

    Let's return to the generated parameter $changed. It is used to detect changes to the data that the composable function uses.

    The values ​​are usually called “masks”, since the changed parameter will contain masks for each of the parameters, on the basis of which the omissions of the compiled functions will be determined.

    The most common mask meanings are:

    • Uncertain (0b000) — the value has not yet been set.

    • Same (0b001) – the value has not changed.

    • Different (0b010) – the meaning has changed.

    • Static (0b011) — the value can be changed (for example, constants).

    • Mask (0b111) — a service mask, used by the compiler to obtain the state of a certain parameter.

    They help determine whether it is necessary to call a function recomposition or retrieve a previously stored value.

    Let's look at an example:

    @Composable
    fun Examle(a:Int, b:Int)
    <-------------------------->
    @Composable
    fun Examle(a:Int, b:Int, $composer: Composer<*>, $changed: Int)

    Let A have changed, but B has not. Then the mask will take the value:
    changed = 010_001_0 (different_same_0 – the zero at the end is a system bit).

    Based on these masks, the recomposition of dependent elements will be calculated using bitwise operations inside Compose. We will not go into detail about the algorithms for determining states for each of the parameters. It is enough to know that due to bitwise operations, the states of parameters are calculated from the changed value for the recomposition of certain groups within the framework of the function being composed. Accordingly, if the data has not changed, then a transition will occur to the end of the group, which will take the previously calculated value from the cache.

    Conclusion

    I looked at the main and basic mechanism of how Compose works, on which everything else is built. This article will only cover part of the implementation, but this is what will allow you to understand how everything works under the hood. Already at this stage, without even describing the implementation of remember, the essence of the algorithm for operating this function is clear.

    Why do you need to know how it works?
    To create a quality application using Compose, you need to understand the rules of the game. Otherwise, many non-obvious issues will arise in the form of freezes and incorrect displays of data, which ultimately lead to the user deleting the product.

    Interesting fact:

    With the release of the new version of Compose 1.5.4, a new operating mode appeared in the experimental version: Strong skipping mode. It relaxes data stability rules, allowing more classes to be made immediately stable without additional code on the part of the developer. But now it is still impossible to foresee all the pitfalls that may arise. Technology moves forward and it is not known what awaits us in the future. Maybe in the future developers will not have to think about many of the subtleties of use, but we live here and now.

    The topic is debatable, the technology is moving. If you have any questions, I will be happy to answer them in the comments.

    Similar Posts

    Leave a Reply

    Your email address will not be published. Required fields are marked *