这是用户在 2024-3-23 7:54 为 https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?

Under the hood of Jetpack Compose — part 2 of 2
深入了解 Jetpack Compose — 第 2 部分(共 2 部分)

Under the hood of Compose
在 Compose 的背后

Leland Richardson
Android Developers
Published in
9 min readAug 29, 2020

This is the second of two posts about Compose. In the first post I explained the benefits of Compose, the challenges Compose addresses, the reasons behind some of our design decisions, and how those help app developers. Also, I discussed the mental model of Compose, how you should think about the code that you write in Compose, and how you should shape your APIs.
这是有关 Compose 的两篇文章中的第二篇。在第一篇文章中,我解释了 Compose 的好处、Compose 解决的挑战、我们一些设计决策背后的原因,以及这些决策如何帮助应用程序开发人员。此外,我还讨论了 Compose 的心理模型、您应该如何思考在 Compose 中编写的代码,以及您应该如何塑造您的 API。

Now I’m going to look at how Compose works under the hood. But before I start, I want to emphasize that understanding how Compose is implemented is not required to use Compose. What follows is purely to satisfy your intellectual curiosity.
现在我将了解 Compose 的底层工作原理。但在开始之前,我想强调的是,使用 Compose 并不需要了解 Compose 的实现方式。接下来的内容纯粹是为了满足您的求知欲。

What does @Composable mean?
@Composable 是什么意思?

If you’ve looked at Compose, you have likely seen the @Composable annotation in a number of code examples. An important thing to note is that Compose is not an annotation processor. Compose works with the aid of a Kotlin compiler plugin in the type checking and code generation phases of Kotlin: there is no annotation processor needed in order to use compose.
如果您看过 Compose,您可能在许多代码示例中看到过 @Composable 注释。需要注意的重要一点是 Compose 不是注释处理器。 Compose 在 Kotlin 的类型检查和代码生成阶段借助 Kotlin 编译器插件进行工作:使用 compose 不需要注释处理器。

This annotation more closely resembles a language keyword. A good analogy is Kotlin’s suspend keyword.
该注释更类似于语言关键字。一个很好的类比是 Kotlin 的 suspend 关键字。

Kotlin’s suspend keyword operates on function types: you can have a function declaration that’s suspend, a lambda, or a type. Compose works in the same way: it can alter function types.
Kotlin 的 suspend 关键字作用于函数类型:您可以有一个 suspend、lambda 或类型的函数声明。 Compose 以同样的方式工作:它可以改变函数类型。

The important point here is that when you annotate a function type with @Composable you’re changing its type: the same function type without the annotation is not compatible with the annotated type. Also, suspend functions require a calling context, meaning that you can only call suspend functions inside of another suspend function.
这里重要的一点是,当您使用 @Composable 注解函数类型时,您正在更改其类型:没有注解的相同函数类型与注解的类型不兼容。此外,挂起函数需要调用上下文,这意味着您只能在另一个挂起函数内部调用挂起函数。

Composable works the same way. This is because there’s a calling context object that we need to thread through all of the invocations.
可组合的工作方式相同。这是因为我们需要有一个调用上下文对象来线程化所有调用。

Execution model 执行模型

So, what is this calling context thing that we’re passing around and why do we need to do it?
那么,我们传递的调用上下文是什么以及为什么我们需要这样做?

We call this object the “Composer”. The implementation of the Composer contains a data structure that is closely related to a Gap Buffer. This data structure is commonly used in text editors.
我们称这个对象为“Composer”。 Composer 的实现包含与 Gap Buffer 密切相关的数据结构。这种数据结构通常用于文本编辑器。

A gap buffer represents a collection with a current index or cursor. It is implemented in memory with a flat array. That flat array is larger than the collection of data that it represents, with the unused space referred to as the gap.
间隙缓冲区表示具有当前索引或游标的集合。它是通过平面数组在内存中实现的。该平面数组大于它所表示的数据集合,未使用的空间称为间隙。

Now, an executing Composable hierarchy can appeal to this data structure and insert things into it.
现在,执行的可组合层次结构可以调用此数据结构并向其中插入内容。

Let’s imagine that we’re done executing the hierarchy. At some point, we’re going to re-compose something. So, we reset the cursor to the top of the array and then go through execution again. As we execute we can look at the data and do nothing or update the value.
假设我们已经完成了层次结构的执行。在某些时候,我们会重新创作一些东西。因此,我们将光标重置到数组顶部,然后再次执行。当我们执行时,我们可以查看数据,但不执行任何操作或更新值。

We may decide that the structure of the UI has changed and want to make an insert. At this point we move the gap to the current position.
我们可能会认为 UI 的结构已更改并想要进行插入。此时我们将间隙移动到当前位置。

And now, we’re able to make inserts.
现在,我们可以制作插入件了。

The important thing to understand about this data structure is that all of the operations — get, move, insert, and delete — are constant time operations, except for moving the gap. Moving the gap is O(n). 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.
了解此数据结构的重要一点是,除了移动间隙之外,所有操作(获取、移动、插入和删除)都是常数时间操作。移动间隙的时间复杂度为 O(n)。我们选择这种数据结构的原因是因为我们打赌,平均而言,UI 不会改变太多结构。当我们有动态 UI 时,它们的值会发生变化,但结构不会经常发生变化。当它们确实改变结构时,它们通常会发生大块变化,因此进行这种 O(n) 间隙移动是一个合理的权衡。

Let’s look at an example of a counter.
让我们看一个计数器的例子。

This is the code that we would write, but let’s look at what the compiler does.
这是我们要编写的代码,但让我们看看编译器会做什么。

When the compiler sees the Composable annotation, it inserts additional parameters and calls into the body of the function.
当编译器看到 Composable 注释时,它会插入附加参数并调用函数体。

First, the compiler adds a call to the composer.start method and passes it a compile time generated key integer.
首先,编译器添加对composer.start方法的调用,并向其传递编译时生成的关键整数。

The compiler also passes the composer object into all of the composable invocations in the body of the function.
编译器还将 Composer 对象传递到函数体中的所有可组合调用中。

When this composer executes it does the following:
当这个作曲家执行时,它会执行以下操作:

  • Composer.start gets called and stores a group object
    Composer.start 被调用并存储一个组对象
  • remember inserts a group object
    记住插入一个组对象
  • the value that mutableStateOf returns, the state instance, is stored.
    存储 mutableStateOf 返回的值(状态实例)。
  • Button stores a group, followed by each of its parameters.
    Button 存储一个组,后跟其每个参数。

And then finally we arrive at composer.end.
最后我们到达了composer.end。

The data structure now holds all of the objects from the composition, the entire tree in execution order, effectively a depth first traversal of the tree.
数据结构现在保存了组合中的所有对象,整个树按执行顺序,有效地深度优先遍历树。

Now, all of those group objects are taking up a lot of space, so what are they there for? These group objects are there to manage the moves and inserts that can happen with a dynamic UI. The compiler knows what code that changes the structure of the UI looks like so it can conditionally insert those groups. Most of the time, the compiler doesn’t need them so it doesn’t insert that many groups into the slot table. To illustrate this look at the following conditional logic.
现在,所有这些组对象都占用了大量空间,那么它们有什么用呢?这些组对象用于管理动态 UI 中可能发生的移动和插入。编译器知道更改 UI 结构的代码是什么样子,因此它可以有条件地插入这些组。大多数时候,编译器不需要它们,因此不会将那么多组插入槽表中。为了说明这一点,请看以下条件逻辑。

In this composable the getData function returns some result and renders a loading composable in one case and a header and a body in another case. The compiler inserts separate keys for each branch of the if statement.
在此可组合项中,getData 函数返回一些结果,并在一种情况下呈现加载可组合项,在另一种情况下呈现标题和正文。编译器为 if 语句的每个分支插入单独的键。

Let’s assume that when this code first executes result is null. This inserts a group into the gap array and the loading screen runs.
假设该代码第一次执行时结果为 null。这会将一个组插入间隙数组并运行加载屏幕。

The second time the function runs let’s assume that result is no longer null so that the second branch of the if statement executes. This is where it gets interesting.
函数第二次运行时,我们假设 result 不再为 null,以便执行 if 语句的第二个分支。这就是有趣的地方。

The call to composer.start has a group with the key 456. The compiler sees that the group in the slot table of 123 doesn’t match, so now it knows that the UI has changed in structure.
对composer.start的调用有一个键为456的组。编译器发现槽表中的组123不匹配,所以现在它知道UI的结构发生了变化。

The compiler then moves the gap to the current cursor position and extends the gap across the UI that was there, effectively getting rid of it.
然后,编译器将间隙移动到当前光标位置,并将间隙扩展到整个 UI,从而有效地消除它。

At this point, the code is executed as normal, and the new UI — the header and the body — is inserted.
此时,代码将正常执行,并插入新的 UI(标题和正文)。

The overhead of the if statement, in this case, was a single slot entry in the slot table. By inserting this single group we have arbitrary control flow in our UI enabling the compiler to manage it and appeal to this cache-like data structure while executing the UI.
在这种情况下,if 语句的开销是槽表中的单个槽条目。通过插入这个单一组,我们在 UI 中拥有任意控制流,使编译器能够管理它并在执行 UI 时调用这种类似缓存的数据结构。

This concept is that we call Positional Memoization and this is the concept that Compose is built around, from the ground up.
我们将这个概念称为“位置记忆化”,Compose 就是围绕这个概念从头开始构建的。

Positional Memoization 位置记忆

Normally, we have global memoization meaning that the compiler caches the result of a function based on the inputs of that function. To illustrate an example of positional memoization take this composable function, which is performing a computation.
通常,我们有全局记忆,这意味着编译器根据函数的输入缓存函数的结果。为了说明位置记忆的示例,请使用这个可组合函数,该函数正在执行计算。

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 slot table. remember looks at items and stores the list and query in the slot table. The filter computation then runs and remember stores the result before passing it back.
该函数接受字符串项列表和查询,然后对该列表执行过滤计算。我们可以将这个计算包装在对 Remember 的调用中:remember 是知道如何调用槽表的东西。记住查看项目并将列表和查询存储在插槽表中。然后运行过滤器计算并记住在将结果传回之前存储结果。

The second time the function executes, remember looks at the new values being passed in and compares them with the old values. If neither of them has changed, then the filter operation is skipped and the previous result returned. That’s positional memoization.
第二次执行该函数时,请记住查看传入的新值并将它们与旧值进行比较。如果它们都没有改变,则跳过过滤操作并返回之前的结果。这就是位置记忆。

Interestingly, this operation was really cheap: the compiler had to store one previous invocation. This calculation could happen all over your UI and, because you’re storing it positionally, it only stores it for that location.
有趣的是,这个操作非常便宜:编译器必须存储以前的调用。此计算可能会在您的整个 UI 中进行,并且由于您按位置存储它,因此它仅存储该位置的数据。

This is the signature of the remember function, it can take any number of inputs and then a calculation function.
这是记住函数的签名,它可以接受任意数量的输入,然后是计算函数。

But there is an interesting degenerate case here when there are zero inputs. One of the things we can do is deliberately misuse this API. We can memoize an intentionally impure calculation, such as Math.random.
但是当输入为零时,这里有一个有趣的退化情况。我们可以做的事情之一就是故意滥用这个 API。我们可以记住故意不纯的计算,例如 Math.random。

If you were doing this with global memoization it would make no sense. But, with positional memoization, it ends up taking on a new semantic. Every time we use App in a composable hierarchy, a new Math.random value is returned. However, every time that composable re-composes, it will be the same Math.random return value. This gives rise to persistence and that persistence gives rise to state.
如果你用全局记忆来做到这一点,那就没有意义了。但是,通过位置记忆,它最终呈现出一种新的语义。每次我们在可组合层次结构中使用 App 时,都会返回一个新的 Math.random 值。但是,每次可组合项重新组合时,它都将是相同的 Math.random 返回值。这产生了持久性,而持久性又产生了状态。

Storing parameters 存储参数

To illustrate how a Composable function’s parameters are stored, let’s take a Google Composable that takes in a number, calls an address composable, and renders an address.
为了说明 Composable 函数的参数是如何存储的,我们以一个 Google Composable 为例,它接受一个数字,调用一个地址可组合项,并呈现一个地址。

Compose stores the parameters of a composable function in the slot table. If this is the case, looking at the example above we see some redundancies: “Mountain View” and “CA”, which are added in the address invocation, are stored again in the underlying text invocations, so these strings will be stored twice..
Compose 将可组合函数的参数存储在槽表中。如果是这种情况,查看上面的示例,我们会看到一些冗余:在地址调用中添加的“Mountain View”和“CA”会再次存储在底层文本调用中,因此这些字符串将被存储两次。 。

We can get rid of this redundancy by adding the static parameter to Composable functions at the compiler level.
我们可以通过在编译器级别将静态参数添加到可组合函数来消除这种冗余。

The static parameter in this case is a bit field indicating whether or not the runtime knows the parameter does not change. If a parameter is known to not change, then there’s no need for it to be stored. So, in this Google example, the compiler passes a bit field that says none of the parameters are going to change.
在这种情况下,静态参数是一个位字段,指示运行时是否知道该参数不会更改。如果已知参数不会更改,则无需存储它。因此,在这个 Google 示例中,编译器传递了一个位字段,表示所有参数都不会更改。

Then, in Address, the compiler can do the same thing and pass it into text.
然后,在 Address 中,编译器可以执行相同的操作并将其传递为文本。

This bitwise logic is hard to read and is confusing, but there is no need for us to understand this: compilers are good at this, humans aren’t.
这种按位逻辑很难阅读并且令人困惑,但我们没有必要理解这一点:编译器擅长于此,而人类则不然。

In the Google example, we see that there is redundant information but there are also constants here. It turns out, we don’t need to store them either. So the entire hierarchy is determined by the number parameter and that is the only value that the compiler needs to store.
在谷歌的例子中,我们看到有冗余信息,但也有常量。事实证明,我们也不需要存储它们。因此,整个层次结构由 number 参数确定,这是编译器需要存储的唯一值。

Because of this, we can go further and generate code that understands that number is the only thing that’s going to change. This code can then work so that if a number hasn’t changed the body of the function would be skipped entirely, and we can just instruct the composer to move its current index to where it would be as if the function had executed.
正因为如此,我们可以更进一步,生成理解数字是唯一会改变的东西的代码。然后,这段代码可以工作,这样,如果数字没有改变,函数的主体将被完全跳过,我们可以指示编写器将其当前索引移动到函数已执行的位置。

The composer knows how far to fast forward the execution to resume where it needs to.
作曲家知道将执行快进多远才能恢复到需要的位置。

Recomposition 重组

To explain how recomposition works, let’s go back to the counter example.
为了解释重组是如何工作的,让我们回到反例。

The generated code the compiler creates for this counter has a composer.start and compose.end. Whenever Counter executes, the runtime understands that when it calls count.value, it’s reading the property of an appmodel instance. At runtime, whenever we call compose.end, we optionally return a value.
编译器为此计数器创建的生成代码具有composer.start 和compose.end。每当 Counter 执行时,运行时就会明白,当它调用 count.value 时,它​​正在读取 appmodel 实例的属性。在运行时,每当我们调用 compose.end 时,我们都可以选择返回一个值。

Then we can call an updateScope method on that value with a lambda that tells the runtime how to restart this Composable, if it needs to. This is equivalent to the lambda that LiveData would otherwise receive. The reason that the question mark is here — the reason that this is nullable — is because, if we don’t read any model objects during the execution of Counter there’s no reason to teach the runtime how to update this because we know it never will update.
然后,我们可以使用 lambda 对该值调用 updateScope 方法,该 lambda 告诉运行时如何重新启动此 Composable(如果需要)。这相当于 LiveData 在其他情况下收到的 lambda。这里问号的原因——它可以为空的原因——是因为,如果我们在 Counter 执行期间没有读取任何模型对象,就没有理由教运行时如何更新它,因为我们知道它永远不会更新。

Closing thoughts 结束语

It is important to remember that most of these details are just implementation details. Composable functions have different behaviors and capabilities from standard Kotlin functions, and it can sometimes be helpful to understand how those are implemented, but while the behaviors and capabilities will not change, the implementation might.
重要的是要记住,这些细节中的大部分只是实现细节。可组合函数具有与标准 Kotlin 函数不同的行为和功能,有时了解它们的实现方式会有所帮助,但虽然行为和功能不会改变,但实现可能会改变。

Likewise, the compose compiler is able to generate code that is more efficient in certain situations. Over time, we hope for these optimizations to improve.
同样,Compose 编译器能够生成在某些情况下更高效的代码。随着时间的推移,我们希望这些优化能够得到改进。

More from Leland Richardson and Android Developers
Leland Richardson 和 Android 开发者提供的更多内容

Recommended from Medium 媒体推荐

Lists 列表

See more recommendations