本文是 Compose 系列的第二篇文章。在 第一篇文章 中熏版,我已經(jīng)闡述了 Compose 的優(yōu)點(diǎn)哺窄、Compose 所解決的問題暇检、一些設(shè)計(jì)決策背后的原因吓蘑,以及這些內(nèi)容是如何幫助開發(fā)者的悼潭。此外,我還討論了 Compose 的思維模型义屏、您應(yīng)如何考慮使用 Compose 編寫代碼,以及如何創(chuàng)建您自己的 API。
在本文中县爬,我將著眼于 Compose 背后的工作原理。但在開始之前财喳,我想要強(qiáng)調(diào)的是察迟,使用 Compose 并不一定需要您理解它是如何實(shí)現(xiàn)的耳高。接下來的內(nèi)容純粹是為了滿足您的求知欲而撰寫的。
@Composable 注解意味著什么泌枪?
如果您已經(jīng)了解過 Compose概荷,您大概已經(jīng)在一些代碼示例中看到過 @Composable 注解。這里有件很重要的事情需要注意—— Compose 并不是一個(gè)注解處理器碌燕。Compose 在 Kotlin 編譯器的類型檢測與代碼生成階段依賴 Kotlin 編譯器插件工作修壕,所以無需注解處理器即可使用 Compose。
這一注解更接近于一個(gè)語言關(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) { … }
這里的重點(diǎn)是烟零,當(dāng)您使用 @Composable 注解一個(gè)函數(shù)類型時(shí)锨阿,會(huì)導(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 的工作方式與其相同。這是因?yàn)槲覀冃枰粋€(gè)貫穿所有的上下文調(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 的實(shí)現(xiàn)包含了一個(gè)與 Gap Buffer (間隙緩沖區(qū)) 密切相關(guān)的數(shù)據(jù)結(jié)構(gòu)氮块,這一數(shù)據(jù)結(jié)構(gòu)通常應(yīng)用于文本編輯器。
間隙緩沖區(qū)是一個(gè)含有當(dāng)前索引或游標(biāo)的集合逛钻,它在內(nèi)存中使用扁平數(shù)組 (flat array) 實(shí)現(xiàn)锰提。這一扁平數(shù)組比它代表的數(shù)據(jù)集合要大,而那些沒有使用的空間就被稱為間隙边坤。
一個(gè)正在執(zhí)行的 Composable 的層級結(jié)構(gòu)可以使用這個(gè)數(shù)據(jù)結(jié)構(gòu)茧痒,而且我們可以在其中插入一些東西。
讓我們假設(shè)已經(jīng)完成了層級結(jié)構(gòu)的執(zhí)行弄企。在某個(gè)時(shí)候拘领,我們會(huì)重新組合一些東西樱调。所以我們將游標(biāo)重置回?cái)?shù)組的頂部并再次遍歷執(zhí)行。在我們執(zhí)行時(shí)圣猎,可以選擇僅僅查看數(shù)據(jù)并且什么都不做乞而,或是更新數(shù)據(jù)的值。
我們也許會(huì)決定改變 UI 的結(jié)構(gòu),并且希望進(jìn)行一次插入操作呻右。在這個(gè)時(shí)候鞋喇,我們會(huì)把間隙移動(dòng)至當(dāng)前位置侦香。
現(xiàn)在,我們可以進(jìn)行插入操作了憾赁。
在了解此數(shù)據(jù)結(jié)構(gòu)時(shí)龙考,很重要的一點(diǎn)是除了移動(dòng)間隙,它的所有其他操作包括獲取 (get)炎功、移動(dòng) (move) 缓溅、插入 (insert) 、刪除 (delete) 都是常數(shù)時(shí)間操作淤齐。移動(dòng)間隙的時(shí)間復(fù)雜度為 O(n)酝陈。我們選擇這一數(shù)據(jù)結(jié)構(gòu)是因?yàn)?UI 的結(jié)構(gòu)通常不會(huì)頻繁地改變沉帮。當(dāng)我們處理動(dòng)態(tài) UI 時(shí),它們的值雖然發(fā)生了改變穆壕,卻通常不會(huì)頻繁地改變結(jié)構(gòu)喇勋。當(dāng)它們確實(shí)需要改變結(jié)構(gòu)時(shí),則很可能需要做出大塊的改動(dòng)贰拿,此時(shí)進(jìn)行 O(n) 的間隙移動(dòng)操作便是一個(gè)很合理的權(quán)衡熄云。
讓我們來看一個(gè)計(jì)數(shù)器示例:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
}
這是我們編寫的代碼缴允,不過我們要看的是編譯器做了什么。
當(dāng)編譯器看到 Composable 注解時(shí)矗漾,它會(huì)在函數(shù)體中插入額外的參數(shù)和調(diào)用薄料。
首先,編譯器會(huì)添加一個(gè) composer.start 方法的調(diào)用嫡锌,并向其傳遞一個(gè)編譯時(shí)生成的整數(shù) key。
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
$composer.end()
}
編譯器也會(huì)將 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í)行時(shí)啦桌,它會(huì)進(jìn)行以下操作:
- Composer.start 被調(diào)用并存儲了一個(gè)組對象 (group object)
- remember 插入了一個(gè)組對象
- mutableStateOf 的值被返回甫男,而 state 實(shí)例會(huì)被存儲起來
- Button 基于它的每個(gè)參數(shù)存儲了一個(gè)分組
最后,當(dāng)我們到達(dá) composer.end 時(shí):
數(shù)據(jù)結(jié)構(gòu)現(xiàn)在已經(jīng)持有了來自組合的所有對象又跛,整個(gè)樹的節(jié)點(diǎn)也已經(jīng)按照深度優(yōu)先遍歷的執(zhí)行順序排列慨蓝。
現(xiàn)在端幼,所有這些組對象已經(jīng)占據(jù)了很多的空間,它們?yōu)槭裁匆紦?jù)這些空間呢此熬?這些組對象是用來管理動(dòng)態(tài) UI 可能發(fā)生的移動(dòng)和插入的滑进。編譯器知道哪些代碼會(huì)改變 UI 的結(jié)構(gòu),所以它可以有條件地插入這些分組阴汇。大部分情況下驮审,編譯器不需要它們疯淫,所以它不會(huì)向插槽表 (slot table) 中插入過多的分組戳玫。為了說明一這點(diǎn),請您查看以下條件邏輯:
@Composable fun App() {
val result = getData()
if (result == null) {
Loading(...)
} else {
Header(result)
Body(result)
}
}
在這個(gè) Composable 函數(shù)中币绩,getData 函數(shù)返回了一些結(jié)果并在某個(gè)情況下繪制了一個(gè) Loading composable 函數(shù)缆镣;而在另一個(gè)情況下,它繪制了 Header 和 Body 函數(shù)董瞻。編譯器會(huì)在 if 語句的每個(gè)分支間插入分隔關(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。這會(huì)使一個(gè)分組插入空隙并運(yùn)行載入界面艘刚。
函數(shù)第二次執(zhí)行時(shí)攀甚,讓我們假設(shè)它的結(jié)果不再是 null笛臣,這樣一來第二個(gè)分支就會(huì)執(zhí)行。這里便是它變得有趣的地方静陈。
對 composer.start 的調(diào)用有一個(gè) key 為 456 的分組诞丽。編譯器會(huì)看到插槽表中 key 為 123 分組與之并不匹配僧免,所以此時(shí)它知道 UI 的結(jié)構(gòu)發(fā)生了改變。
于是編譯器將縫隙移動(dòng)至當(dāng)前游標(biāo)位置并使其在以前 UI 的位置進(jìn)行擴(kuò)展撞叨,從而有效地消除了舊的 UI浊洞。
此時(shí),代碼已經(jīng)會(huì)像一般的情況一樣執(zhí)行枷餐,而且新的 UI —— header 和 body —— 也已被插入其中苫亦。
在這種情況下,if 語句的開銷為插槽表中的單個(gè)條目诗眨。通過插入單個(gè)組趁桃,我們可以在 UI 中任意實(shí)現(xiàn)控制流,同時(shí)啟用編譯器對 UI 的管理油啤,使其可以在處理 UI 時(shí)利用這種類緩存的數(shù)據(jù)結(jié)構(gòu)益咬。
這是一種我們稱之為 Positional Memoization 的概念帜平,同時(shí)也是自創(chuàng)建伊始便貫穿整個(gè) Compose 的概念。
Positional Memoization (位置記憶化)
通常冗锁,我們所說的全局記憶化嗤栓,指的是編譯器基于函數(shù)的輸入緩存了其結(jié)果。下面是一個(gè)正在執(zhí)行計(jì)算的函數(shù)叨叙,我們用它作為位置記憶化的示例:
@Composable
fun App(items: List<String>, query: String) {
val results = items.filter { it.matches(query) }
// ...
}
該函數(shù)接收一個(gè)字符串列表與一個(gè)要查找的字符串擂错,并在接下來對列表進(jìn)行了過濾計(jì)算樱蛤。我們可以將該計(jì)算包裝至對 remember 函數(shù)的調(diào)用中——remember 函數(shù)知道如何利用插槽列表。remember 函數(shù)會(huì)查看列表中的字符串爽醋,同時(shí)也會(huì)存儲列表并在插槽表中對其進(jìn)行查詢土匀。過濾計(jì)算會(huì)在之后運(yùn)行就轧,并且 remember 函數(shù)會(huì)在結(jié)果傳回之前對其進(jìn)行存儲。
函數(shù)第二次執(zhí)行時(shí)解愤,remember 函數(shù)會(huì)查看新傳入的值并將其與舊值進(jìn)行對比乎莉,如果所有的值都沒有發(fā)生改變惋啃,過濾操作就會(huì)在跳過的同時(shí)將之前的結(jié)果返回。這便是位置記憶化异希。
有趣的是绒瘦,這一操作的開銷十分低廉:編譯器必須存儲一個(gè)先前的調(diào)用。這一計(jì)算可以發(fā)生在您的 UI 的各個(gè)地方憨降,由于您是基于位置對其進(jìn)行存儲该酗,因此只會(huì)為該位置進(jìn)行存儲垂涯。
下面是 remember 的函數(shù)簽名,它可以接收任意多的輸入與一個(gè) calculation 函數(shù)骄蝇。
@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T
不過操骡,這里沒有輸入時(shí)會(huì)產(chǎn)生一個(gè)有趣的退化情況册招。我們可以故意誤用這一 API,比如記憶一個(gè)像 Math.random 這樣不輸出穩(wěn)定結(jié)果的計(jì)算:
@Composable fun App() {
val x = remember { Math.random() }
// ...
}
使用全局記憶化來進(jìn)行這一操作將不會(huì)有任何意義虑鼎,但如果換做使用位置記憶化,此操作將最終呈現(xiàn)出一種新的語義匾七。每當(dāng)我們在 Composable 層級中使用 App 函數(shù)時(shí)江兢,都將會(huì)返回一個(gè)新的 Math.random 值杉允。不過,每次 Composable 被重新組合時(shí)拢驾,它將會(huì)返回相同的 Math.random 值改基。這一特性使得持久化成為可能,而持久化又使得狀態(tài)成為可能嵌洼。
存儲參數(shù)
下面封恰,讓我們用 Google Composable 函數(shù)來說明 Composable 是如何存儲函數(shù)的參數(shù)的诺舔。這個(gè)函數(shù)接收一個(gè)數(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” 會(huì)在下面的文本調(diào)用被再次存儲尿背,所以這些字符串會(huì)被存儲兩次。
我們可以在編譯器級為 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ù)是一個(gè)用于指示運(yùn)行時(shí)是否知道參數(shù)不會(huì)改變的位字段陪捷。如果已知一個(gè)參數(shù)不會(huì)改變诺擅,則無需存儲該參數(shù)烁涌。所以這一 Google 函數(shù)示例中酒觅,編譯器傳遞了一個(gè)位字段來表示所有參數(shù)都不會(huì)發(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ù)的實(shí)例中舱馅,我們看到這里不僅有冗余代嗤,而且有一些常量。事實(shí)證明武翎,我們也不需要存儲它們溶锭。這樣一來,number 參數(shù)便可以決定整個(gè)層級垫毙,它也是唯一一個(gè)需要編譯器進(jìn)行存儲的值综芥。
有賴于此,我們可以更進(jìn)一步屠阻,生成可以理解 number 是唯一一個(gè)會(huì)發(fā)生改變的值的代碼额各。接下來這段代碼可以在 number 沒有發(fā)生改變時(shí)直接跳過整個(gè)函數(shù)體虾啦,而我們也可以指導(dǎo) Composer 將當(dāng)前索引移動(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 知道快進(jìn)至需要恢復(fù)的位置的距離蝇闭。
重組
為了解釋重組是如何工作的丁眼,我們需要回到計(jì)數(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ù)生成的代碼含有一個(gè) composer.start 和一個(gè) compose.end昭殉。每當(dāng) Counter 執(zhí)行時(shí)挪丢,運(yùn)行時(shí)就會(huì)理解:當(dāng)它調(diào)用 count.value 時(shí),它會(huì)讀取一個(gè) appmodel 實(shí)例的屬性惠啄。在運(yùn)行時(shí)任内,每當(dāng)我們調(diào)用 compose.end死嗦,我們都可以選擇返回一個(gè)值。
$composer.end()?.updateScope { nextComposer ->
Counter(nextComposer)
}
接下來节腐,我們可以在該返回值上使用 lambda 來調(diào)用 updateScope 方法翼雀,從而告訴運(yùn)行時(shí)在有需要時(shí)如何重啟當(dāng)前的 Composable。這一方法等同于 LiveData 接收的 lambda 參數(shù)箱熬。在這里使用問號的原因——可空的原因——是因?yàn)槿绻覀冊趫?zhí)行 Counter 的過程中不讀取任何模型對象狈邑,則沒有理由告訴運(yùn)行時(shí)如何更新它官地,因?yàn)槲覀冎浪肋h(yuǎn)不會(huì)更新烙懦。
最后
您一定要記得的重要一點(diǎn)是氯析,這些細(xì)節(jié)中的絕大部分只是實(shí)現(xiàn)細(xì)節(jié)。與標(biāo)準(zhǔn)的 Kotlin 函數(shù)相比雪情, Composable 函數(shù)具有不同的行為和功能巡通。有時(shí)候理解如何實(shí)現(xiàn)十分有用舍哄,但是未來 Composable 函數(shù)的行為與功能不會(huì)改變,而實(shí)現(xiàn)則有可能發(fā)生變化弥锄。
同樣的蟆沫,Compose 編譯器在某些狀況下可以生成更為高效的代碼饭庞。隨著時(shí)間流逝,我們也期待優(yōu)化這些改進(jìn)救崔。