本文是 Compose 系列的第二篇文章。在 第一篇文章 中,我已經(jīng)闡述了 Compose 的優(yōu)點、Compose 所解決的問題、一些設(shè)計決策背后的原因,以及這些內(nèi)容是如何幫助開發(fā)者的。此外,我還討論了 Compose 的思維模型、您應(yīng)如何考慮使用 Compose 編寫代碼,以及如何創(chuàng)建您自己的 API。
在本文中,我將著眼于 Compose 背后的工作原理。但在開始之前,我想要強調(diào)的是,使用 Compose 并不一定需要您理解它是如何實現(xiàn)的。接下來的內(nèi)容純粹是為了滿足您的求知欲而撰寫的。
@Composable 注解意味著什么?
如果您已經(jīng)了解過 Compose,您大概已經(jīng)在一些代碼示例中看到過 @Composable 注解。這里有件很重要的事情需要注意—— Compose 并不是一個注解處理器。Compose 在 Kotlin 編譯器的類型檢測與代碼生成階段依賴 Kotlin 編譯器插件工作,所以無需注解處理器即可使用 Compose。
這一注解更接近于一個語言關(guān)鍵字。作為類比,可以參考 Kotlin 的 suspend 關(guān)鍵 字:
// 函數(shù)聲明
suspend fun MyFun() { … }
// lambda 聲明
val myLambda = suspend { … }
// 函數(shù)類型
fun MyFun(myParam: suspend () -> Unit) { … }
Kotlin 的 suspend 關(guān)鍵字 適用于處理函數(shù)類型:您可以將函數(shù)、lambda 或者函數(shù)類型聲明為 suspend。Compose 與其工作方式相同:它可以改變函數(shù)類型。
// 函數(shù)聲明
@Composable fun MyFun() { … }
// lambda 聲明
val myLambda = @Composable { … }
// 函數(shù)類型
fun MyFun(myParam: @Composable () -> Unit) { … }
這里的重點是,當(dāng)您使用 @Composable 注解一個函數(shù)類型時,會導(dǎo)致它類型的改變:未被注解的相同函數(shù)類型與注解后的類型互不兼容。同樣的,掛起 (suspend) 函數(shù)需要調(diào)用上下文作為參數(shù),這意味著您只能在其他掛起函數(shù)中調(diào)用掛起函數(shù):
fun Example(a: () -> Unit, b: suspend () -> Unit) {
a() // 允許
b() // 不允許
}
suspend
fun Example(a: () -> Unit, b: suspend () -> Unit) {
a() // 允許
b() // 允許
}
Composable 的工作方式與其相同。這是因為我們需要一個貫穿所有的上下文調(diào)用對象。
fun Example(a: () -> Unit, b: @Composable () -> Unit) {
a() // 允許
b() // 不允許
}
@Composable
fun Example(a: () -> Unit, b: @Composable () -> Unit) {
a() // 允許
b() // 允許
}
執(zhí)行模式
所以,我們正在傳遞的調(diào)用上下文究竟是什么?還有,我們?yōu)槭裁葱枰獋鬟f它?
我們將其稱之為 “Composer”。Composer 的實現(xiàn)包含了一個與 Gap Buffer (間隙緩沖區(qū)) 密切相關(guān)的數(shù)據(jù)結(jié)構(gòu),這一數(shù)據(jù)結(jié)構(gòu)通常應(yīng)用于文本編輯器。
間隙緩沖區(qū)是一個含有當(dāng)前索引或游標(biāo)的集合,它在內(nèi)存中使用扁平數(shù)組 (flat array) 實現(xiàn)。這一扁平數(shù)組比它代表的數(shù)據(jù)集合要大,而那些沒有使用的空間就被稱為間隙。
一個正在執(zhí)行的 Composable 的層級結(jié)構(gòu)可以使用這個數(shù)據(jù)結(jié)構(gòu),而且我們可以在其中插入一些東西。
讓我們假設(shè)已經(jīng)完成了層級結(jié)構(gòu)的執(zhí)行。在某個時候,我們會重新組合一些東西。所以我們將游標(biāo)重置回數(shù)組的頂部并再次遍歷執(zhí)行。在我們執(zhí)行時,可以選擇僅僅查看數(shù)據(jù)并且什么都不做,或是更新數(shù)據(jù)的值。
我們也許會決定改變 UI 的結(jié)構(gòu),并且希望進行一次插入操作。在這個時候,我們會把間隙移動至當(dāng)前位置。
現(xiàn)在,我們可以進行插入操作了。
在了解此數(shù)據(jù)結(jié)構(gòu)時,很重要的一點是除了移動間隙,它的所有其他操作包括獲取 (get)、移動 (move) 、插入 (insert) 、刪除 (delete) 都是常數(shù)時間操作。移動間隙的時間復(fù)雜度為 O(n)。我們選擇這一數(shù)據(jù)結(jié)構(gòu)是因為 UI 的結(jié)構(gòu)通常不會頻繁地改變。當(dāng)我們處理動態(tài) UI 時,它們的值雖然發(fā)生了改變,卻通常不會頻繁地改變結(jié)構(gòu)。當(dāng)它們確實需要改變結(jié)構(gòu)時,則很可能需要做出大塊的改動,此時進行 O(n) 的間隙移動操作便是一個很合理的權(quán)衡。
讓我們來看一個計數(shù)器示例:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
}
這是我們編寫的代碼,不過我們要看的是編譯器做了什么。
當(dāng)編譯器看到 Composable 注解時,它會在函數(shù)體中插入額外的參數(shù)和調(diào)用。
首先,編譯器會添加一個 composer.start 方法的調(diào)用,并向其傳遞一個編譯時生成的整數(shù) key。
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
$composer.end()
}
編譯器也會將 composer 對象傳遞到函數(shù)體里的所有 composable 調(diào)用中。
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember($composer) { mutableStateOf(0) }
Button(
$composer,
text="Count: $count",
onPress={ count += 1 },
)
$composer.end()
}
當(dāng)此 composer 執(zhí)行時,它會進行以下操作:
- Composer.start 被調(diào)用并存儲了一個組對象 (group object)
- remember 插入了一個組對象
- mutableStateOf 的值被返回,而 state 實例會被存儲起來
- Button 基于它的每個參數(shù)存儲了一個分組
最后,當(dāng)我們到達(dá) composer.end 時:
數(shù)據(jù)結(jié)構(gòu)現(xiàn)在已經(jīng)持有了來自組合的所有對象,整個樹的節(jié)點也已經(jīng)按照深度優(yōu)先遍歷的執(zhí)行順序排列。
現(xiàn)在,所有這些組對象已經(jīng)占據(jù)了很多的空間,它們?yōu)槭裁匆紦?jù)這些空間呢?這些組對象是用來管理動態(tài) UI 可能發(fā)生的移動和插入的。編譯器知道哪些代碼會改變 UI 的結(jié)構(gòu),所以它可以有條件地插入這些分組。大部分情況下,編譯器不需要它們,所以它不會向插槽表 (slot table) 中插入過多的分組。為了說明一這點,請您查看以下條件邏輯:
@Composable fun App() {
val result = getData()
if (result == null) {
Loading(...)
} else {
Header(result)
Body(result)
}
}
在這個 Composable 函數(shù)中,getData 函數(shù)返回了一些結(jié)果并在某個情況下繪制了一個 Loading composable 函數(shù);而在另一個情況下,它繪制了 Header 和 Body 函數(shù)。編譯器會在 if 語句的每個分支間插入分隔關(guān)鍵字。
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()
}
}
讓我們假設(shè)這段代碼第一次執(zhí)行的結(jié)果是 null。這會使一個分組插入空隙并運行載入界面。
函數(shù)第二次執(zhí)行時,讓我們假設(shè)它的結(jié)果不再是 null,這樣一來第二個分支就會執(zhí)行。這里便是它變得有趣的地方。
對 composer.start 的調(diào)用有一個 key 為 456 的分組。編譯器會看到插槽表中 key 為 123 分組與之并不匹配,所以此時它知道 UI 的結(jié)構(gòu)發(fā)生了改變。
于是編譯器將縫隙移動至當(dāng)前游標(biāo)位置并使其在以前 UI 的位置進行擴展,從而有效地消除了舊的 UI。
此時,代碼已經(jīng)會像一般的情況一樣執(zhí)行,而且新的 UI —— header 和 body —— 也已被插入其中。
在這種情況下,if 語句的開銷為插槽表中的單個條目。通過插入單個組,我們可以在 UI 中任意實現(xiàn)控制流,同時啟用編譯器對 UI 的管理,使其可以在處理 UI 時利用這種類緩存的數(shù)據(jù)結(jié)構(gòu)。
這是一種我們稱之為 Positional Memoization 的概念,同時也是自創(chuàng)建伊始便貫穿整個 Compose 的概念。
Positional Memoization (位置記憶化)
通常,我們所說的全局記憶化,指的是編譯器基于函數(shù)的輸入緩存了其結(jié)果。下面是一個正在執(zhí)行計算的函數(shù),我們用它作為位置記憶化的示例:
@Composable
fun App(items: List<String>, query: String) {
val results = items.filter { it.matches(query) }
// ...
}
該函數(shù)接收一個字符串列表與一個要查找的字符串,并在接下來對列表進行了過濾計算。我們可以將該計算包裝至對 remember 函數(shù)的調(diào)用中——remember 函數(shù)知道如何利用插槽列表。remember 函數(shù)會查看列表中的字符串,同時也會存儲列表并在插槽表中對其進行查詢。過濾計算會在之后運行,并且 remember 函數(shù)會在結(jié)果傳回之前對其進行存儲。
函數(shù)第二次執(zhí)行時,remember 函數(shù)會查看新傳入的值并將其與舊值進行對比,如果所有的值都沒有發(fā)生改變,過濾操作就會在跳過的同時將之前的結(jié)果返回。這便是位置記憶化。
有趣的是,這一操作的開銷十分低廉:編譯器必須存儲一個先前的調(diào)用。這一計算可以發(fā)生在您的 UI 的各個地方,由于您是基于位置對其進行存儲,因此只會為該位置進行存儲。
下面是 remember 的函數(shù)簽名,它可以接收任意多的輸入與一個 calculation 函數(shù)。
@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T
不過,這里沒有輸入時會產(chǎn)生一個有趣的退化情況。我們可以故意誤用這一 API,比如記憶一個像 Math.random 這樣不輸出穩(wěn)定結(jié)果的計算:
@Composable fun App() {
val x = remember { Math.random() }
// ...
}
使用全局記憶化來進行這一操作將不會有任何意義,但如果換做使用位置記憶化,此操作將最終呈現(xiàn)出一種新的語義。每當(dāng)我們在 Composable 層級中使用 App 函數(shù)時,都將會返回一個新的 Math.random 值。不過,每次 Composable 被重新組合時,它將會返回相同的 Math.random 值。這一特性使得持久化成為可能,而持久化又使得狀態(tài)成為可能。
存儲參數(shù)
下面,讓我們用 Google Composable 函數(shù)來說明 Composable 是如何存儲函數(shù)的參數(shù)的。這個函數(shù)接收一個數(shù)字作為參數(shù),并且通過調(diào)用 Address Composable 函數(shù)來繪制地址。
@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 函數(shù)的參數(shù)存儲在插槽表中。在本例中,我們可以看到一些冗余:Address 調(diào)用中添加的 “Mountain View” 與 “CA” 會在下面的文本調(diào)用被再次存儲,所以這些字符串會被存儲兩次。
我們可以在編譯器級為 Composable 函數(shù)添加 static 參數(shù)來消除這種冗余。
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 參數(shù)是一個用于指示運行時是否知道參數(shù)不會改變的位字段。如果已知一個參數(shù)不會改變,則無需存儲該參數(shù)。所以這一 Google 函數(shù)示例中,編譯器傳遞了一個位字段來表示所有參數(shù)都不會發(fā)生改變。
接下來,在 Address 函數(shù)中,編譯器可以執(zhí)行相同的操作并將參數(shù)傳遞給 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 函數(shù)的實例中,我們看到這里不僅有冗余,而且有一些常量。事實證明,我們也不需要存儲它們。這樣一來,number 參數(shù)便可以決定整個層級,它也是唯一一個需要編譯器進行存儲的值。
有賴于此,我們可以更進一步,生成可以理解 number 是唯一一個會發(fā)生改變的值的代碼。接下來這段代碼可以在 number 沒有發(fā)生改變時直接跳過整個函數(shù)體,而我們也可以指導(dǎo) Composer 將當(dāng)前索引移動至函數(shù)已經(jīng)執(zhí)行到的位置。
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 知道快進至需要恢復(fù)的位置的距離。
重組
為了解釋重組是如何工作的,我們需要回到計數(shù)器的例子:
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 函數(shù)生成的代碼含有一個 composer.start 和一個 compose.end。每當(dāng) Counter 執(zhí)行時,運行時就會理解:當(dāng)它調(diào)用 count.value 時,它會讀取一個 appmodel 實例的屬性。在運行時,每當(dāng)我們調(diào)用 compose.end,我們都可以選擇返回一個值。
$composer.end()?.updateScope { nextComposer ->
Counter(nextComposer)
}
接下來,我們可以在該返回值上使用 lambda 來調(diào)用 updateScope 方法,從而告訴運行時在有需要時如何重啟當(dāng)前的 Composable。這一方法等同于 LiveData 接收的 lambda 參數(shù)。在這里使用問號的原因——可空的原因——是因為如果我們在執(zhí)行 Counter 的過程中不讀取任何模型對象,則沒有理由告訴運行時如何更新它,因為我們知道它永遠(yuǎn)不會更新。
最后
您一定要記得的重要一點是,這些細(xì)節(jié)中的絕大部分只是實現(xiàn)細(xì)節(jié)。與標(biāo)準(zhǔn)的 Kotlin 函數(shù)相比, Composable 函數(shù)具有不同的行為和功能。有時候理解如何實現(xiàn)十分有用,但是未來 Composable 函數(shù)的行為與功能不會改變,而實現(xiàn)則有可能發(fā)生變化。
同樣的,Compose 編譯器在某些狀況下可以生成更為高效的代碼。隨著時間流逝,我們也期待優(yōu)化這些改進。