Composable 函数的含义
如果我们只专注于简单的语法,任何标准的Kotlin函数都可以成为一个可组合函数,只需将其注解为@Composable
:
通过这样做,我们实际上是在告诉编译器,该函数打算将一些数据转换为一个Node节点,以便注册到可组合树中。也就是说,如果我们将可组合函数看成是 @Composable (Input) -> Unit
,输入是数据,但输出却不是大多数人认为的函数返回的值,而是一个将元素插入树中的注册动作。我们可以认为这是一种函数执行带来的副作用。
这里所谓的 “注册动作” 在 Compose 中通常称为 “emitting”(发射)。emit 动作是在可组合函数被执行时进行的,这发生在组合过程中。
执行Composable函数的唯一目的是构建或更新树的内存表示状态。这将使它始终与其所代表的树结构保持同步更新状态,因为可组合函数会在其读取的数据每次发生变化时重新执行。为了保持和树的同步更新状态,它们可以发出插入新节点的操作(如上所述),但同样也可以删除、替换或移动节点。可组合函数还可以从树中读取或向树中写入状态。
Composable 函数的属性
将函数注解为Composable
还有其他相关含义。@Composable
注解有效地更改了它所应用的函数或表达式的类型,并且与任何其他类型一样,它对其施加了一些约束或属性。这些属性与Jetpack Compose非常的紧密相关,因为它们可以解锁Compose库的相关功能。
Compose runtime
期望Composable
函数遵守上述属性,因此它可以假定某些行为,并利用不同的运行时优化,如并行组合、基于优先级的任意组合顺序、智能重组或位置记忆等等。
一般来说,只有当运行时对需要运行的代码有一定的确定性时,才可能进行运行时优化,因此它可以从中假设特定的条件和行为。这解锁了执行的时机,或者换句话说,利用上述确定性来 “消费” 这段代码,以便遵循不同的执行策略或评估技术。
这些确定性的一个例子是代码中不同元素之间的关系。他们是否相互依赖? 我们可以在不影响程序的情况下,并行或以不同的顺序运行它们吗?我们能把每个原子逻辑片段解释为一个完全孤立的单元吗?
调用上下文
可组合函数的大多数属性是由Compose编译器启用的。因为它是一个Kotlin编译器插件,所以它在正常的编译器阶段运行,并且可以访问到所有的Kotlin编译器可以访问的信息。这允许它拦截和转换来自我们所有可组合函数的 IR (intermediate representation,中间产物表示),以便添加一些额外的信息。
其中,Compose编译器 为每一个 Composable
函数都会新增的一个东西就是在参数列表的末尾附加了一个 Composer
参数。这个参数是隐式的,也就是说开发者在编写代码时并不会感知到这一点。它的实例是在运行时被注入的,并被转发给所有的子Composable
调用,因此它可以从树的所有级别中被访问到。
假设我们有如下代码:
那么Compose编译器会将其翻译成下面这样:
我们可以看到,Composer
被转发到了body内的所有Composable
调用。在此基础上,Compose编译器对可组合函数施加了严格的规则:它们只能从其他可组合函数调用。因为这实际是需要调用上下文,它确保树只由可组合函数组成,以便Composer
可以被向下转发。
Composer
是我们作为开发者编写的可组合代码与Compose runtime
之间的连接桥梁。可组合函数将使用它来发射对树的更改操作,从而通知 Compose runtime
树的形状,以便构建或更新其内存表示状态。
幂等
可组合函数相对于它们生成的节点树是幂等的。也就是说:使用相同的输入参数多次重新执行一个Composable函数应该会得到相同的树。Jetpack Compose运行时依赖于这个假设来进行重新组合之类的事情。
在Jetpack Compose中,重组是在可组合函数的输入发生变化时重新执行的动作,这样它们就可以发射更新后的信息并更新树。 Compose runtime
必须能够在任意时间,根据各种原因重新组合可组合函数。
重组过程将遍历整个树,检查哪些节点需要重新组合(重复执行)。只有具有输入变化的节点将重新组合,而其余的节点将被跳过。跳过一个节点只有在表示它的Composable函数是幂等的情况下才有可能发生,因为runtime可以假设给定相同的输入,它将产生相同的结果。这些结果已经在内存中了,因此Compose便不再需要重新执行它。
摆脱无法控制的副作用
副作用是为了做一些意想不到的事情而逃避调用它的函数控制。从本地缓存读取数据、进行网络请求或设置全局变量等都可以认为是副作用。它们使得函数的调用依赖于可能影响其行为的外部因素:可能从其他线程被写入的外部状态,可能抛出异常的第三方api等等。换句话说,这时函数并不仅仅依赖于它的输入来产生结果。
副作用会导致函数拥有模糊或不确定性的输入来源。这对Compose来说不是好事,因为runtime期望可组合函数是可预测的(即确定性的),那样它们才可以被安全地重新执行多次。
如果一个可组合函数运行副作用,它可能在每次执行时产生不同的程序状态,这使其变成非幂等的。
假如我们直接从一个Composable函数体进行一个网络请求,就像下面这样:
这将是非常危险的,因为该函数可能会在短时间内被Compose runtime
重新执行多次,从而使网络请求多次触发并失去控制。实际情况比这更糟,因为这些执行可能发生在不同的线程中,没有任何协调。
Compose runtime
保留为可组合函数选择执行策略的权利。它可以将重组安排到不同的线程中,以利用多核心提升性能,或者它可以根据自己的需要或优先级以任意顺序运行可组合函数 (例如:没有显示在屏幕上的组合可以被分配一个较低的优先级)
另一个常见的副作用警告是,我们可以使一个Composable函数依赖于另一个Composable函数的结果,强加一个顺序关系。我们无论如何都要避免这种情况。举个例子:
在这个代码片段中,Header
、ProfileDetail
和EventList
可以任意顺序执行,甚至可以并行执行。
我们不应该编写假定任何特定执行顺序的逻辑,比如从ProfileDetail
中读取预期从Header
中写入的外部变量。
一般来说,在可组合函数中,副作用并不理想。我们必须尝试使所有的Composable函数都是无状态的,这样它们就可以将所有的输入作为参数,并且只使用它们来产生结果。这使得Composables更简单,更可靠,并且高度可重用。然而,副作用在编写有状态程序时又是必要的,程序需要运行网络请求,在数据库中保存信息,使用内存缓存等。因此在某种程度上我们需要运行它们(通常在可组合树的根节点)。出于这个原因, Jetpack Compose 提供了在受控环境中安全地从Composable函数调用副作用操作的机制:副作用API 。
副作用API 使副作用操作能感知到Composable的生命周期,因此它们可以受到它的约束/驱动。它们允许在Composable从树中卸载时自动取消副作用操作,而在副作用输入改变时重新触发副作用操作,甚至可以跨越多次重组保持相同的副作用(只被调用一次)。它们将允许我们避免在没有任何控制的情况下直接从Composable的主体调用副作用操作。我们将在后面的章节中详细介绍副作用处理程序。
可重启
我们已经提到过几次,可组合函数可以重新组合,因此它们不像传统的标准函数,在某种意义上,它们不会作为调用堆栈的一部分只被调用一次。
下面是正常调用堆栈的情形。每个函数被调用一次,它可以调用一个或多个其他函数。
另一方面,由于可组合函数可以多次重新启动(重新执行,重新组合),因此runtime会保留对它们的引用。下面是一个可组合调用树的样子:
其中,Composable 4
和Composable 5
在输入改变后被重新执行。
Compose会选择重新启动树中的哪些节点,以保持其内存中的表示状态始终是最新的。可组合函数被设计为响应式的,并能够根据它们观察到的状态变化重新执行。
Compose编译器会找到所有读取某种状态的Composable函数,并生成必要的代码,以便告知runtime该重新启动它们。而那些不读取状态的Composable函数不需要重新启动,因此也就没有必要告诉runtime如何这样做。
快速执行
我们可以将可组合函数和可组合函数树视为一种快速、声明性和轻量级的方法,用于构建程序的描述,该描述将保留在内存中,并在稍后阶段进行解释/物化。
Composable函数不会构建并返回UI。它们只是发出数据来构建或更新内存结构。这使得它们非常快,并允许runtime毫无畏惧地多次执行它们。有时它发生得非常频繁,比如在动画的每一帧中。
开发者在编写代码时必须意识到这一点并尽可能满足这一期望。任何有可能导致较高时间成本的计算操作都应该放到协程中执行,并始终将其包装到一个能感知生命周期的副作用API中。
位置记忆
位置记忆是函数记忆的一种形式。函数记忆是函数根据其输入缓存其结果的能力,以便无需每次为相同的输入调用时都去重新计算结果。正如前面所提到过的,这只可能发生在纯函数 (确定性) 中,因为我们可以确定它们总是对相同的输入返回相同的结果,因此我们才可以缓存和重用该值。
函数记忆是函数式编程范式中广为人知的一种技术,其中程序被定义为纯函数的组合。
在函数记忆中,一个函数调用可以通过其名称、类型和参数值的组合来标识。并且可以使用这些元素创建一个唯一的 Key,用于在以后的调用中存储/索引/读取缓存结果。但在Compose中,还会考虑一个额外的元素:Composable函数对于它们在源代码中的位置具有恒定不变的认知。当使用相同的参数值调用相同的函数但在不同的位置时,runtime 将生成不同的 id
(在父函数中惟一):
内存树将存储它的三个不同实例,每个实例具有不同的 id 标识。
Composable的标识在重新组合时被保留,因此runtime可以根据这个标识来判断之前是否调用了一个Composable,如果可能的话,就可以跳过它。
有时,对Compose runtime
来说,分配唯一标识是很困难的。一个简单的例子就是从一个循环中生成的 Composables 列表:
在这种情况下,每次都从相同的位置调用Talk(talk)
,但是每个Talk
表示列表上的不同项,因此是树上的不同节点。在这种情况下,Compose runtime
依赖于调用的顺序来生成唯一的id
,并且仍然能够区分它们。
当将一个新元素添加到列表的末尾时,这段代码仍然可以正常工作,因为其余的调用保持在与以前相同的位置。但如果我们在顶部或中间的某个位置添加元素呢?Compose runtime
将重新组合该位置以下的所有Talk
,因为它们改变了位置,即使它们的输入没有变化。这是非常低效的(尤其是对于长列表而言),因为这些调用本应该被跳过。
为了解决这个问题,Compose提供了一个用来设置 key
的 Composable,因此我们可以手动为Composable调用分配一个显式的 key
:
在这个例子中,我们使用talk.id
(可能是唯一的)作为每个Talk
的 key
,这将允许runtime保存列表中所有项的标识,而不管它们的位置如何。
位置记忆允许runtime根据设计记住可组合函数。任何由Compose编译器推断为可重新启动的可组合函数也应该是可跳过的,因此会被自动记住。Compose正是构建在这个机制之上。
有时候,开发人员需要以一种比Composable函数范围更细粒度的方式来使用这种内存结构。假设我们想要缓存在Composable函数中发生的繁重计算的结果。Compose runtime 为此提供了 remember
函数:
在这里,我们使用remember
缓存操作的结果来预计算图像的过滤器。索引缓存值的key将基于源码中的调用位置,以及函数输入(在本例中是文件路径)。remember
函数只是一个Composable函数,它知道如何读取和写入保存树状态的内存结构。它只向开发人员公开这种 “位置记忆” 机制。
在Compose中,记忆不是Application级别的。当某个东西被记忆时,它是在调用它的Composable的上下文中完成的。在上面的例子中,它是FilteredImage
。在实践中,Compose将从内存结构中,存储Composable信息的插槽范围内查找缓存的值。这使得它在这个范围内更像一个单例。如果从不同的父类调用相同的Composable,则会返回该值的一个新实例。
与挂起函数的相似之处
Kotlin挂起函数只能从其他挂起函数调用,因此它们也需要调用上下文。这确保了挂起函数只能链接在一起,并给Kotlin编译器提供了一个跨越所有计算级别注入和转发运行时环境的机会。这为每个挂起函数的参数列表末尾添加了一个额外的参数: Continuation
。这个参数也是隐式的,所以开发人员可以不用知道它。Continuation可用于解锁语言中一些新的强大特性。
这跟前面提到的 Compose 编译器所干的事情很相似,不是吗?
在Kotlin协程系统中,Continuation类似于回调。它告诉程序如何继续执行。
例如,如下代码:
它会被Kotlin编译器替换为:
Continuation
包含Kotlin运行时从程序中的不同挂起点挂起和恢复执行所需的所有信息。这使得挂起成为另一个很好的例子,它说明了调用上下文可以作为跨执行树携带隐式信息的一种手段。可在运行时用于启用高级语言特性的信息。
同样,我们也可以将@Composable
理解为一种语言特性。它制定了标准Kotlin函数可重新启动、响应式等。
在这一点上,一个公平的问题是,为什么Jetpack Compose团队没有使用
suspend
来实现他们想要的行为。好吧,即使这两个特性在它们实现的模式上非常相似,但它们都在语言中启用了完全不同的特性。
Continuation接口在挂起和恢复执行方面非常具体,因此它被建模为一个回调接口,Kotlin为它生成一个默认实现,其中包含执行跳转、协调不同挂起点、在它们之间共享数据等所需的所有机制。Compose用例非常不同,因为它的目标是创建一个大型调用图的内存表示,可以在运行时以不同的方式进行优化。
一旦我们理解了可组合函数和挂起函数之间的相似之处,考虑“函数着色”的思想就会很有趣。
Composable 函数的颜色
与标准函数相比,可组合函数具有不同的限制和功能。它们具有不同的类型(稍后将详细介绍),并为非常具体的关注点建模。这种区分可以理解为 函数着色 的一种形式,因为它们在某种程度上代表了一种单独的函数类别。
函数着色是由Google的Dart团队的Bob Nystrom曾经在一篇名为 你的函数是什么颜色? 的博客中所提到的。他解释了异步函数和同步函数为何不能很好地组合在一起,因为你不能从同步函数中调用异步函数,除非你使同步也成为异步的,或者提供一种等待机制,允许调用异步函数并等待它们的结果。这就是为什么Promise
和async/await
被一些库和语言引入的原因。这是一次让可组合性回归的尝试。Bob Nystrom将这两种函数类别称为两种不同的“函数颜色”。
在Kotlin中,suspend
旨在解决同样的问题。但是,挂起函数也是有颜色的,因为我们只能从其他挂起函数中调用挂起函数。使用标准函数和挂起函数组合程序需要一些特别的集成机制(协程启动点)。集成对开发人员来说是不透明的。
总的来说,这种限制是预料之中的。实际上我们正在建模两类函数,它们代表了性质完全不同的概念。就像我们在讨论两种不同的语言一样。我们有两种操作:一种是旨在计算一个立即返回结果的同步操作,另一种是随着时间展开并最终提供结果的异步操作(这可能需要更长的时间才能完成)。
在Jetpack Compose中,可组合函数的情况是等效的。我们不能透明地从标准函数中调用可组合函数。如果我们想这样做,就需要一个集成点(例如:Composition.setContent
)。可组合函数的目标与标准函数完全不同。它们不是用来编写程序逻辑的,而是用来描述节点树的变化。
这看起来可能有点可笑。我们知道可组合函数的一个好处就是你可以使用逻辑来声明UI。这意味着有时我们需要从标准函数中调用可组合函数。例如:
这里 Speaker
Composable是从forEach
的lambda函数里调用的,但是编译器似乎并没有报错。这种混合不同函数颜色的方式是如何做到的呢?
原因是forEach
函数是inline
内联的。集合操作符被声明为inline
的,因此它们将lambdas内联到调用者中,并使其有效,就好像没有额外的间隙一样。在上面的例子中,Speaker
Composable的调用被内联到了SpeakerList
当中,这是允许的,因为两者都是Composable
函数。通过利用内联,我们可以绕过函数着色的问题来编写组合的逻辑。最终我们的树也将只会由可组合函数组成。
但是,函数着色问题真的是一个问题吗?
好吧,这也许会,如果我们需要结合这两种类型的函数并且一直从一种跳到另一种的话。但是,对于suspend
或@Composable
而言,它们都不属于这样的情况。这两种机制都需要一个集成点,因此我们获得了一个超出该点的完全彩色的调用堆栈(包含任何suspend
函数或Composable
函数)。这实际上是一个优势,因为它允许编译器和运行时以不同的方式处理有颜色的函数,并启用一些对于标准函数而言是不可能的更高级的语言特性。
在Kotlin中,suspend
允许以一种非常习惯和富有表现力的方式对异步非阻塞程序建模。该语言能够以极其简单的方式表示非常复杂的概念:在函数中添加 suspend
修饰符。另一方面,@Composable
使标准函数变得可重新启动、可跳过和可响应,这些功能是标准Kotlin函数所不具备的。
Composable 函数的类型
@Composable
注解在编译时有效地改变了函数的类型。从语法的角度来看,Composable函数的类型是@Composable (T) -> A
,其中 A
可以是 Unit
,或任何其他类型(如果函数返回了一个值的话,例如remember
函数)。开发人员可以使用这种类型来声明可组合lambda,就像在Kotlin中声明任何标准lambda一样。
可组合函数也可以有@Composable Scope.() -> A
类型,通常只用于将信息限定在特定的可组合对象的作用域上。例如:
从语言的角度来看,类型的存在是为了向编译器提供信息,以便执行快速的静态验证,有时生成一些方便的代码,并划分/细化在运行时如何使用数据。@Composable
注解改变了函数在运行时的验证和使用方式,这也是为什么它们被认为与普通函数具有不同类型的原因。
总结
-
Composable 函数的含义是在执行时向 Composition 组合发射一个 LayoutNode 节点插入到组合树中。
-
@Composable 注解实际上改变了函数的类型或表达式的类型,Compose runtime 基于此做出运行时的优化,如并行组合、智能重组、位置记忆等。
-
Compose 编译器会为每一个 Composable 函数的参数列表末尾添加一个 Composer 参数,这是在编译器阶段修改 IR 实现的,对开发者不可见。 Composer 的实例会在运行时被注入,并转发给所有的子Composable中,在整棵树中都可以访问。
-
Compose编译器限制规则:Composable 函数只能从其他 Composable 函数调用。原因正是为了添加的 Composer 参数可以被向下转发。
-
Composable 函数应该避免直接执行副作用操作,这会使输入变得不确定性,否则 runtime无法保证能够安全地执行多次进行重组。应该使用Compose提供的副作用API来执行副作用操作。副作用API 使副作用操作能感知到Composable的生命周期。它们可以在Composable从树中挂载时启动,卸载时自动取消。
-
Composable 函数彼此之间没有顺序可言,它们不会按照代码书写的顺序先后执行,而是可能以任意顺序执行,或者并发执行,这由runtime来决定。因此不能依赖它的顺序来写代码逻辑。
-
Compose runtime 持有对 Composable 函数的引用,所以 Composable 函数是可重启的,即可被多次重新执行,这与传统意义的函数调用堆栈不同。
-
位置记忆:Compose runtime 会为每一个 Composable 函数生成一个唯一的 id (key),该 id 包含了Composable在源码中的位置信息,即在不同位置调用的相同参数值的相同函数 id 也不相同。我们可以通过手动调用 key() { } 为 Composable 分配一个显式的 key。
-
Composable 函数与kotlin挂起函数存在着惊人的相似性,比如挂起函数只能在其他挂起函数中调用,Kotlin编译器会为挂起函数注入额外的Continuation参数。
-
函数着色:对于 Kotlin 挂起函数,它是关于异步函数和同步函数的区分,普通函数和挂起函数代表了两种不同的函数颜色。 对于 Composable 函数,它是关于 标准函数 与 可组合函数的区分。
-
我们可以在 Composable 函数内部的一些集合操作API中调用其他 Composable 函数,这并没有违背“Composable 函数只能从其他 Composable 函数调用”的规则,因为这些集合操作API是inline内联的,也就是说,Composable 函数内部的任意内联的函数调用中,都可以直接调用其他 Composable 函数。
-
suspend 函数的目的是挂起和恢复,解决异步和同步函数的组合问题,而 Composable 函数的目的是为了解决可重启、可跳过、可响应问题,它是为了构建或更新树的内存表示状态。这两种机制在实现上都需要一个集成点。