[譯]深入Flutter

小紅星閃啊閃發表於2020-05-16

原文地址在這裡

本文主要說了Flutter內部使用了怎樣的演算法和優化讓Flutter如此強大。某些內容對比了Flutter和其他開發工具一致性演算法的優劣,不過個人感覺還是太過簡短,後面我會花更多的時間來研究這方面的內容,後續補上。最後還講述了Flutter在API設計上是如何達到開發者的預期的。由於譯者水平有限,疏漏之處還請見諒。 我沒有全部翻譯,因為我更加關心的是一致性演算法的部分。在文章的最後一節中,主要講述了API的人性化設計,知道固然好,瞭解的不深入也不會有什麼損失。

本文描述了Flutter的內部工作原理。Flutter的widget是用激進組合的方式工作的,所以使用者在構建UI的時候會用到很多的widget。為了支援這個工作量,Flutter使用了亞線性演算法來處理佈局、構建元件以及樹資料結構。還包括了其他的一些常量及的優化。綜合考慮其他的一些細節,這樣的設計也會讓開發者更加的容易的使用回撥來建立無限滾動列表中對使用者可見的部分。

激進組合

Flutter的特點之一就是激進組合(aggressive composability)。一般元件(widget)都是由其他的更加基本的元件逐步組合而成的。比如Padding就是一個元件,而不是一個元件的某個屬性。總之,使用者的UI是有很多,很多的元件組成的。

元件會形成樹狀結構,最末尾的節點都是RenderObjectWidget型別的,葉子節點的元件都會被用來建立繪製到螢幕上的節點。一顆繪製樹就是一個儲存了使用者介面幾何資訊(大小,位置等)的資料結構。這些幾何資訊是在layout階段計算出來的,並在**繪製(painting)碰撞檢測(hit test)**被用到。基本上Flutter的開發人員不會直接去建立繪製物件(render object),而是通過元件(widget)來操作繪製樹。

為了在元件層支援激進組合,Flutter對元件層和繪製樹層使用了很多的演算法和優化。這些都會在隨後的章節中介紹。

亞線性佈局(Sublinear layout)

如何應對大量的元件和繪製物件(render object),如何獲取好的效能?關鍵就是有效的演算法!這其中最關鍵的就是layout演算法,這個演算法決定了繪製物件的幾何資訊,比如大小和位置。某些工具的佈局演算法的時間複雜度到了O(N²)甚至更差(比如,在某些約束下做固定點的迭代)。Flutter的目標是首次佈局計算達到線性效能,在更新已存在佈局的時候達到亞線性效能,尤其相對於大量增加的繪製物件,佈局計算消耗的時間只會緩慢增加。

Flutter每一幀只執行一次佈局計算,每次佈局計算都是單步操作(single pass)。約束會通過父物件呼叫每個子物件的layout方法傳遞下去。子物件會遞迴地執行自己的layout方法並在返回的時候把幾何資訊向上返回。一旦一個繪製物件從它的layout方法返回了,那麼這個繪製物件不會再被訪問1,一直到下一幀(frame)的佈局計算。這樣的方式把本來會分成兩步:測量(measure)和佈局的計算集合成了一步(single pass)。這樣,每個render object在計算佈局的時候最多被訪問兩次2:一次是沿著樹向下的訪問,一次是沿著樹向上的訪問。

Flutter對這個協議(protocol)有多個實現(specialization)。最常見的具象就是RenderBox,作用於一個二維笛卡爾座標系。在這個box佈局中,它的約束是一個最大、最小寬度和一個最大、最小高度。在佈局的時候,child就是通過在邊界中選擇一個值作為它的幾何資訊。當child從佈局中返回,父物件知道了child在父物件座標系3的位置。注意:子物件的佈局和它的位置無關,因為直到子物件從layout返回才能直到它的位置。因此,父物件可以任意給子物件定位,而不需要再次計算佈局。

總的來說,在佈局期間,唯一從父物件流向子物件的就是約束(constraints)。唯一從子物件流向父物件的資料就是幾何資訊。這些不變數可以大幅度減少佈局計算所需的工作量。

  • 如果子物件沒有把它自己的佈局標記為髒(dirty),子物件可以立即從layout計算中返回。因為,父物件給子物件的約束和上一次佈局的約束是一樣的。
  • 無論何時,一個父物件呼叫子物件的layout方法,父物件都要指出是否需要子物件返回的size資料。很多情況下都是不需要的,那麼父物件就不需要重新計算佈局。即使子物件選擇了一個新的size,子物件也自己保證這個新的size適配於已經存在的約束。
  • 緊約束只會被明確唯一而且有效的幾何資訊滿足。比如,最小和最大寬度是相等的,最小和最大高度也是相等的,那麼能滿足這個約束的就只有一個寬度和一個高度值。如果父物件提供了緊約束,那麼無論子物件layout的計算結果如何,父物件本身不需要重複計算自身的layout,即使父物件使用了子物件的layout返回的size,因為子物件不能在沒有父物件提供的約束的基礎上修改它自身的size。
  • 一個繪製物件可以宣告它僅僅使用父物件提供的約束來計算幾何資訊。這樣的宣告明確告知了框架就運算元物件重新計算了layout,父物件也不需要計算。即使父物件的約束不是緊約束,即使父物件的layout依賴於子物件的大小。因為子物件不能在沒有父物件的新約束的情況下修改大小。

這麼多優化的結果是:當繪製物件樹(render object tree)裡包含了髒(dirt)節點的時候,只有這些髒節點和他們周圍有限的子樹需要在layout的被訪問到。

亞線性元件構建(Sublinear widget building)

和layout演算法類似,Flutter的元件構建演算法也是亞線性的。在構建之後,所有元件都被element樹持有,這個樹也保持了UI的邏輯結構。Element樹非常必要,因為元件本身是不可修改的(immutable)。也就是說如果有多個wiget的話,他們是不會記得他們之中的父子節點關係的。Element樹也持有StatefuleWidgetstate物件。

在使用者資料或者其他操作之後,一個element可以變為髒(dirty),比如一個開發者對state物件呼叫了setState()。那麼Flutter會保留一個“髒”element的列表,並在構建階段直接跳過去而忽略掉其他乾淨(clean)的element。在構建階段,資料單向的由上向下從element樹流動,也就是說在構建階段,element的節點只會被訪問一次。一旦element變成乾淨的(clean)就不會變成髒的,因為它的祖先element都是乾淨的了4

由於元件是不可修改(immutable)的,如果父物件使用同一個元件重新構建,而且元件對應的element沒有把自己標記為髒,那麼這個element可以在構建階段立即返回。而且,element只需要比較兩個widget引用的ID來確定兩個widget是否為同一個。這個優化叫做二次投影模式,具體來說就是一個元件包含了一個構建前的子元件,在構建的時候把它儲存為了一個成員變數。

在構建的時候,Flutter也會避免使用InheritedWidget來訪問父鏈。如果所有元件都訪問父鏈,比如獲取當前的主題顏色,那麼根據樹的深度,構建將變成O(N²)。這樣的耗時就回非常之多。為了避免這樣的情況發生,Flutter在每個element上都有一個InheritedWidget的雜湊表。很多的element只會引用同一個雜湊表,只有element引用了新的InheritedWidget才會發生改變。

線性一致(Linear reconciliation)

與普遍認為的不同,Flutter不會進樹級別的找不同。而且使用了一個O(N)演算法:獨立檢測每個element的子element列表來決定是否要重用這個element。子列表一致性演算法的優化分為以下幾種情況:

  • 就得子列表是空
  • 兩個列表是同一的
  • 在列表裡的明確的一個地方有插入或者刪除一個或者多個元件
  • 如果每個列表裡包含了有同一個Key的元件,那麼兩個元件是被認為是匹配的

通常的方法就是從頭到尾的對比兩個列表裡的每個元件的執行時型別(runtime type)和key,極有可能會在每個列表裡發現一段包含了所有不匹配的元件,以及他們的範圍(range) 。Flutter會把舊的列表裡的元件根據他們的key,放進一個雜湊表裡。接下來,Flutter遍歷新的列表的範圍(range),並從雜湊表中查詢匹配的key。不匹配的會被拋棄,匹配的則使用新的元件重新構建。

樹的分解(Tree surgery)

重用element對於效能來說非常之重要,因為element持有兩種很重要的資料:狀態元件的狀態和底層的繪製物件。當Flutter可以重用一個element的時候,UI某個邏輯部分的狀態得以保留,並且之前計算出來的layout資料也可以重用,基本可以避免整個子樹的遍歷。事實上,Flutter支援保留了狀態和佈局的非本地(non-local)樹修改。

開發者可以通過使一個widget和一個GlobalKey關聯的方式來執行非本地樹修改。每一個全域性鍵(global key)在整個app裡都是唯一的,並且註冊在了一個執行緒相關的雜湊表裡。在構建階段,開發者可以把一個有全域性鍵的元件移動到element樹的任意位置。而不是在那個位置上再建一個全新的元件。Flutter會檢查雜湊表,然後把元件掛在新的父元件下,並保留整個子樹。

在子樹裡的繪製物件可以保留他們的佈局資訊,因為佈局的約束是唯一從樹的父物件流向子物件的資料。新的父物件會被標記為髒(dirty)因為它的子物件列表已經發生了改變。但是如果新的父物件傳過來的layout資料和舊的parent傳過來的是一樣的,那麼這個子物件會立刻返回,停止遍歷。

全域性鍵和非本地樹修改廣泛用於英雄轉化(hero transition)和導航(navigation)。

常量因素優化

在這些演算法優化之外,要達到及機組和還需要幾個常量因素優化。這些優化對於上面提到的演算法也至關重要。

  • 子模型無關:和其他的使用子列表的工具不同,Flutter的繪製樹不會依賴於特定子模式。比如,RenderBox類有一個抽象的visitChildren()方法而不是實際的firstChildnextSibling介面。許多子類都支援一個單一的child,直接做為一個類成員變數,而不是一列子節點。比如,RenderPadding支援一個唯一的child。這樣只會有一個耗時更短的,更簡單的layout方法會被執行。
  • 可視繪製樹,邏輯元件樹:在Flutter裡,繪製樹的操作是裝置無關(device-indepent)的視覺座標系統上進行的。也就是說x軸上更小的值在更左邊,即使當前閱讀方向是從右到左的。元件樹是基於邏輯座標系的,也就是說開始結束值的解析是依賴於閱讀方向的。從邏輯座標系到視覺座標系的轉化是元件樹到繪製樹之間手遞手完成的。這樣的方式會更加的高效,因為在繪製樹中佈局和繪製(painting)的計算比元件到繪製樹的轉化更為頻繁,可以避免重複的座標系轉化。
  • 文字有特殊的繪製物件處理:大量的繪製物件是不會處理複雜的文字的,文字只會被專門的繪製物件處理,RenderParagraph。這是一個繪製樹的葉子節點。文字的處理不需要繼承的方式,而是用組合的方式。如此一來就可以避免RenderParagraph重新計算它所持有的文字在父節點傳遞了同樣的約束的條件下再次計算佈局資料。著很常見,即使是在樹分解中也一樣。
  • 可觀察物件:Flutter使用觀察者模型和響應式模型。顯而易見,響應式已經是主流,但是Flutter在葉子節點的資料結構上使用了觀察者模型。比如,動畫的值發生改變的時候會通知一個觀察者列表。Flutter把這些觀察者物件從元件樹傳遞到了繪製樹,回執書可以直接觀察這些物件並在他們發生改變的時候,在繪製管道的某個合適的實際把他們置為無效。比如,對一個動畫值的修改只會出發繪製(painting)而不是構建和繪製兩個都出發。

鑑於通常都是碩大的元件數來說,這樣的優化帶來的效能提升非常顯著。

Element樹和繪製樹的拆分

繪製物件樹(繪製樹)和Element樹是同構的(嚴格的說,繪製樹是element樹的一個子集)。一個明顯的簡化是把這兩個陣列合成一個。然而,在實踐中把這兩個數分開有很多的益處。

  • 效能:當佈局發生更改,只有佈局樹中相關的部分需要遍歷。由於激進組合的原因,element樹裡總是會有很多需要跳過的節點。
  • 明確:對關注點的分離允許元件和繪製物件各自聚焦在各自的焦點上。這樣極大的簡化了API,也極大的降低了風險和測試的負擔。
  • 型別安全:繪製樹可以保證在執行時它節點的型別都是合適的,如此繪製樹可以保證型別的安全。組合元件不關心佈局的座標系,因此在element樹裡,檢查繪製對像的型別會導致一次樹遍歷。

無限滾動

無限滾動列表的實現對於各種工具來說都是非常之難。Flutter在構建的時候提供了一個非常簡單的介面來實現這個功能。一個列表,當使用者滾動的時候,使用了一個回撥來實現可視的時候顯示一個元件。支援這個功能需要用到viewport-aware佈局按需構建元件

Viewport感知佈局

和Flutter多數的東西一樣,可滾動元件也是用組合的方式組成的。在可滾動元件的外面是一個Viewport,的子元件它可以擴充套件到可視視窗外面的部分,還可以滾動到檢視內。然而,一個viewport有一個RenderSilver型別的子元件,而不是RenderBox型別的子元件。RenderSilver型別有一個檢視感知介面。

這個silver佈局協議和盒式佈局的協議結構上是一致的,也會給子節點傳遞約束並返回幾何資訊。然而,約束和幾何資訊在兩者之間卻不同。在silver協議裡,子節點收到的是viewport資料,包括剩餘的可視空間。他們返回的幾何資料讓很多種和滾動相關的效果成為可能,包括可摺疊的header和視差效果。

不同的silver填充viewport剩餘空間的方式是不同的。比如,一個silver可以生成一列子元件,一個挨著一個排列,直到這個silver顯示了全部的子元件或者用光了所有的空間。類似地,一個silver生成一個二維的grid,子元件只填滿這個grid可視的部分。因為他們可以感知到剩餘的空間還有多少。silver還可以生成有限的子元件,雖然他們也可以生成無限的子元件。

silver可以通過組合生成不同的可滾動佈局和效果。比如,一個單獨的viewport可以有一個可摺疊的heaer,下面跟著一個線性列表和一個grid。所有的三個silver都會根據silver協議來互動,從而生成在viewport裡可視的子元件,不管他們是屬於header,list還是grid的。

按需建立元件

如果Flutter有一個嚴格的先構建再佈局再繪製的管道(pipeline),前述內容在構建無限滾動列表的時候就非常的低效了,因為viewport裡剩餘多少空間可以使用的資料只有在layout的階段才可以知道。不採用其他手法的前提下,佈局階段對於構建填充剩餘空間的元件來說太遲了。Flutter把管道中的構建和佈局兩個階段互相交叉,解決了這個問題。在佈局階段的任何時刻,Flutter都可以按需構建新的元件,只要這些元件是當前執行佈局的繪製物件的子元件。

交叉構建和佈局可以實現,完全是因為構建和佈局演算法中嚴格的資料傳遞控制。尤其是,在構建階段,資料只可以向下傳遞。當一個繪製物件在計算佈局的時候,佈局遍歷還沒有訪問到這個繪製對戲的子樹,也就是說子樹生成的寫入還不能改寫當前佈局的計算結果。類似的,一旦佈局從一個繪製物件返回了,在這次佈局計算中這個繪製物件講不會再被訪問到,也就是說任何後一步佈局計算生成的寫入都不能影響當前繪製物件用於構建子樹的資料。

另外,線性一致性和樹分解對於滾動中高效的更新element都非常的有必要。當element滾動進或出可視區域的時候,對修改繪製樹來說也同樣的重要。

API開發者友好設計

只有在框架可以被正確使用的時候,快才有意義。為了達到API友好的效果,Flutter和開發者進行了廣泛的體驗研究。這些有時肯定了之前的某些決定,有時會幫助確定某些功能的優先順序,有時又改變了API設計的方向。比如,Flutter的API都有豐富的文件。UX研究肯定了這些文件的價值,但是也明確了示例程式碼和圖示的作用。

這一節討論Flutter的API的設計以備急用。

特定的API符合開發者特定的理解

Flutter的Widget, ElementRenderObject樹節點的基類沒有子模型(child model)。這也讓某個節點可以成為它要適用的某個節點的子模型。

多數的元件物件都只有一個子元件,因此只暴露了一個child引數。某些元件支援很多的子元件,所以暴露了一個叫做children的列表引數。某些元件沒有任何的子元件,所以也不會暴露任何的引數。類似的,RenderObjects暴露特定的子模型API。RenderImage是一個葉子節點,沒有子物件的概念。RenderPadding接受一個單一的child,所以它只有一個單獨的引用指向一個child。RenderFlex接受未知數量的child並使用一個連結串列管理他們。

在某些特殊的情況下,需要更復雜的子模型。RenderTable繪製物件接收的是一個二維陣列,這個類對應的getter和setter來控制行和列的數量,還有一定數量的方法可以替換某個x,y下標的child。

Chip元件和InputDecoration物件的屬性也和相關控制元件相符合。一刀切的子模型會強制語義至於子模型分層的頂端,比如,定義第一個child為字首值,第二個child為字尾值,那麼這個特定的子模型(child model)可以被用於特定的命名屬性。

這種靈活性讓這些樹的每個節點按照它的角色來操作。很少會把一個cell插入到一個table裡,因為所有其他的cell都會變形,移位。類似地,也很少會使用下標而不是引用從一個flex行刪除某個元素。

RenderParagraph物件是組極端的例子:它有一個完全不同的child,TextSpan。在RenderParagraph範圍內,RenderObject樹會變成一個TextSpan樹。

總體上,讓API的設計符合開發者預期,不只是子模型上的努力。

某些簡單的元件的存在就是為了讓開發者在解決的某個問題的時候可以找他他們。給一個行或列新增空間,一旦你知道方法就回變得非常簡單:使用Expanded元件和一個零大小的SiezedBox子元件,不過其實這還不是最好的方法。如果你搜尋space的話你會找到Spacer元件,這個元件內部就使用了ExpandedSizedBox

類似的還有隱藏一個元件的子樹也很容易,只要不要在構建的時候包含這個元件的子樹。開發者希望有一個元件可以達到這個效果,那麼就有了Visibility元件。

顯示引數

UI框架都會有很多的引數,一般來說開發者很少會記得建構函式裡的每個引數的語義。Flutter使用響應模式,所以在構建的時候會用到很多的建構函式。有了Dart語言的命名引數,Flutter的API就可以保證每個build方法都清晰,容易理解。

這個模式可以擴充套件到每個用了很多引數的方法上。尤其是每個bool型別的引數,所以方法裡的每個truefalse都是自帶文件屬性的。

避免掉坑

一個在Flutter裡普遍使用的技術是定義一種錯誤條件不存在的API。這樣避免了對於錯誤的過多關注。

比如,一個插值方法允許一端或者插值的兩端都為null,Flutter沒有把它定義為錯誤。而是:插值的兩端都為null則返回null,如果有一端為null,那麼就相當於是給某個特定型別的0值插入值。也就是說,開發者如果意外給插值方法傳入了null值,那麼不會發生錯誤,而是會輸出一個合理的值。

Flex佈局演算法裡有一個更加細微的例子。這個佈局的概念是flex的繪製物件的空間會有它的一個或者多個子元件分割,所以flex佈局的大小應該是佔滿可用空間。在最初的設計中,提供一個無線的空間會出錯:這樣隱式的表明flex佈局是無限大小,一個沒有用的佈局。然而,API做了修改,這樣當一個無限大小的空間賦值給flex繪製物件的時候,它會變成這些子元件需要的大小,減少了可能的錯誤情況。

積極報告錯誤情況

不是所有的錯誤都可以通過設計避免的。對於那些在debug的時候依舊存在的問題,Flutter通常都會今早的捕獲異常,並且及時報告。斷言(assert)的使用非常普遍。建構函式的引數也都檢查到了細節。生命週期也都有監控,一旦發生錯誤就會丟擲異常。

在某些情況下,這些發揮到了極致:比如,當執行單元測試的時候,不管測試的是什麼,每個RenderBox子類都會檢查固有size方法是否滿足固有size的契約。這可以幫助發現那些API中不容易暴露的問題。

丟擲的異常也包含了竟可能豐富的資訊。

響應式模型

基於可變樹的API都要經歷一種混亂:建立樹的最初狀態的操作集合和後續的更新的操作結合存在很大的不同。Flutter的繪製層使用了這個模型,這是一種維護一個持久樹的有效方法,也是使佈局和繪製高效的關鍵所在。然而,直接操作繪製層會顯得非常奇怪,更糟糕的是還可能引入bug。

Flutter的元件層使用了響應式的模式來組合元件,並以此來操作底層的繪製樹。這一API把樹的建立和修改多個步驟合成為一個樹的描述(build)步驟,每當APP的狀態(state)發生了改變,那麼UI就會生出新的配置,這個配置是由開發者控制的。之後Flutter對樹的修改做必要的計算來反映出新的修改。

插值

Flutter鼓勵開發者根據當前APP狀態的,作出相應的配置。也就是說,App狀態變了,那麼對應的元件也發生了變化。這個時候需要一種機制可以保證這些變化是有動畫效果來過渡的。

比如,在狀態1的時候有S1,介面上包含了一個圓圈。但是在下一個狀態S2,它變成了一個方框。沒有任何動畫機制的話,這個顯得很突兀。一個隱式的動畫會讓圓圈經過幾幀之後再變成方框,體驗會更好。

總結

Flutter的口號是:“everything is a widget”,也就是使用基本的元件來構建複雜的元件。激進組合的結果會導致開發者使用大量的元件,這就需要仔細地設計演算法和資料結構,這樣才能高效的處理元件。再加上另外的設計,這些資料結構也讓開發者可以很容易的建立無限滾動列表元件。


1 對於佈局計算是這樣的。不過他還是會在繪製,建立無障礙樹(如果需要的話),以及碰撞測試的時候被再次訪問。

2 在實際執行中會更加複雜。某些佈局需要固有維度或者baseline的測量,這樣會導致對相關子樹的額外遍歷(激進快取就是為了應對這種極差情況而準備的)。這種情況非常罕見。尤其shrink-wrapping並不是一定需要固有維度。

3 技術上來說,child的位置不在RenderBox幾何資訊裡,所以也不需要佈局的時候計算。大多數情況下繪製物件都把他們的child定位在相對於它的origin的(0,0)上,這樣也不需要計算或者儲存任何的東西。有些繪製物件不到最後專門計算它的子節點位置的時候都儘量避免對位置的計算,如果這些子元件最後不用繪製的話也就不會有計算了。

4 這條規則存在一個例外。在[按需構建元件](## 按需建立元件)一節關於這一點的討論,某些元件會因為佈局約束的改變而重新構建。如果一個元件在同一幀中因為無關的原因把它自己標記為髒,那麼它也會佈局約束的更改影響,它會更新兩次。這個構建只會發生在元件本身而不會影響到它的子元件。

相關文章