共计 6920 个字符,预计需要花费 18 分钟才能阅读完成。
本文是 Compose 系列的第二篇文章。在 第一篇文章 中,我曾经论述了 Compose 的长处、Compose 所解决的问题、一些设计决策背地的起因,以及这些内容是如何帮忙开发者的。此外,我还探讨了 Compose 的思维模型、您应如何思考应用 Compose 编写代码,以及如何创立您本人的 API。
在本文中,我将着眼于 Compose 背地的工作原理。但在开始之前,我想要强调的是, 应用 Compose 并不一定须要您了解它是如何实现的 。接下来的内容纯正是为了满足您的求知欲而撰写的。
@Composable 注解意味着什么?
如果您曾经理解过 Compose,您大略曾经在一些代码示例中看到过 @Composable 注解。这里有件很重要的事件须要留神—— Compose 并不是一个注解处理器。Compose 在 Kotlin 编译器的类型检测与代码生成阶段依赖 Kotlin 编译器插件工作,所以无需注解处理器即可应用 Compose。
这一注解更靠近于一个语言关键字。作为类比,能够参考 Kotlin 的 suspend 要害 字:
// 函数申明
suspend fun MyFun() { …}
// lambda 申明
val myLambda = suspend {…}
// 函数类型
fun MyFun(myParam: suspend () -> Unit) {…}
Kotlin 的 suspend 关键字 实用于处理函数类型:您能够将函数、lambda 或者函数类型申明为 suspend。Compose 与其工作形式雷同:它能够扭转函数类型。
// 函数申明
@Composable fun MyFun() { …}
// lambda 申明
val myLambda = @Composable {…}
// 函数类型
fun MyFun(myParam: @Composable () -> Unit) {…}
这里的重点是, 当您应用 @Composable 注解一个函数类型时,会导致它类型的扭转 :未被注解的雷同函数类型与注解后的类型互不兼容。同样的,挂起 (suspend) 函数须要调用上下文作为参数,这意味着您只能在其余挂起函数中调用挂起函数:
fun Example(a: () -> Unit, b: suspend () -> Unit) {a() // 容许
b() // 不容许}
suspend
fun Example(a: () -> Unit, b: suspend () -> Unit) {a() // 容许
b() // 容许}
Composable 的工作形式与其雷同。这是因为咱们须要一个贯通所有的上下文调用对象。
fun Example(a: () -> Unit, b: @Composable () -> Unit) {a() // 容许
b() // 不容许}
@Composable
fun Example(a: () -> Unit, b: @Composable () -> Unit) {a() // 容许
b() // 容许}
执行模式
所以,咱们正在传递的调用上下文到底是什么?还有,咱们为什么须要传递它?
咱们将其称之为“Composer”。Composer 的实现蕴含了一个与 Gap Buffer (间隙缓冲区) 密切相关的数据结构,这一数据结构通常利用于文本编辑器。
间隙缓冲区是一个含有以后索引或游标的汇合,它在内存中应用扁平数组 (flat array) 实现。这一扁平数组比它代表的数据汇合要大,而那些没有应用的空间就被称为间隙。
一个正在执行的 Composable 的层级构造能够应用这个数据结构,而且咱们能够在其中插入一些货色。
让咱们假如曾经实现了层级构造的执行。在某个时候,咱们会重新组合一些货色。所以咱们将游标重置回数组的顶部并再次遍历执行。在咱们执行时,能够抉择仅仅查看数据并且什么都不做,或是更新数据的值。
咱们兴许会决定扭转 UI 的构造,并且心愿进行一次插入操作。在这个时候,咱们会把间隙挪动至以后地位。
当初,咱们能够进行插入操作了。
在理解此数据结构时,很重要的一点是除了挪动间隙,它的所有其余操作包含获取 (get)、挪动 (move)、插入 (insert)、删除 (delete) 都是常数工夫操作。挪动间隙的工夫复杂度为 O(n)。咱们抉择这一数据结构是因为 UI 的构造通常不会频繁地扭转。当咱们解决动静 UI 时,它们的值尽管产生了扭转,却通常不会频繁地扭转构造。当它们的确须要扭转构造时,则很可能须要做出大块的改变,此时进行 O(n) 的间隙挪动操作便是一个很正当的衡量。
让咱们来看一个计数器示例:
@Composable
fun Counter() {var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={count += 1}
)
}
这是咱们编写的代码,不过咱们要看的是编译器做了什么。
当编译器看到 Composable 注解时,它会在函数体中插入额定的参数和调用。
首先,编译器会增加一个 composer.start 办法的调用,并向其传递一个编译时生成的整数 key。
fun Counter($composer: Composer) {$composer.start(123)
var count by remember {mutableStateOf(0) }
Button(
text="Count: $count",
onPress={count += 1}
)
$composer.end()}
编译器也会将 composer 对象传递到函数体里的所有 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()}
当此 composer 执行时,它会进行以下操作:
- Composer.start 被调用并存储了一个组对象 (group object)
- remember 插入了一个组对象
- mutableStateOf 的值被返回,而 state 实例会被存储起来
- Button 基于它的每个参数存储了一个分组
最初,当咱们达到 composer.end 时:
数据结构当初曾经持有了来自组合的所有对象,整个树的节点也曾经依照深度优先遍历的执行顺序排列。
当初,所有这些组对象曾经占据了很多的空间,它们为什么要占据这些空间呢?这些组对象是用来治理动静 UI 可能产生的挪动和插入的。编译器晓得哪些代码会扭转 UI 的构造,所以它能够有条件地插入这些分组。大部分状况下,编译器不须要它们,所以它不会向插槽表 (slot table) 中插入过多的分组。为了阐明一这点,请您查看以下条件逻辑:
@Composable fun App() {val result = getData()
if (result == null) {Loading(...)
} else {Header(result)
Body(result)
}
}
在这个 Composable 函数中,getData 函数返回了一些后果并在某个状况下绘制了一个 Loading composable 函数;而在另一个状况下,它绘制了 Header 和 Body 函数。编译器会在 if 语句的每个分支间插入分隔关键字。
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()}
}
让咱们假如这段代码第一次执行的后果是 null。这会使一个分组插入空隙并运行载入界面。
函数第二次执行时,让咱们假如它的后果不再是 null,这样一来第二个分支就会执行。这里便是它变得乏味的中央。
对 composer.start 的调用有一个 key 为 456 的分组。编译器会看到插槽表中 key 为 123 分组与之并不匹配,所以此时它晓得 UI 的构造产生了扭转。
于是编译器将缝隙挪动至以后游标位置并使其在以前 UI 的地位进行扩大,从而无效地打消了旧的 UI。
此时,代码曾经会像个别的状况一样执行,而且新的 UI —— header 和 body —— 也已被插入其中。
在这种状况下,if 语句的开销为插槽表中的单个条目。通过插入单个组,咱们能够在 UI 中任意实现控制流,同时启用编译器对 UI 的治理,使其能够在解决 UI 时利用这品种缓存的数据结构。
这是一种咱们称之为 Positional Memoization 的概念,同时也是自创立伊始便贯通整个 Compose 的概念。
Positional Memoization (地位记忆化)
通常,咱们所说的全局记忆化,指的是编译器基于函数的输出缓存了其后果。上面是一个正在执行计算的函数,咱们用它作为地位记忆化的示例:
@Composable
fun App(items: List<String>, query: String) {val results = items.filter { it.matches(query) }
// ...
}
该函数接管一个字符串列表与一个要查找的字符串,并在接下来对列表进行了过滤计算。咱们能够将该计算包装至对 remember 函数的调用中——remember 函数晓得如何利用插槽列表。remember 函数会查看列表中的字符串,同时也会存储列表并在插槽表中对其进行查问。过滤计算会在之后运行,并且 remember 函数会在后果传回之前对其进行存储。
函数第二次执行时,remember 函数会查看新传入的值并将其与旧值进行比照,如果所有的值都没有产生扭转,过滤操作就会在跳过的同时将之前的后果返回。这便是地位记忆化。
乏味的是,这一操作的开销非常低廉:编译器必须存储一个先前的调用。这一计算能够产生在您的 UI 的各个中央,因为您是基于地位对其进行存储,因而只会为该地位进行存储。
上面是 remember 的函数签名,它能够接管任意多的输出与一个 calculation 函数。
@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T
不过,这里没有输出时会产生一个乏味的进化状况。咱们能够成心误用这一 API,比方记忆一个像 Math.random 这样不输入稳固后果的计算:
@Composable fun App() {val x = remember { Math.random() }
// ...
}
应用全局记忆化来进行这一操作将不会有任何意义,但如果换做应用地位记忆化,此操作将最终呈现出一种新的语义。每当咱们在 Composable 层级中应用 App 函数时,都将会返回一个新的 Math.random 值。不过,每次 Composable 被重新组合时,它将会返回雷同的 Math.random 值。这一个性使得长久化成为可能,而长久化又使得状态成为可能。
存储参数
上面,让咱们用 Google Composable 函数来阐明 Composable 是如何存储函数的参数的。这个函数接管一个数字作为参数,并且通过调用 Address Composable 函数来绘制地址。
@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)
}
Compose 将 Composable 函数的参数存储在插槽表中。在本例中,咱们能够看到一些冗余:Address 调用中增加的“Mountain View”与“CA”会在上面的文本调用被再次存储,所以这些字符串会被存储两次。
咱们能够在编译器级为 Composable 函数增加 static 参数来打消这种冗余。
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"
)
}
本例中,static 参数是一个用于批示运行时是否晓得参数不会扭转的位字段。如果已知一个参数不会扭转,则无需存储该参数。所以这一 Google 函数示例中,编译器传递了一个位字段来示意所有参数都不会产生扭转。
接下来,在 Address 函数中,编译器能够执行雷同的操作并将参数传递给 text。
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)
}
这些位操作逻辑难以浏览且令人困惑,但咱们也没有必要了解它们:编译器擅长于此,而人类则不然。
在 Google 函数的实例中,咱们看到这里不仅有冗余,而且有一些常量。事实证明,咱们也不须要存储它们。这样一来,number 参数便能够决定整个层级,它也是惟一一个须要编译器进行存储的值。
有赖于此,咱们能够更进一步,生成能够了解 number 是惟一一个会产生扭转的值的代码。接下来这段代码能够在 number 没有产生扭转时间接跳过整个函数体,而咱们也能够领导 Composer 将以后索引挪动至函数曾经执行到的地位。
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()
}
}
Composer 晓得快进至须要复原的地位的间隔。
重组
为了解释重组是如何工作的,咱们须要回到计数器的例子:
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()}
编译器为 Counter 函数生成的代码含有一个 composer.start 和一个 compose.end。每当 Counter 执行时,运行时就会了解:当它调用 count.value 时,它会读取一个 appmodel 实例的属性。在运行时,每当咱们调用 compose.end,咱们都能够抉择返回一个值。
$composer.end()?.updateScope { nextComposer ->
Counter(nextComposer)
}
接下来,咱们能够在该返回值上应用 lambda 来调用 updateScope 办法,从而通知运行时在有须要时如何重启以后的 Composable。这一办法等同于 LiveData 接管的 lambda 参数。在这里应用问号的起因——可空的起因——是因为如果咱们在执行 Counter 的过程中不读取任何模型对象,则没有理由通知运行时如何更新它,因为咱们晓得它永远不会更新。
最初
您肯定要记得的重要一点是,这些细节中的绝大部分只是实现细节。与规范的 Kotlin 函数相比,Composable 函数具备不同的行为和性能。有时候了解如何实现非常有用,然而将来 Composable 函数的行为与性能不会扭转,而实现则有可能发生变化。
同样的,Compose 编译器在某些情况下能够生成更为高效的代码。随着工夫流逝,咱们也期待优化这些改良。