天天看點

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

對Jetpack Compose設計實作的解讀與思考
原文連結:www.jianshu.com/p/7bff0964c…

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

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

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

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

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

Svelte, SwiftUI, Vue3.0的趨勢揭示出通用程式設計語言并不能很好的滿足高性能UI的需求。這些語言都不約而同地選擇在編譯期優化上下功夫,這些優化需要大量關于代碼的元資訊來實作,當通用程式設計語言預設提供的元資訊不足之後,隻剩下開發者手動标注和發明新的編譯流程兩個選項。

(代碼本身執行會産生傳回值和副作用,大部分時候人們隻關心傳回值和副作用,但是代碼本身包含的資訊遠多于傳回值和副作用。代碼是怎麼寫的、什麼邏輯,都是編譯期優化感興趣的内容。有些語言預設攜帶了更多的元資訊,比如如果某個函數式語言文法自帶了依賴收集,那麼就相當于這個語言的每個變量自己就攜帶了這個元資訊,那麼寫React的<code>useMemo</code>時就能省略手動标注依賴項的操作。但是“通用”程式設計語言一般預設攜帶的元資訊少得可憐,一般每個變量可挖掘的資訊隻有值、編譯期類型和運作期類型。有些語言甚至後兩個都是殘廢甚至不存在。)

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

Vue3.0使用的模闆則是為了另一個目的:在編譯期收集UI布局的靜态資訊。模闆的編譯器可以在編譯器自動識别模闆裡出現的那些值和節點是不變的,哪些值和節點是開發者傳入的可變的變量,進而在編譯結果中跳過對于不變值的Diff過程。Vue3.0的模闆優化遠不止這些,但從根本上來說,這些優化都是基于收集代碼的元資訊而實作的(或者說是在編譯期實作的),基于純javascript并不足以很好的實作這些需求,是以才産生了對于模闆文法的需求。

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

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

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

轉化為一個<code>TupleView&lt;(Text,Text)&gt;</code>的編譯期類型,進而告知runtime:“子節點數量寫死的隻有兩個”+“類型也是寫死的”。

也能夠把表面上if控制的一個構造函數

轉化為一個<code>Text?</code>,告知runtime:“這個子節點是有條件出現的”

甚至連擴充方法

傳回的都是<code>ModifiedContent&lt;Text, _BackgroundModifier&lt;Color&gt;&gt;</code>,告知runtime代碼中究竟是怎麼修改的Text,修改了什麼屬性。

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

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

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

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

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

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

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

最簡單的Counter示例

翻譯為

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

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

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示範文檔

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

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

可以看到<code>remember</code>函數,state,Button函數傳的參數,全部都被以深度優先周遊的順序記錄在了紙帶上

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

會被編譯為

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

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

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

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

會被編譯為類似

<code>Google</code>函數和<code>Address</code>函數均多了一個<code>$static</code>的<code>bit flag</code>參數。Address函數的$static參數<code>0b11110</code>在運作時會導緻後四個參數跳過儲存和 Diff步驟。這實作了常量參數的消除。

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

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

Compose的編譯期優化潛力較為巨大,在未來完全有實作SwiftUI所有編譯期優化的潛力,盡管沒有使用類型系統可能會導緻某些實作更為困難。

Compose對于原版Kotlin的強勢侵入是其得以實作設計目标的重要原因。但也是一個隐患,Compose對語義的入侵過深,我們可以看看Compose的編譯器可能會幹什麼事情

對每個樹的節點,按照源代碼位置,生成一個唯一的int型ID

插入start和group來表明節點的邊界

收集向函數傳遞的參數,參數的性質

根據收集的資訊指導一個指令機的工作,那個指令機工作在一個無類型的紙帶上

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

作為未來取代Android SDK的候選者,有強烈的風格取向,opinionated。為了實作對樹狀渲染結構“通用”的描述,Compose捆綁了一整套非常新的解決方案,從Positional Memoization,到安卓上第xxxxxxx個響應式資料解決方案@State(目前仍然缺乏資料以證明其通用性)。好是好,但是Angular前車之鑒在那裡。公平的來說,SwiftUI也是強烈的風格取向,但SwiftUI在蘋果生态中的地位個人覺得谷歌沒法在Compose上複刻。而且加上強勢侵入原語言語義,一旦要調整估計就要大調整。

Compose大量功能處于編譯器層,導緻這些功能其實是沒辦法靈活調節的。Flutter我覺得官方的xxx不好還可以自己重寫一個釋出出去,才有了一堆群魔亂舞的東西,engine雖大但除了engine以外都可以自己寫。Compose感覺很容易就會碰到編譯器層。

Compose由于基于編譯器,很多的優化都是類似于編譯器過一個pass的模式來的,尤其是Diff和常量消除那些地方,比較細碎,不容易歸檔解釋,給人一種“想到哪裡寫到哪裡”的感覺,目前官方的文檔就有很多這種問題。

總之,非常期待以後真正理想的“通用”程式設計語言配上先進的前端架構。也許就是swift加上MPS。

繼續閱讀