本文是 Compose 系列的第二篇文章。在 第一篇文章 中,我已經闡述了 Compose 的優勢、Compose 所解決的問題、一些設計決策背後的緣由,以及這些內容是如何幫助開發者的。此外,我還討論了 Compose 的思惟模型、您應如何考慮使用 Compose 編寫代碼,以及如何建立您本身的 API。
在本文中,我將着眼於 Compose 背後的工做原理。但在開始以前,我想要強調的是,使用 Compose 並不必定須要您理解它是如何實現的。接下來的內容純粹是爲了知足您的求知慾而撰寫的。數組
若是您已經瞭解過 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 與其工做方式相同:它能夠改變函數類型。app
// 函數聲明 @Composable fun MyFun() { … } // lambda 聲明 val myLambda = @Composable { … } // 函數類型 fun MyFun(myParam: @Composable () -> Unit) { … }
這裏的重點是,當您使用 @Composable 註解一個函數類型時,會致使它類型的改變:未被註解的相同函數類型與註解後的類型互不兼容。一樣的,掛起 (suspend) 函數須要調用上下文做爲參數,這意味着您只能在其餘掛起函數中調用掛起函數:composer
fun Example(a: () -> Unit, b: suspend () -> Unit) { a() // 容許 b() // 不容許 } suspend fun Example(a: () -> Unit, b: suspend () -> Unit) { a() // 容許 b() // 容許 }
Composable 的工做方式與其相同。這是由於咱們須要一個貫穿全部的上下文調用對象。dom
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 的層級結構可使用這個數據結構,並且咱們能夠在其中插入一些東西。spa
讓咱們假設已經完成了層級結構的執行。在某個時候,咱們會從新組合一些東西。因此咱們將遊標重置回數組的頂部並再次遍歷執行。在咱們執行時,能夠選擇僅僅查看數據而且什麼都不作,或是更新數據的值。
咱們也許會決定改變 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.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 的概念。
一般,咱們所說的全局記憶化,指的是編譯器基於函數的輸入緩存了其結果。下面是一個正在執行計算的函數,咱們用它做爲位置記憶化的示例:
@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 編譯器在某些情況下能夠生成更爲高效的代碼。隨着時間流逝,咱們也期待優化這些改進。