對Jetpack Compose設計實現的解讀與思考


Jetpack Compose近日終於邁入了Beta階段,API也逐漸趨於穩定,所以我們也能對於Compose的設計進行初步的解讀和評價了。

Compose從整體技術風格上來說是這樣一個產物:在語法上激進模仿SwiftUI,編譯/運行過程充滿Svelte風格,同時也綜合了各方包括Android開發組自身對UI框架的思考結果。

使用Compose時,最值得關注的就是Compose的編譯器插件。可以這么說,Compose的runtime、api都是依附於編譯器插件的,那個巨大而無所不包的編譯器插件才是Compose的本體。

Compose插件強勢的入侵了原版Kotlin的語法,導致包含了Compose的Kotlin基本上可以算作新語言(算成個新虛擬機都不過分)。初次了解的時候確實讓我很困惑,因為這與ReactFlutter推崇的趨勢簡直是背道而馳。但是了解了SvelteSwiftUI之后,Compose顯得沒有那么突兀了。

高性能UI與通用編程語言的沖突

很久以前代碼與UI都是分開用不同語言寫的,React改變了UI,"Code in one language"的呼聲越來越高,再到后來,他們又改了回去。

Svelte, SwiftUI, Vue3.0的趨勢揭示出通用編程語言並不能很好的滿足高性能UI的需求。這些語言都不約而同地選擇在編譯期優化上下功夫,這些優化需要大量關於代碼的元信息來實現,當通用編程語言默認提供的元信息不足之后,只剩下開發者手動標注和發明新的編譯流程兩個選項。

(代碼本身執行會產生返回值和副作用,大部分時候人們只關心返回值和副作用,但是代碼本身包含的信息遠多於返回值和副作用。代碼是怎么寫的、什么邏輯,都是編譯期優化感興趣的內容。有些語言默認攜帶了更多的元信息,比如如果某個函數式語言語法自帶了依賴收集,那么就相當於這個語言的每個變量自己就攜帶了這個元信息,那么寫React的useMemo時就能省略手動標注依賴項的操作。但是“通用”編程語言一般默認攜帶的元信息少得可憐,一般每個變量可挖掘的信息只有值、編譯期類型和運行期類型。有些語言甚至后兩個都是殘廢甚至不存在。)

Svelte

Svelte試圖解決的問題是:如何用聲明式的代碼書寫風格,一對一直接翻譯成純粹命令式的DOM操作,從而達到無額外開銷+極小runtime的效果。最終Svelte選擇發明一套自制的模板語法來翻譯到Javascript上。這個好理解,因為大家很熟悉,顯然javascript以及其工具鏈沒有任何實現這個目標的可能。

Vue3.0

Vue3.0使用的模板則是為了另一個目的:在編譯期收集UI布局的靜態信息。模板的編譯器可以在編譯器自動識別模板里出現的那些值和節點是不變的,哪些值和節點是開發者傳入的可變的變量,從而在編譯結果中跳過對於不變值的Diff過程。Vue3.0的模板優化遠不止這些,但從根本上來說,這些優化都是基於收集代碼的元信息而實現的(或者說是在編譯期實現的),基於純javascript並不足以很好的實現這些需求,所以才產生了對於模板語法的需求。

另一方面,Vue3.0的Reactivity API倒是成功的通過hack的方式實現了類似依賴收集的特性,基本不需要手動標注,Javascript各種奇怪的特性總能帶來驚喜。

SwiftUI

SwiftUI解決方案則更加誇張。由於設計目標導致SwiftUI必須由Swift單語言完成而不能搞自制語法,SwiftUI采用了兩個舉措來獲取代碼元信息:第一個是對編譯器開洞,搞了黑箱式的FunctionBuilder注解,第二個是利用FunctionBuilder提供的操作空間,將感興趣的元信息編碼進編譯期類型中,通過對編譯期類型的解讀實現類似於vue3.0的編譯期優化。

具體來說,SwiftUI的FunctionBuilder能把對於表面上一個閉包調用了兩個構造函數

{
  Text("Hello")
  Text("World")
}
復制代碼

轉化為一個TupleView<(Text,Text)>的編譯期類型,從而告知runtime:“子節點數量寫死的只有兩個”+“類型也是寫死的”。 也能夠把表面上if控制的一個構造函數

{
  if something {
    Text("Hello")
  }
}
復制代碼

轉化為一個Text?,告知runtime:“這個子節點是有條件出現的” 甚至連擴展方法

{
  Text("Hello")
    .background(Color.red)
}
復制代碼

返回的都是ModifiedContent<Text, _BackgroundModifier<Color>>,告知runtime代碼中究竟是怎么修改的Text,修改了什么屬性。

總之Swift選擇用復雜的編譯期類型嵌套來描述UI的絕大部分細節,相當於把代碼AST中感興趣的部分,在黑箱內轉譯成一個符合語言規范的表達方式(編譯期類型),再傳遞給框架的其他部分。這個方法相對較通用,而且侵入性較低(否則直接拿着AST在框架里傳來傳去就相當於是在魔改編譯器了)。

React

React選擇**all in javascript",jsx都是直接展開成React.createElement,所以沒有編譯期優化。同樣的useMemo后面得手動聲明一堆依賴項。

Flutter

Flutter同為Google的項目,很適合與Compose進行對比。Flutter很顯然對於編譯期優化缺乏興趣(同樣也對很多其他高層次優化缺乏興趣),Flutter的目的只是提供一個貼近乃至暴露底層渲染流程的跨平台app自繪引擎,提供的上層封裝很淺。Flutter關心的只有運行期的各種機制,對編譯期細節基本是毫無興趣,也符合其偏向底層的風格。所以Dart這種缺乏特性的語言對Flutter來說並無大礙。(多嘴一句,Dart的趨勢估計是要逐漸的成為帶GC的增甜C++,更適合開發Flutter這種引擎)

Compose

Compose在框架設計方面的野心明顯超越Flutter。Compose團隊多次表示Compose就是對Android SDK的重寫。Compose對自身的定位估計類似於SwiftUI在蘋果系生態中的定位,那就是高層次、生態內通用、外加依靠自身定位盡可能挖掘以及定制工具鏈以實現先進的開發模式。Compose使用了語法上和Swift神似的Kotlin,也面臨相似的問題,於是Compose(出於一些原因)選擇了簡單粗暴的魔改Kotlin編譯器,而不是模仿SwiftUI玩類型系統雜耍。

Compose團隊解釋過Compose的出發點:構建一個通用的、描述樹狀結構渲染過程的框架,不管是是手機UI組件樹或者是瀏覽器HTML Element。

Compose一不做二不休,直接把Kotlin編譯器魔改到底。最后利用編譯器魔改實現了幾大功能。

Svelte風格的指令式翻譯

Compose對於@Composable函數的翻譯很有Svelte的風格,基本上做到了將聲明式的函數語句一對一的翻譯為針對composer的指令。這個翻譯過程目前官方放出的資料很少,而且演示性質居多,一般只是針對某個特定的翻譯模式來撰寫簡單的例子,而沒有准確的、成體系的說明,真正的翻譯產物遠復雜於官方示例。

最簡單的Counter示例

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

翻譯為

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()?.updateScope { nextComposer ->
    Counter(nextComposer)
  } // 為重渲染注冊鈎子
}
復制代碼

Button函數也是一個@Composable函數,內部也會被編譯器處理。可以看到這段最簡單的示例被翻譯成了composer上start了一個group,執行了remember和Button的操作(Button也將被進行類似的展開,直到展開為最基礎的畫布操作),在end的時候注冊了一個為了重渲染准備的鈎子。接下來的優化,也都是基於這種指令式翻譯的風格展開的。

實質上各種vdom,widget,HTML Element,Swift View結構的存在,無非都是用面向對象的方式儲存基礎畫布操作,方便聲明式編程而已。這也是Svelte風格的指令式翻譯的突破點:取消掉中間層,直接編譯階段翻譯為基礎操作。

Positional Memoization進行狀態持久化與運行期優化

Compose團隊口中的“描述通用的樹狀結構渲染過程”很大程度上指的就是Positional Memoization。聲明式編程常常遇到的問題是如何在重渲染過程中保存部分狀態,從而1.實現狀態管理2.方便進行Diff從而避免不必要開銷。類React的方案是在組件層背后再增加一層v-dom層,這樣v-dom層自然保持了狀態,同時也能進行Diff,Flutter的Element層同理。但是Compose團隊表示連這一層的開銷他們都想省......

跳脫出面向對象,換成指令式的思路之后,這事就變得可行了。反正計算機到頭來都是紙帶加上讀寫頭,紙帶就是狀態,讀寫頭移到哪就在哪里Diff不就行了。Compose團隊最后實現了這個暴力美學的方案,Compose的runtime還真的就是一個composer(讀寫頭)工作在一個slot table(紙帶)上。

代碼的執行流程本質上就是深度遍歷一棵樹的過程,於是在Compose的思想里,@Composable函數代碼里所有感興趣的細節可以視為一棵AST樹(不僅@Composable函數的嵌套關系被記錄下來了,開發者傳的每一個參數、調用的某些函數也被視為節點),然后composer執行時就相當於按照深度遍歷的順序把這棵樹事無巨細的記在slot table里。如果這棵樹的結構不發生變化(UI結構不發生變化),那么無論怎么重渲染,節點在紙帶上的位置一定不會變化,所以composer讀到相應的位置,就相當於找到了相應節點在上一輪執行時留下的狀態,就叫做Positional Memoization

以下示例來自於Google演示文檔

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

對應在slot table上的執行結果為

可以看到remember函數,state,Button函數傳的參數,全部都被以深度優先遍歷的順序記錄在了紙帶上

Positional Memoization自然可以用作狀態管理。同時,因為Compose記錄了每個函數傳遞的參數,因此Diff操作就變成了composer在紙帶上對比上一輪參數與本輪參數,從而決定是否跳過某個組件。

@Composable
fun Google(number: Int) {
  Address(number=number)
}
復制代碼

會被編譯為

fun Google(
  $composer: Composer,
  number: Int
) {
  if (number == $composer.next()) {
    $composer.skip()
  } else {
    Address(
      $composer,
      number=number
    )
  }
}
復制代碼

在沒有引入額外層的情況下,Compose實現了狀態的持久化和Diff操作,可以算是Compose團隊創新的思路了。但是由於Compose收集以及處理的信息如此之多,這樣的直接結果就是導致Compose幾乎可以被稱為是一個新虛擬機了

同時,根據Compose團隊所述,這個模型容易實現並發渲染。原理看起來如此,但目前沒有技術方案和實現,在此僅作提及。

編譯期優化

到此為止,不論做不做編譯期優化,Compose都已經具有了一個新型框架的合格技術水准。但Compose因為選擇了直接魔改Kotlin編譯器,所以在編譯期優化上大有挖掘之處。Compose團隊主要舉了常量參數的消除作為例子。

Compose理論上應該記錄下所有@Composable函數的參數,從而進行Diff。但和Vue3.0的思路類似,如果開發者傳進來一個常量,很明顯是沒必要記錄和Diff的。Compose編譯插件會自動分析每一處函數調用,產生一個bit flag,提示runtime跳過某些常量參數。例如下面這個例子中出現了大量常量(實際編譯產物中bit flag的邏輯和官方演示並不一致,望周知)

@Composable fun Google(number: Int) {
 Address(
   number=number,
   street="Amphitheatre Pkwy",
   city="Mountain View",
   state="CA"
   zip="94043"
 )
}
復制代碼

會被編譯為類似

fun Google(
 $composer: Composer,
 $static: Int,
 number: Int
) {
  //此處應有Diff代碼,略過
 Address(
   $composer,
   0b11110 or ($static and 0b1),
   number=number,
   street="Amphitheatre Pkwy",
   city="Mountain View",
   state="CA"
   zip="94043"
 )
}
復制代碼

Google函數和Address函數均多了一個$staticbit flag參數。Address函數的$static參數0b11110在運行時會導致后四個參數跳過保存和 Diff步驟。這實現了常量參數的消除。

同樣的,Google函數的number參數未經修改便直接被傳入了Address函數,如果Google處的number是常量,那么Address處應當也是,而不是因為number=number的寫法就被當作變量。於是0b11110后一個or運算實現了常量屬性的傳遞。

對Compose設計的思考

目前Compose的官方資料仍然較為缺乏,因此很難知道Compose除此之外的優化設計以及runtime具體調度機制。但總體來說,我認為

  1. Compose的編譯期優化潛力較為巨大,在未來完全有實現SwiftUI所有編譯期優化的潛力,盡管沒有使用類型系統可能會導致某些實現更為困難。
  2. Compose對於原版Kotlin的強勢侵入是其得以實現設計目標的重要原因。但也是一個隱患,Compose對語義的入侵過深,我們可以看看Compose的編譯器可能會干什么事情
    1. 對每個樹的節點,按照源代碼位置,生成一個唯一的int型ID
    2. 插入start和group來表明節點的邊界
    3. 收集向函數傳遞的參數,參數的性質
    4. 根據收集的信息指導一個指令機的工作,那個指令機工作在一個無類型的紙帶上

這個已經可以算作在Kotlin/JVM上重新發明了一個虛擬機了。結構化的執行流程,內存,ABI,call site,復雜的調度策略都有了,我覺得就差來個人來證明能跑操作系統了。在已經因為DSL特性高度特化的Kotlin語言上繼續發明新虛擬機,總歸是有點奇怪的事情。與此對比,SwiftUI對語言的入侵很少而且是隱式的。

  1. 作為未來取代Android SDK的候選者,有強烈的風格取向,opinionated。為了實現對樹狀渲染結構“通用”的描述,Compose捆綁了一整套非常新的解決方案,從Positional Memoization,到安卓上第xxxxxxx個響應式數據解決方案@State(目前仍然缺乏資料以證明其通用性)。好是好,但是Angular前車之鑒在那里。公平的來說,SwiftUI也是強烈的風格取向,但SwiftUI在蘋果生態中的地位個人覺得谷歌沒法在Compose上復刻。而且加上強勢侵入原語言語義,一旦要調整估計就要大調整。
  2. Compose大量功能處於編譯器層,導致這些功能其實是沒辦法靈活調節的。Flutter我覺得官方的xxx不好還可以自己重寫一個發布出去,才有了一堆群魔亂舞的東西,engine雖大但除了engine以外都可以自己寫。Compose感覺很容易就會碰到編譯器層。
  3. Compose由於基於編譯器,很多的優化都是類似於編譯器過一個pass的模式來的,尤其是Diff和常量消除那些地方,比較細碎,不容易歸檔解釋,給人一種“想到哪里寫到哪里”的感覺,目前官方的文檔就有很多這種問題。
  4. 總之,非常期待以后真正理想的“通用”編程語言配上先進的前端框架。也許就是swift加上MPS。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM