深入詳解 Jetpack Compose | 實(shí)現(xiàn)原理

image

本文是 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)的。接下來(lái)的內(nèi)容純粹是為了滿足您的求知欲而撰寫的。

@Composable 注解意味著什么?

如果您已經(jīng)了解過(guò) Compose,您大概已經(jīng)在一些代碼示例中看到過(guò) @Composable 注解。這里有件很重要的事情需要注意—— Compose 并不是一個(gè)注解處理器。Compose 在 Kotlin 編譯器的類型檢測(cè)與代碼生成階段依賴 Kotlin 編譯器插件工作,所以無(wú)需注解處理器即可使用 Compose。

這一注解更接近于一個(gè)語(yǔ)言關(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)用對(duì)象。

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ù)集合要大,而那些沒有使用的空間就被稱為間隙。

image

一個(gè)正在執(zhí)行的 Composable 的層級(jí)結(jié)構(gòu)可以使用這個(gè)數(shù)據(jù)結(jié)構(gòu),而且我們可以在其中插入一些東西。

image

讓我們假設(shè)已經(jīng)完成了層級(jí)結(jié)構(gòu)的執(zhí)行。在某個(gè)時(shí)候,我們會(huì)重新組合一些東西。所以我們將游標(biāo)重置回?cái)?shù)組的頂部并再次遍歷執(zhí)行。在我們執(zhí)行時(shí),可以選擇僅僅查看數(shù)據(jù)并且什么都不做,或是更新數(shù)據(jù)的值。

image

我們也許會(huì)決定改變 UI 的結(jié)構(gòu),并且希望進(jìn)行一次插入操作。在這個(gè)時(shí)候,我們會(huì)把間隙移動(dòng)至當(dāng)前位置。

image

現(xiàn)在,我們可以進(jìn)行插入操作了。

image

在了解此數(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)衡。

讓我們來(lái)看一個(gè)計(jì)數(shù)器示例:

@Composable
fun Counter() {
 var count by remember { mutableStateOf(0) }
 Button(
   text="Count: $count",
   onPress={ count += 1 }
 )
}

這是我們編寫的代碼,不過(guò)我們要看的是編譯器做了什么。

當(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 對(duì)象傳遞到函數(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)用并存儲(chǔ)了一個(gè)組對(duì)象 (group object)
  • remember 插入了一個(gè)組對(duì)象
  • mutableStateOf 的值被返回,而 state 實(shí)例會(huì)被存儲(chǔ)起來(lái)
  • Button 基于它的每個(gè)參數(shù)存儲(chǔ)了一個(gè)分組

最后,當(dāng)我們到達(dá) composer.end 時(shí):

image

數(shù)據(jù)結(jié)構(gòu)現(xiàn)在已經(jīng)持有了來(lái)自組合的所有對(duì)象,整個(gè)樹的節(jié)點(diǎn)也已經(jīng)按照深度優(yōu)先遍歷的執(zhí)行順序排列。

現(xiàn)在,所有這些組對(duì)象已經(jīng)占據(jù)了很多的空間,它們?yōu)槭裁匆紦?jù)這些空間呢?這些組對(duì)象是用來(lái)管理動(dòng)態(tài) UI 可能發(fā)生的移動(dòng)和插入的。編譯器知道哪些代碼會(huì)改變 UI 的結(jié)構(gòu),所以它可以有條件地插入這些分組。大部分情況下,編譯器不需要它們,所以它不會(huì)向插槽表 (slot table) 中插入過(guò)多的分組。為了說(shuō)明一這點(diǎn),請(qǐng)您查看以下條件邏輯:

@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 語(yǔ)句的每個(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)行載入界面。

image

函數(shù)第二次執(zhí)行時(shí),讓我們假設(shè)它的結(jié)果不再是 null,這樣一來(lái)第二個(gè)分支就會(huì)執(zhí)行。這里便是它變得有趣的地方。

對(duì) 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 —— 也已被插入其中。

image

在這種情況下,if 語(yǔ)句的開銷為插槽表中的單個(gè)條目。通過(guò)插入單個(gè)組,我們可以在 UI 中任意實(shí)現(xiàn)控制流,同時(shí)啟用編譯器對(duì) UI 的管理,使其可以在處理 UI 時(shí)利用這種類緩存的數(shù)據(jù)結(jié)構(gòu)。

這是一種我們稱之為 Positional Memoization 的概念,同時(shí)也是自創(chuàng)建伊始便貫穿整個(gè) Compose 的概念。

Positional Memoization (位置記憶化)

通常,我們所說(shuō)的全局記憶化,指的是編譯器基于函數(shù)的輸入緩存了其結(jié)果。下面是一個(gè)正在執(zhí)行計(jì)算的函數(shù),我們用它作為位置記憶化的示例:

@Composable
fun App(items: List<String>, query: String) {
 val results = items.filter { it.matches(query) }
 // ...
}

該函數(shù)接收一個(gè)字符串列表與一個(gè)要查找的字符串,并在接下來(lái)對(duì)列表進(jìn)行了過(guò)濾計(jì)算。我們可以將該計(jì)算包裝至對(duì) remember 函數(shù)的調(diào)用中——remember 函數(shù)知道如何利用插槽列表。remember 函數(shù)會(huì)查看列表中的字符串,同時(shí)也會(huì)存儲(chǔ)列表并在插槽表中對(duì)其進(jìn)行查詢。過(guò)濾計(jì)算會(huì)在之后運(yùn)行,并且 remember 函數(shù)會(huì)在結(jié)果傳回之前對(duì)其進(jìn)行存儲(chǔ)。

函數(shù)第二次執(zhí)行時(shí),remember 函數(shù)會(huì)查看新傳入的值并將其與舊值進(jìn)行對(duì)比,如果所有的值都沒有發(fā)生改變,過(guò)濾操作就會(huì)在跳過(guò)的同時(shí)將之前的結(jié)果返回。這便是位置記憶化。

有趣的是,這一操作的開銷十分低廉:編譯器必須存儲(chǔ)一個(gè)先前的調(diào)用。這一計(jì)算可以發(fā)生在您的 UI 的各個(gè)地方,由于您是基于位置對(duì)其進(jìn)行存儲(chǔ),因此只會(huì)為該位置進(jìn)行存儲(chǔ)。

下面是 remember 的函數(shù)簽名,它可以接收任意多的輸入與一個(gè) calculation 函數(shù)。

@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T

不過(guò),這里沒有輸入時(shí)會(huì)產(chǎn)生一個(gè)有趣的退化情況。我們可以故意誤用這一 API,比如記憶一個(gè)像 Math.random 這樣不輸出穩(wěn)定結(jié)果的計(jì)算:

@Composable fun App() {
 val x = remember { Math.random() }
 // ...
}

使用全局記憶化來(lái)進(jìn)行這一操作將不會(huì)有任何意義,但如果換做使用位置記憶化,此操作將最終呈現(xiàn)出一種新的語(yǔ)義。每當(dāng)我們?cè)?Composable 層級(jí)中使用 App 函數(shù)時(shí),都將會(huì)返回一個(gè)新的 Math.random 值。不過(guò),每次 Composable 被重新組合時(shí),它將會(huì)返回相同的 Math.random 值。這一特性使得持久化成為可能,而持久化又使得狀態(tài)成為可能。

存儲(chǔ)參數(shù)

下面,讓我們用 Google Composable 函數(shù)來(lái)說(shuō)明 Composable 是如何存儲(chǔ)函數(shù)的參數(shù)的。這個(gè)函數(shù)接收一個(gè)數(shù)字作為參數(shù),并且通過(guò)調(diào)用 Address Composable 函數(shù)來(lái)繪制地址。

@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ù)存儲(chǔ)在插槽表中。在本例中,我們可以看到一些冗余:Address 調(diào)用中添加的 “Mountain View” 與 “CA” 會(huì)在下面的文本調(diào)用被再次存儲(chǔ),所以這些字符串會(huì)被存儲(chǔ)兩次。

我們可以在編譯器級(jí)為 Composable 函數(shù)添加 static 參數(shù)來(lái)消除這種冗余。

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ì)改變,則無(wú)需存儲(chǔ)該參數(shù)。所以這一 Google 函數(shù)示例中,編譯器傳遞了一個(gè)位字段來(lái)表示所有參數(shù)都不會(huì)發(fā)生改變。

接下來(lái),在 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)
}

這些位操作邏輯難以閱讀且令人困惑,但我們也沒有必要理解它們:編譯器擅長(zhǎng)于此,而人類則不然。

在 Google 函數(shù)的實(shí)例中,我們看到這里不僅有冗余,而且有一些常量。事實(shí)證明,我們也不需要存儲(chǔ)它們。這樣一來(lái),number 參數(shù)便可以決定整個(gè)層級(jí),它也是唯一一個(gè)需要編譯器進(jìn)行存儲(chǔ)的值。

有賴于此,我們可以更進(jìn)一步,生成可以理解 number 是唯一一個(gè)會(huì)發(fā)生改變的值的代碼。接下來(lái)這段代碼可以在 number 沒有發(fā)生改變時(shí)直接跳過(guò)整個(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)
}

接下來(lái),我們可以在該返回值上使用 lambda 來(lái)調(diào)用 updateScope 方法,從而告訴運(yùn)行時(shí)在有需要時(shí)如何重啟當(dāng)前的 Composable。這一方法等同于 LiveData 接收的 lambda 參數(shù)。在這里使用問號(hào)的原因——可空的原因——是因?yàn)槿绻覀冊(cè)趫?zhí)行 Counter 的過(guò)程中不讀取任何模型對(duì)象,則沒有理由告訴運(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)十分有用,但是未來(lái) Composable 函數(shù)的行為與功能不會(huì)改變,而實(shí)現(xiàn)則有可能發(fā)生變化。

同樣的,Compose 編譯器在某些狀況下可以生成更為高效的代碼。隨著時(shí)間流逝,我們也期待優(yōu)化這些改進(jìn)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容