Flutter 內部原理
從 Flutter 的創始工程師之一那裡瞭解 Flutter 的內部工作原理。
本文件介紹了 Flutter 工具包的內部工作原理,正是這些原理使得 Flutter 的 API 成為可能。由於 Flutter 的 Widget 是透過激進的組合方式構建的,因此使用 Flutter 構建的使用者介面包含大量的 Widget。為了支援這種工作負載,Flutter 在佈局和構建 Widget 時採用了亞線性演算法,並使用資料結構使“樹手術”(Tree surgery)變得高效,同時還包含許多常量級最佳化。結合一些額外的細節,這種設計還使得開發者能夠使用回撥函式輕鬆建立無限滾動列表,這些回撥函式僅構建使用者當前可見的 Widget。
激進的組合性
#Flutter 最顯著的特點之一是其激進的組合性(aggressive composability)。Widget 是透過組合其他 Widget 來構建的,而這些 Widget 本身又是透過越來越基礎的 Widget 構建出來的。例如,Padding 是一個 Widget,而不是其他 Widget 的一個屬性。因此,使用 Flutter 構建的使用者介面由許多、許多的 Widget 組成。
Widget 構建遞迴在 RenderObjectWidgets 處觸底,這些 Widget 用於在底層渲染(render)樹中建立節點。渲染樹是一種儲存使用者介面幾何結構的資料結構,它在佈局階段進行計算,並在繪製和點選測試(hit testing)階段使用。大多數 Flutter 開發者不會直接編寫渲染物件,而是透過 Widget 來操作渲染樹。
為了在 Widget 層支援激進的組合性,Flutter 在 Widget 層和渲染樹層都使用了許多高效的演算法和最佳化,這些將在後續小節中介紹。
亞線性佈局
#面對大量的 Widget 和渲染物件,實現高效能的關鍵在於高效的演算法。其中最重要的是佈局(layout)的效能,即確定渲染物件幾何結構(例如大小和位置)的演算法。一些其他工具包使用時間複雜度為 O(N²) 或更差的佈局演算法(例如某些約束領域中的不動點迭代)。Flutter 的目標是實現初始佈局的線性效能,以及後續更新現有佈局時亞線性的佈局效能。通常,佈局所花費的時間隨渲染物件數量增加的增長速度應低於線性增長。
Flutter 每幀執行一次佈局,且佈局演算法以單次遍歷方式工作。約束(Constraints)由父物件透過呼叫每個子物件的佈局方法向下傳遞到樹中。子物件遞迴地執行它們自己的佈局,然後透過從其佈局方法返回,將幾何結構(geometry)向上傳遞迴樹中。重要的是,一旦渲染物件從其佈局方法返回,該渲染物件在下一幀佈局之前不會再次被訪問1。這種方法將原本可能分開的度量和佈局過程合併為一次遍歷。因此,在佈局期間,每個渲染物件最多被訪問兩次2:一次是在向下遍歷樹時,另一次是在向上返回時。
Flutter 對此通用協議有幾種特殊實現。最常見的是 RenderBox,它在二維笛卡爾座標系中執行。在盒子佈局中,約束是最小/最大寬度和高度。在佈局期間,子物件透過在這些邊界內選擇一個尺寸來確定其幾何結構。子物件完成佈局返回後,父物件決定子物件在父座標系中的位置3。注意,子物件的佈局不能依賴於其位置,因為位置是在子物件從佈局返回後才確定的。因此,父物件可以自由地重新定位子物件,而無需重新計算其佈局。
更一般地說,在佈局期間,唯一從父級流向子級的資訊是約束,而唯一從子級流向父級的資訊是幾何結構。這些不變性可以減少佈局期間所需的工作量。
-
如果子物件沒有將自己的佈局標記為髒(dirty),並且父物件給出的約束與子物件在上次佈局期間收到的約束相同,那麼子物件可以立即從佈局方法返回,從而截斷遍歷路徑。
-
每當父物件呼叫子物件的佈局方法時,父物件會指示它是否使用了子物件返回的尺寸資訊。如果父物件不使用該尺寸資訊(這種情況很常見),那麼即使子物件選擇了新的尺寸,父物件也無需重新計算其佈局,因為父物件可以保證新尺寸會符合現有約束。
-
緊約束(Tight constraints)是指那些只能由一個唯一有效幾何結構滿足的約束。例如,如果最小和最大寬度相等,且最小和最大高度相等,那麼唯一滿足這些約束的尺寸就是該特定的寬高。如果父物件提供緊約束,則即使父物件在其佈局中使用了子物件的尺寸,每當子物件重新計算佈局時,父物件也無需重新計算其佈局,因為在沒有父物件的新約束下,子物件無法改變尺寸。
-
渲染物件可以宣告它僅使用父物件提供的約束來確定其幾何結構。這樣的宣告告知框架,即使約束不是緊約束,且父物件的佈局依賴於子物件的尺寸,父物件也無需在子物件重新計算佈局時重新計算其佈局,因為在沒有父物件的新約束下,子物件無法改變尺寸。
由於這些最佳化,當渲染物件樹包含髒節點時,在佈局期間只會訪問這些節點及其周圍有限的子樹部分。
亞線性 Widget 構建
#與佈局演算法類似,Flutter 的 Widget 構建演算法也是亞線性的。構建完成後,Widget 由 Element 樹持有,Element 樹保留了使用者介面的邏輯結構。Element 樹是必要的,因為 Widget 本身是不可變的,這意味著(除其他事項外)它們無法記錄其與其他 Widget 之間的父子關係。Element 樹還持有與有狀態 Widget(StatefulWidgets)關聯的 State 物件。
響應使用者輸入(或其他激勵)時,Element 可能會變髒(dirty),例如當開發者在關聯的 State 物件上呼叫 setState() 時。框架會維護一個髒 Element 列表,並在構建(build)階段直接跳轉到它們,跳過乾淨的 Element。在構建階段,資訊單向流向 Element 樹,這意味著每個 Element 在構建階段最多被訪問一次。一旦被清理,Element 就不會再次變髒,因為透過歸納法,其所有祖先 Element 也都是乾淨的4。
由於 Widget 是不可變的,如果一個 Element 沒有將自己標記為髒,並且父物件使用相同的 Widget 重建該 Element,那麼該 Element 可以立即從構建方法返回,從而截斷遍歷。此外,Element 只需要比較兩個 Widget 引用的物件標識,即可確定新 Widget 與舊 Widget 是否相同。開發者利用這一最佳化來實現重投影(reprojection)模式,即 Widget 將儲存在成員變數中的預構建子 Widget 包含在其構建方法中。
在構建期間,Flutter 還透過 InheritedWidgets 避免了向上遍歷父級鏈。如果 Widget 頻繁地遍歷父級鏈(例如為了確定當前主題顏色),那麼構建階段的時間複雜度將達到 O(N²),這在激進組合帶來的深層樹結構中會變得非常大。為了避免這種父級遍歷,框架透過在每個 Element 上維護 InheritedWidget 的雜湊表,將資訊向下推送到 Element 樹中。通常,許多 Element 會引用同一個雜湊表,該表僅在引入新 InheritedWidget 的 Element 處發生更改。
線性調解(Reconciliation)
#與普遍看法相反,Flutter 並不採用樹差異(tree-diffing)演算法。相反,框架透過使用 O(N) 演算法獨立檢查每個 Element 的子列表來決定是否重用 Element。子列表調解(reconciliation)演算法針對以下情況進行了最佳化:
- 舊子列表為空。
- 兩個列表完全相同。
- 列表中的某處有 Widget 的插入或移除。
- 如果每個列表中包含具有相同 Key5 的 Widget,則這兩個 Widget 會被匹配。
通用的方法是透過比較每個 Widget 的執行時型別和 Key 來匹配兩個子列表的開頭和結尾,可能會在每個列表的中間找到一個包含所有未匹配子節點的非空範圍。然後,框架根據 Key 將舊子列表範圍內的子節點放入雜湊表中。接下來,框架遍歷新子列表中的該範圍,並根據 Key 查詢雜湊表以獲取匹配項。未匹配的子節點將被丟棄並從頭開始重建,而匹配的子節點則會使用它們的新 Widget 進行重建。
樹手術(Tree surgery)
#重用 Element 對效能至關重要,因為 Element 擁有兩項關鍵資料:有狀態 Widget 的狀態,以及底層的渲染物件。當框架能夠重用 Element 時,使用者介面該邏輯部分的狀態得以保留,之前計算的佈局資訊也可以複用,通常可以避免遍歷整個子樹。實際上,重用 Element 的價值非常大,以至於 Flutter 支援非區域性(non-local)的樹變動,並能保留狀態和佈局資訊。
開發者可以透過將 GlobalKey 關聯到其 Widget 來執行非區域性樹變動。每個全域性 Key 在整個應用程式中都是唯一的,並註冊線上程特定的雜湊表中。在構建階段,開發者可以將帶有全域性 Key 的 Widget 移動到 Element 樹中的任意位置。框架不會在該位置構建一個新的 Element,而是會檢查雜湊表,並將現有的 Element 從其舊位置重新掛載到新位置,從而保留整個子樹。
重掛載子樹中的渲染物件能夠保留其佈局資訊,因為在渲染樹中,佈局約束是唯一從父級流向子級的資訊。新父級的佈局被標記為髒,因為其子列表發生了變化,但如果新父級傳遞給子級的佈局約束與子級從舊父級收到的相同,子級可以立即從佈局中返回,從而截斷遍歷。
全域性 Key 和非區域性樹變動被開發者廣泛用於實現 Hero 動畫和頁面導航等效果。
常量級最佳化
#除了這些演算法層面的最佳化,實現激進的組合性還依賴於幾個重要的常量級最佳化。這些最佳化在上述主要演算法的葉子節點處最為重要。
-
不依賴特定的子模型。 與大多數使用子列表的工具包不同,Flutter 的渲染樹並不承諾使用特定的子模型。例如,
RenderBox類具有一個抽象的visitChildren()方法,而不是具體的firstChild和nextSibling介面。許多子類只支援單個子節點(直接作為成員變數持有),而不是子列表。例如,RenderPadding只支援一個子節點,因此具有更簡單的佈局方法,執行時間更短。 -
視覺渲染樹,邏輯 Widget 樹。 在 Flutter 中,渲染樹在與裝置無關的視覺座標系中執行,這意味著 x 座標值較小的一側始終在左邊,即使當前的閱讀方向是從右向左。Widget 樹通常以邏輯座標執行,即使用起始(start)和結束(end)值,其視覺解釋取決於閱讀方向。從邏輯座標到視覺座標的轉換是在 Widget 樹和渲染樹交接時完成的。這種方法更高效,因為渲染樹中的佈局和繪製計算比 Widget 到渲染樹的交接更為頻繁,且可以避免重複的座標轉換。
-
由專門的渲染物件處理文字。 絕大多數渲染物件都不瞭解文字的複雜性。相反,文字由專門的渲染物件
RenderParagraph處理,它是渲染樹中的一個葉子節點。開發者不是透過繼承一個文字感知的渲染物件,而是透過組合將文字合併到使用者介面中。這種模式意味著RenderParagraph只要父級提供相同的佈局約束,就可以避免重新計算其文字佈局,這在即使進行“樹手術”時也很常見。 -
可觀察物件。 Flutter 同時使用模型觀察和響應式正規化。顯然,響應式正規化占主導地位,但 Flutter 對某些葉子資料結構使用了可觀察的模型物件。例如,
Animation在其值發生變化時會通知觀察者列表。Flutter 將這些可觀察物件從 Widget 樹移交給渲染樹,後者直接觀察它們,並在它們改變時僅使流水線中相應的階段失效。例如,Animation的改變可能僅觸發繪製階段,而不是同時觸發構建和繪製階段。
綜上所述,並將這些最佳化應用到由激進組合產生的大型樹結構中,這些最佳化對效能有著巨大的影響。
Element 樹與 RenderObject 樹的分離
#Flutter 中的 RenderObject 和 Element (Widget) 樹是同構的(嚴格來說,RenderObject 樹是 Element 樹的一個子集)。一個顯而易見的簡化是將這些樹合併為一棵樹。然而,在實踐中,將這些樹分開有許多好處:
-
效能。 當佈局發生變化時,只需遍歷佈局樹的相關部分。由於組合,Element 樹通常有很多額外的節點,這些節點原本必須被跳過。
-
清晰度。 更清晰的關注點分離使得 Widget 協議和渲染物件協議各自能夠針對其特定需求進行專門最佳化,簡化了 API 介面,從而降低了引入 Bug 的風險和測試負擔。
-
型別安全。 渲染物件樹可以更具型別安全性,因為它可以在執行時保證子節點將具有適當的型別(每個座標系例如都有其對應的渲染物件型別)。組合 Widget 可以不瞭解佈局期間使用的座標系(例如,同一個暴露應用模型部分的 Widget 既可以用於盒子佈局,也可以用於 Sliver 佈局),因此在 Element 樹中,驗證渲染物件的型別將需要遍歷樹。
無限滾動
#無限滾動列表對工具包來說歷來是眾所周知的難題。Flutter 透過基於構建器(builder)模式的簡單介面支援無限滾動列表,在該模式中,ListView 使用回撥函式在 Widget 對使用者滾動可見時按需構建它們。支援此功能需要視口感知佈局和按需構建 Widget。
視口感知佈局
#像 Flutter 中的大多數事物一樣,可滾動 Widget 是透過組合構建的。可滾動 Widget 的外部是一個 Viewport(視口),這是一個“內部更大”的盒子,意味著其子元素可以延伸到視口邊界之外,並可以滾動進入檢視。然而,視口並不擁有 RenderBox 子節點,而是擁有 RenderSliver 子節點,即所謂的 Slivers,它們具有視口感知的佈局協議。
Sliver 佈局協議與盒子佈局協議的結構相匹配,即父級將約束向下傳遞給子級並獲得幾何結構作為回報。然而,這兩種協議之間的約束和幾何資料有所不同。在 Sliver 協議中,子級會獲得有關視口的資訊,包括剩餘的可見空間量。它們返回的幾何資料支援各種滾動相關效果,包括可摺疊標題和視差效果。
不同的 Slivers 以不同的方式填充視口中的可用空間。例如,一個產生線性子列表的 Sliver 會按順序佈局每個子節點,直到該 Sliver 耗盡子節點或耗盡空間。同樣,一個產生二維子節點網格的 Sliver 僅填充其網格中可見的部分。因為它們知道有多少空間是可見的,所以 Slivers 可以只產生有限數量的子節點,即使它們有潛力產生無限數量的子節點。
Slivers 可以被組合起來建立定製的可滾動佈局和效果。例如,單個視口可以有一個可摺疊的標題,後面跟著一個線性列表,然後是一個網格。所有三個 Slivers 都將透過 Sliver 佈局協議進行協作,僅生成那些實際在視口中可見的子節點,無論這些子節點屬於標題、列表還是網格6。
按需構建 Widget
#如果 Flutter 採用嚴格的“先構建-再佈局-再繪製”流水線,上述內容將不足以實現無限滾動列表,因為關於視口可見空間大小的資訊僅在佈局階段可用。沒有額外的機制,佈局階段對於構建填充空間所需的 Widget 來說太晚了。Flutter 透過在流水線的構建和佈局階段交織處理解決了這個問題。在佈局階段的任何時刻,只要新 Widget 是當前執行佈局的渲染物件的後代,框架就可以開始按需構建它們。
交織構建和佈局之所以可能,僅歸功於構建和佈局演算法中對資訊傳播的嚴格控制。具體而言,在構建階段,資訊只能向下傳播。當渲染物件正在執行佈局時,佈局遍歷尚未訪問該渲染物件下方的子樹,這意味著在該子樹中透過構建產生的寫入操作不會使迄今為止進入佈局計算的任何資訊失效。同樣,一旦佈局從一個渲染物件返回,該渲染物件在本次佈局中將永遠不會再次被訪問,這意味著任何由後續佈局計算產生的寫入操作都無法使構建該渲染物件子樹所使用的資訊失效。
此外,線性調解和樹手術對於在滾動時高效更新 Element,以及當 Element 滾動進入和離開視口邊緣時修改渲染樹至關重要。
API 人體工程學
#只有當框架能夠被有效使用時,速度才是有意義的。為了引導 Flutter 的 API 設計向更高的可用性發展,Flutter 在開發者的大規模 UX 研究中進行了反覆測試。這些研究有時確認了既有的設計決策,有時幫助引導了功能的優先順序排序,有時則改變了 API 設計的方向。例如,Flutter 的 API 有詳細的文件;UX 研究證實了此類文件的價值,但也特別強調了對示例程式碼和說明性圖表的需求。
本節討論了 Flutter 在 API 設計中為輔助可用性而做出的一些決策。
專有 API 設計以契合開發者的思維模式
#Flutter Widget、Element 和 RenderObject 樹中節點的基類沒有定義子模型。這允許每個節點針對適用於該節點的子模型進行專門化。
大多數 Widget 物件只有一個子 Widget,因此只暴露一個 child 引數。一些 Widget 支援任意數量的子節點,並暴露一個接收列表的 children 引數。一些 Widget 則根本沒有子節點,不預留記憶體,也沒有任何引數。類似地,RenderObject 暴露了特定於其子模型的 API。RenderImage 是一個葉子節點,沒有子節點的概念。 RenderPadding 接收單個子節點,因此儲存單個指標。 RenderFlex 接收任意數量的子節點並將其作為連結串列管理。
在某些罕見的情況下,會使用更復雜的子模型。 RenderTable 渲染物件的建構函式接收一個子節點陣列的陣列,該類暴露了控制行數和列數的 Getter 和 Setter,並有特定的方法透過 x,y 座標替換單個子節點、新增行、提供新的子節點陣列,以及用單個數組和列計數替換整個子列表。在實現中,該物件不像大多數渲染物件那樣使用連結串列,而是使用可索引的陣列。
Chip Widget 和 InputDecoration 物件具有與相關控制元件上的插槽匹配的欄位。如果採用“一刀切”的子模型,會將語義強行疊加在子列表之上(例如,定義第一個子節點為字首值,第二個為字尾),而專用的子模型則允許改為使用專用的命名屬性。
這種靈活性允許以最符合其角色慣用語的方式操作這些樹中的每個節點。很少有人想在表格中插入單元格導致所有其他單元格重新排列;同樣,很少有人想按索引而不是按引用從 Flex 行中刪除子節點。
RenderParagraph 物件是最極端的情況:它擁有一個完全不同型別的子節點 TextSpan。在 RenderParagraph 邊界處,RenderObject 樹過渡為 TextSpan 樹。
這種使 API 專門化以滿足開發者期望的整體方法,不僅應用於子模型。
存在一些相當瑣碎的 Widget,它們存在的目的就是為了讓開發者在尋找問題解決方案時能找到它們。一旦知道如何操作,向行或列中新增空格很容易,只需使用 Expanded Widget 和一個零尺寸的 SizedBox 子節點即可,但探索出這種模式是不必要的,因為搜尋 space 就能找到 Spacer Widget,它直接使用 Expanded 和 SizedBox 來實現該效果。
類似地,隱藏一個 Widget 子樹也很容易,只需在構建時不包含該 Widget 子樹即可。然而,開發者通常期望有一個 Widget 來執行此操作,因此 Visibility Widget 的存在就是為了將這種模式封裝在一個瑣碎的可重用 Widget 中。
顯式引數
#UI 框架往往擁有許多屬性,開發者很少能記住每個類每個建構函式引數的語義含義。由於 Flutter 使用響應式正規化,Flutter 中的構建方法通常包含許多建構函式呼叫。透過利用 Dart 對命名引數的支援,Flutter 的 API 能夠保持此類構建方法的清晰易懂。
這種模式擴充套件到了任何具有多個引數的方法,特別擴充套件到了任何布林引數,以便方法呼叫中孤立的 true 或 false 字面量始終具有自文件化功能。此外,為了避免 API 中雙重否定造成的常見混淆,布林引數和屬性始終以肯定形式命名(例如,enabled: true 而不是 disabled: false)。
填平陷阱
#Flutter 框架在許多地方使用的一種技術是將 API 定義為錯誤條件不存在。這消除了整類的錯誤考量。
例如,插值函式允許插值的兩端均為 null,而不是將其定義為錯誤情況:在兩個 null 值之間進行插值始終為 null,從 null 值插值到非 null 值,或反之,等同於插值到給定型別的零模擬值。這意味著不小心將 null 傳遞給插值函式的開發者不會遇到錯誤情況,而是會得到一個合理的結果。
一個更微妙的例子是 Flex 佈局演算法。該佈局的概念是,分配給 Flex 渲染物件的空間在各子節點之間劃分,因此 Flex 的尺寸應為可用空間的全部。在最初的設計中,提供無限空間會導致失敗:這意味著 Flex 應該被無限縮放,這是一個無用的佈局配置。相反,API 進行了調整,以便當無限空間分配給 Flex 渲染物件時,渲染物件會自行調整大小以適應子節點所需的尺寸,從而減少了可能的錯誤情況。
這種方法還用於避免建立允許產生不一致資料的建構函式。例如,PointerDownEvent 建構函式不允許將 PointerEvent 的 down 屬性設定為 false(這會自相矛盾);相反,該建構函式沒有 down 欄位的引數,並始終將其設定為 true。
通常,該方法是為輸入域中的所有值定義有效的解釋。最簡單的例子是 Color 建構函式。它不接收四個整數(分別代表紅、綠、藍和 alpha),每個整數都可能超出範圍,而是接收一個單一的整數值,並定義每一位的含義(例如,最低八位定義紅色分量),這樣任何輸入值都是有效的顏色值。
一個更復雜的例子是 paintImage() 函式。此函式接收十一個引數,其中一些引數的輸入域非常廣泛,但它們經過精心設計,大部分相互正交,因此幾乎沒有無效組合。
積極地報告錯誤情況
#並非所有錯誤條件都能透過設計排除。對於剩餘的錯誤,在除錯構建中,Flutter 通常會嘗試儘早捕獲並立即報告它們。斷言(Asserts)被廣泛使用。建構函式引數被詳細點檢。生命週期受到監控,當檢測到不一致時,它們會立即丟擲異常。
在某些情況下,這被髮揮到了極致:例如,執行單元測試時,無論測試在做什麼,每個執行佈局的 RenderBox 子類都會積極檢查其固有尺寸(intrinsic sizing)方法是否滿足固有尺寸契約。這有助於捕獲原本可能未被觸發的 API 錯誤。
當丟擲異常時,它們會包含所有可用的資訊。Flutter 的一些錯誤訊息會主動探測相關的堆疊跟蹤,以確定實際 Bug 最可能的位置。其他訊息則遍歷相關的樹以確定錯誤資料的來源。最常見的錯誤包括詳細說明,在某些情況下甚至包含避免該錯誤的示例程式碼,或指向進一步文件的連結。
響應式正規化
#可變的基於樹的 API 遭受二分訪問模式的困擾:建立樹的初始狀態通常使用與後續更新非常不同的一組操作。Flutter 的渲染層使用了這種正規化,因為它是維護持久樹的有效方式,這對於高效佈局和繪製至關重要。然而,這意味著與渲染層的直接互動充其量是尷尬的,往壞了說是容易出錯的。
Flutter 的 Widget 層引入了一種使用響應式正規化的組合機制7來操作底層渲染樹。該 API 透過將樹建立和樹變動步驟合併為一個單一的樹描述(構建)步驟來抽象化樹操作,在每次系統狀態改變後,開發者描述使用者介面的新配置,框架計算為反映此新配置所需的系列樹變動。
插值(Interpolation)
#由於 Flutter 框架鼓勵開發者描述與當前應用狀態相匹配的介面配置,因此存在一種在這些配置之間進行隱式動畫的機制。
例如,假設在狀態 S1 中介面由一個圓組成,而在狀態 S2 中由一個正方形組成。如果沒有動畫機制,狀態變化將導致刺眼的介面跳變。隱式動畫允許圓在幾幀內平滑地變為正方形。
每個可以隱式動畫化的功能都有一個有狀態的 Widget,它會記錄輸入值的當前狀態,並在輸入值發生變化時開始動畫序列,在指定的持續時間內從當前值過渡到新值。
這是使用不可變物件的 lerp(線性插值)函式實現的。每個狀態(在此例中為圓和正方形)都被表示為一個不可變物件,該物件配置了適當的設定(顏色、描邊寬度等)並且知道如何繪製自身。當動畫期間需要繪製中間步驟時,開始和結束值與表示動畫點位置的 t 值一起傳遞給相應的 lerp 函式,其中 0.0 代表 start,1.0 代表 end8,該函式返回代表中間階段的第三個不可變物件。
對於圓到正方形的過渡,lerp 函式將返回一個代表“圓角正方形”的物件,其半徑為從 t 值匯出的分數,顏色使用顏色的 lerp 函式進行插值,描邊寬度使用雙精度浮點數的 lerp 函式進行插值。該物件實現了與圓和正方形相同的介面,因此在被請求時能夠繪製自身。
這種技術允許狀態機制、狀態到配置的對映、動畫機制、插值機制以及關於如何繪製每一幀的特定邏輯完全彼此分離。
這種方法具有廣泛的適用性。在 Flutter 中,不僅 Color 和 Shape 等基本型別可以進行插值,更復雜的型別如 Decoration、TextStyle 或 Theme 也可以。這些型別通常由本身可以插值的元件構成,而對更復雜的物件進行插值通常就像遞迴地插值描述複雜物件的所有值一樣簡單。
一些可插值物件由類層次結構定義。例如,形狀由 ShapeBorder 介面表示,存在各種各樣的形狀,包括 BeveledRectangleBorder、BoxBorder、CircleBorder、RoundedRectangleBorder 和 StadiumBorder。單個 lerp 函式無法預見所有可能的型別,因此介面定義了 lerpFrom 和 lerpTo 方法,靜態 lerp 方法會將呼叫委派給這些方法。當被告知從形狀 A 插值到形狀 B 時,首先詢問 B 是否可以 lerpFrom A,如果不能,則詢問 A 是否可以 lerpTo B。(如果都不行,則函式在 t 值小於 0.5 時返回 A,否則返回 B。)
這允許類層次結構被任意擴充套件,後續新增的類能夠實現之前已知的類與自身之間的插值。
在某些情況下,插值本身無法由任何可用類描述,因此會定義一個私有類來描述中間階段。例如,在 CircleBorder 和 RoundedRectangleBorder 之間進行插值就是這種情況。
該機制還有一個額外的優勢:它可以處理從中間階段到新值的插值。例如,在圓到正方形的過渡中途,形狀可能會再次發生變化,導致動畫需要插值到三角形。只要三角形類可以 lerpFrom 圓角正方形的中間類,過渡就可以無縫執行。
結論
#Flutter 的口號“一切皆 Widget”圍繞著透過組合 Widget 來構建使用者介面,而這些 Widget 本身又由越來越基礎的 Widget 構成。這種激進組合的結果是產生了大量的 Widget,需要精心設計的演算法和資料結構來高效處理。透過一些額外的設計,這些資料結構也使得開發者能夠輕鬆建立無限滾動列表,當 Widget 對使用者可見時按需構建它們。
腳註
-
至少對於佈局而言。它可能會為了繪製、必要時為了構建可訪問性樹以及必要時為了點選測試而被重新訪問。 ↩
-
當然,現實情況要複雜一些。某些佈局涉及固有維度或基線測量,這些確實涉及相關子樹的額外遍歷(使用了積極的快取來減輕最壞情況下二次效能的可能)。然而,這些情況非常罕見。特別是,對於縮減封裝(shrink-wrapping)的常見情況,並不需要固有維度。 ↩
-
從技術上講,子節點的位置不是其
RenderBox幾何結構的一部分,因此實際上不需要在佈局期間計算。許多渲染物件隱式地將其唯一的子節點定位在其自身原點相對的 0,0 處,這不需要任何計算或儲存。一些渲染物件避免在最後一刻(例如繪製階段)之前計算其子節點的位置,以避免如果後續不繪製它們則完全免除該計算。 ↩ -
此規則有一個例外。正如按需構建 Widget 部分所討論的,一些 Widget 可以因佈局約束的變化而重新構建。如果一個 Widget 在同一幀中因佈局約束的變化而受到影響,同時又因無關原因標記自己為髒,它將被更新兩次。這種冗餘構建僅限於 Widget 本身,不會影響其後代。 ↩
-
Key 是一個可選關聯到 Widget 的不透明物件,其相等性運算子用於影響調解演算法。 ↩
-
為了可訪問性,併為了給應用程式在 Widget 構建到螢幕上出現之間提供幾毫秒的額外時間,視口會在可見 Widget 之前和之後建立(但不繪製)幾百畫素的 Widget。 ↩
-
這種方法最早由 Facebook 的 React 庫推廣。 ↩
-
實際上,t 值被允許超過 0.0-1.0 的範圍,對於某些曲線確實如此。例如,“彈性”曲線為了表現反彈效果會短暫超調。插值邏輯通常能夠根據需要在外推開始或結束之外的部分進行處理。對於某些型別,例如在插值顏色時,t 值實際上被限制在 0.0-1.0 範圍內。 ↩