Flutter原理:三棵重要的樹(渲染過程、佈局約束、應用檢視的構建等)

Meandni發表於2019-05-05

瞭解 HTML 的讀者一定聽說過 DOM 樹這個概念,它由頁面中每一個控制元件組成,這些控制元件所形成的一種天然的巢狀關係使其可以表示為 “樹” 結構,我們也可以將這個概念應用在 Flutter 中,例如預設的計數器應用的結構如下圖:

img

我們也可以看到上圖中每個控制元件所形成的樹結構中隱含了一些關係,例如在上圖中,我們可以說 Text 元件是 Column 元件的子元件,Scaffold 是 AppBar 的父元件,這樣的層級關係使得每個控制元件都清晰的連線到了一起,樹結構由此而來。

我們也知道 Container、Text 等元件都屬於 Widget,所以在 Flutter 中我們將這種樹稱為 Widget 樹,也可以叫做控制元件樹,它就表示了我們在 dart 程式碼中所寫的控制元件的結構。

img
另外,在 Flutter 體系結構中,真正做元件渲染在螢幕上這個任務的並非在 控制元件層(Widget)層,而是在渲染(Rending)層,那麼我們在程式碼中所寫元件又是怎麼通過渲染層顯示的呢?Flutter 中又引入了 Element 樹和 RenderingObject 樹兩棵樹。

Element 是什麼,我們可以把它稱之為 Widget 另一種抽象。讀者也可以把它看作一個更為實際控制元件,因為在我們的手機螢幕上顯示的控制元件並非我們在程式碼中所寫的 Widget,我們在程式碼中所使用的像 Container、Text 等這類元件和其屬性只不過是我們想要構建的元件的配置資訊,當我們第一次呼叫 build() 方法想要在螢幕上顯示這些元件時,Flutter 會根據這些資訊生成該 Widget 控制元件對應的 Element,同樣地,Element 也會被放到相應的 Element 樹當中。在 Flutter 中,一個 Widget 通過多次複用可以對應多個 Element 例項,Element 才是我們真正在螢幕上顯示的元素。

Element 與 Widget 另一個區別在於,Widget 天然是不可變的(immutable),它如要更新便需要重建,如果想要把可變狀態與 Widget 關聯起來,可以使用 StatefulWidget,StatefulWidget 通過使用StatefulWidget.createState 方法建立 State 物件,並將之擴充到 Element 以及合併到樹中;

這裡,為了更為深刻的理解以上描述的含義,我們可以舉一個更為形象的例子。Widget 作為大 Boss,他把近期的戰略部署,即配置資訊,寫在紙上下發給經理人 Element,Element 看到詳細的配置資訊開始真正的開起活來了。我們還需要注意一點,大 Boss 隨時會改變戰略部署,然後不會在原有的紙上修改而是重新寫下來,這時經理人為了減少工作量需要將新的計劃與舊的計劃比較來作出相應的更新措施。這也是 Flutter 框架層做的一大優化。下面又來了,Element 作為經理人也很體面,當然不會把活全乾完,於是又找了一個 RenderObject 的員工來幫它做粗重的累活。

RenderObject 在 Flutter 當中做元件佈局渲染的工作,其為了元件間的渲染搭配及佈局約束也有對應的 RenderObject 樹,我們也稱之為渲染樹。

熟悉了 Flutter 中的上述三顆樹,相信讀者會對元件的渲染過程有了一個清晰的認識,這對我們之後學習常用元件有很大的幫助,我們需要用不同的眼光去看待我們所建立的佈局和控制元件,之後我們也會更加深入的去理解其中更不為人知的奧祕。

元件渲染過程簡述

從上文中,我們知道控制元件樹中的每個控制元件都會實現一個 RenderObject 物件做渲染任務,並將所有的RenderObject 組成渲染樹。Flutter 渲染元件的過程如下:

img

Flutter 的渲染過程由使用者的輸入開始,當接受到使用者輸入的訊號時,就會觸發動畫的進度更新,例如我們第一次渲染時的啟動動畫,或者我們在滾動手機螢幕時單個列表項複用時的移動動畫。之後便需要開始檢視資料的構建(build),這一步中 Flutter 建立了前文所描述的三棵檢視樹。

在這之後,檢視才會進行佈局(layout),計算各個部分的大小,然後進行繪製(paint),生成每個檢視的視覺資料,這部分的任務主要就是由 RenderObject 所做。這裡,Flutter 中的佈局過程可用下圖表示,在上述構建完成渲染樹後,父渲染物件會將佈局約束資訊向下傳遞,子渲染物件根據自己的渲染情況返回 Size,Size 資料會向上傳遞,最終父渲染物件完成佈局過程。

img

最後一步進行“光柵化”(Rasterize),前一步得到合成的檢視資料其實還是一份向量描述資料,光柵化幫助把這份資料真正地生成一個一個的畫素填充資料。在 Flutter 中,光柵化這個步驟被放在了 Engine 層中。

在日常開發學習中,我們只需要在程式碼層配置好我們的 Widget 樹,瞭解各種 Widget 特性及使用方法,其餘的工作都可以交給我們的框架層去實現。

簡述計數器應用的渲染過程

佔坑

元素樹詳解

我們已經知道了各類控制元件的作用及其使用方法,這些 Widget 被我們開發人員配置了多個屬性來定義它的展現形式,例如配置 Text 元件需要顯示的字串,配置輸入框元件需要顯示的內容。我們 Element 樹會記錄這些配置資訊。熟悉 React 的讀者可能瞭解過其中的 “虛擬 DOM” 這個概念,上述 Flutter 這種操作也正體現了這一概念。Widget 是不可變,它的改變就意味著要重建,而其重建也非常頻繁,如果我們將更多的任務都交給它將會對效能造成很大的損傷,因此我們把 Widget 元件當作一個虛擬的元件樹,而真正被渲染在螢幕上的其實是 Elememt 這棵樹,它持有其對應 Widget 的引用,如果他對應的 Widget 發生改變,它就會被標記為 dirty Element,於是下一次更新檢視時根據這個狀態只更新被修改的內容,從而達到提升效能的效果。

每次,當控制元件掛載到控制元件樹上時,Flutter 呼叫其 createElement() 方法,建立其對應的 Element。Flutter 再將這個 Element 放到元素樹上,並持有建立它控制元件的引用,如下圖:

img

控制元件會有它的子樹:

img

子控制元件也會建立相應 Element 被放在元素樹上:

img

Element 中的狀態

我們上文提到了 Widget 的不可變性,相應的 Element 就有其可變性,正如我們前文所說的它被標記為 dirty Element 便是作為需要更新的狀態,另外一個我們需要格外注意的是,有狀態元件(statefulWidget)對應的 State 物件其實也被 Element 所管理,如下圖所示。

img

Flutter 中的 Widget 一直在重建,每次重建之後,Element 都會採用相應的措施來確定是否我對應的新控制元件跟之前引用舊控制元件是否有所改變,如果沒改變則只需要做更新操作,如果前後不同則會重建立。那麼,Element 根據什麼來確定控制元件是否改變呢?它會比較 Widget 以下兩個屬性:

  • 元件型別
  • Widget 的 Key (如果有)

元件型別即前後控制元件的是否是同一個類所建立的,Key 即為每個控制元件的唯一標識。

例子證明 Elment 持有元件狀態

佔坑

渲染樹詳解

我們已經大致知道 Flutter 中的三棵重要的樹及 Element 樹的工作原理,其中第三棵渲染樹的任務就是做元件的具體的佈局渲染工作。

渲染樹上每個節點都是一個繼承自 RenderObject 類的物件,其由 Element 中的 renderObject 或 RenderObjectWidget 中的 createRenderObject 方法生成,該物件內部提供多個屬性及方法來幫助框架層中的元件如何佈局渲染。

我們知道 StatelessWidget 和 StatefulWidget 兩種直接繼承自 Widget 的類,在 Flutter 中,還有另一個類 RenderObjectWidget 也同樣直接繼承自 Widget,它沒有 build 方法,可通過 createRenderObject 直接建立 RenderObject 物件放入渲染樹中。Column 和 Row 等控制元件都間接繼承自RenderObjectWidget。

主要屬性和方法如下:

  • constraints 物件,從其父級傳遞給它的約束
  • parentData 物件,其父物件附加有用的資訊。
  • performLayout 方法,計算此渲染物件的佈局。
  • paint 方法,繪製該元件及其子元件。

RenderObject 作為一個抽象類。每個節點需要實現它才能進行實際渲染。擴充套件 RenderOject 的兩個最重要的類是RenderBox 和 RenderSliver。這兩個類分別是應用了 Box 協議和 Sliver 協議這兩種佈局協議的所有渲染物件的父類,其還擴充套件了數十個和其他幾個處理特定場景的類,並實現了渲染過程的細節,如 RenderShiftedBox 和 RenderStack 等等。

佈局約束

在上面,我們介紹元件渲染流程時,我們瞭解到了 Flutter 中的控制元件在螢幕上繪製渲染之前需要先進行佈局(layout)操作。其具體可分為兩個線性過程:從頂部向下傳遞約束,從底部向上傳遞佈局資訊,其過程可用下圖表示。

img

第一個線性過程用於傳遞佈局約束。父節點給每個子節點傳遞約束,這些約束是每個子節點在佈局階段必須要遵守的規則。就好像父母告訴自己的孩子 :“你必須遵守學校的規定,才可以做其他的事”。常見的約束包括規定子節點最大最小寬度或者子節點最大最小的高度。這種約束會向下延伸,子元件也會產生約束傳遞給自己的孩子,一直到葉子結點。

第二的線性過程用來傳遞具體的佈局資訊。子節點接受到來自父節點的約束後,會依據它產生自己具體的佈局資訊,如父節點規定我的最小寬度是 500 的單位畫素,子節點按照這個規則可能定義自己的寬度為 500 個畫素,或者低於 500 畫素的任何一個值。這樣,確定好自己的佈局資訊之後,將這些資訊告訴父節點。父節點也會繼續此操作向上傳遞一直到最頂部。

下面我們具體介紹有哪些具體的佈局約束可在樹中傳遞。Flutter 中有兩種主要的佈局協議:Box 盒子協議和 Sliver 滑動協議。這裡我們先以盒子協議為例展開具體的介紹。

在盒子協議中,父節點傳遞給其子節點的約束為 BoxConstraints。該約束規定了允許每個子節點的最大和最小寬度和高度。如下圖,父節點傳入 Min Width 為 150,Max Width 為 300 的 BoxConstraints:

img

當子節點接受到該約束,便可以取得上圖中綠色範圍內的值,即寬度在 150 到 300 之間,高度大於 100,當取得具體的值之後再將取得具體的大小的值上傳給父節點,從而達到父子的佈局通訊。

自定義一個 Center 控制元件

之後更新,大家也可以看各元件的原始碼探究其如何應用上面提到的原理。

應用檢視的構建

Flutter App 入口的部分發生於如下程式碼:

import 'package:flutter/material.dart';

// 這裡的 MyApp是一個 Widget
void main() => runApp(new MyApp());
複製程式碼

runApp函式接受一個 Widget型別的物件作為引數,也就是說在 Flutter的概念中,只存在 View,而其他的任何邏輯都只為 View的資料、狀態改變服務,不存在 ViewController(或者叫 Activity)。 接下來看 runApp做了什麼:

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..attachRootWidget(app)
    ..scheduleWarmUpFrame();
}

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, RendererBinding, WidgetsBinding {
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null)
      new WidgetsFlutterBinding();
    return WidgetsBinding.instance;
  }
}
複製程式碼

runApp 中,傳入的 widget 被掛載到根 widget 上。這個 WidgetsFlutterBinding 其實是一個單例,通過 mixin 來使用框架中實現的其他 binding 的 Service,比如手勢、基礎服務、佇列、繪圖等等。然後會呼叫 scheduleWarmUpFrame 這個方法,從這個方法註釋可知,呼叫這個方法會主動構建檢視資料。這樣做的好處是因為 Flutter 依賴 Dart 的 MicroTask 來進行幀資料構建任務的 schedule,這裡通過主動呼叫進行整個週期的 “熱身”,這樣最近的下次 VSync 訊號同步時就有檢視資料可提供,而不用等到 MicroTask 的 next Tick。

然後我們再來看 attachRootWidget 這個函式幹了什麼:

void attachRootWidget(Widget rootWidget) {
    _renderViewElement = new RenderObjectToWidgetAdapter<RenderBox>(
      container: renderView,
      debugShortDescription: '[root]',
      child: rootWidget
    ).attachToRenderTree(buildOwner, renderViewElement);
}
複製程式碼

attachRootWidget 把 widget交給了 RenderObjectToWidgetAdapter這座橋樑,通過這座橋樑,Element 被建立,並且同時能持有 Widget 和 RenderObject的引用。然後我們從上文就知道後面發生的就是第一次的檢視資料構建了。

從這一部分能印證了:Flutter應用通過 Widget、Element、RenderObject 三種樹結構來維護整個應用的檢視資料。

附言

在沒更新文章的這段期間一直在準備春招,原本就準備寫一些關於 Flutter 原理的文章,今天發現已經有不少大佬在解析原始碼,尤其看到了 戀貓de小郭 的文章寫得很好,希望我的一些總結也能幫助到大家吧!

我的部落格:meandni.com/2019/05/05/…

我的Github:github.com/MeandNi/

歡迎一起討論!

相關文章