Flutter 入門與實戰(三十九):渲染模式詳解|8月更文挑戰

島上碼農發表於2021-08-01

本文翻譯自 Flutter 官網整體架構介紹的渲染部分:Flutter architectural overview,以便後續的篇章更好地理解狀態管理和渲染機制。

前言

作為一個跨平臺的框架,Flutter 的渲染機制和很多混合開發的框架具有很大的不同。目前諸如 React-Native,UniApp,Weex 等框架實際上時在原生 UI 基礎上做了一層抽象對映,試圖抹平不同平臺的差異。大部分的實現時基於 Javascript 與原生進行轉譯互動,實際渲染還是依賴於原生平臺。這種方式的好處是保留了原生 UI 的特性,當然也會帶來一個很大的缺陷,那就是不同平臺的差異性——雖然是同一套程式碼,但是不同平臺執行的介面效果和設計效果不一樣

Flutter 的方式與上述的框架不同,實際上的渲染過程不依賴於原生,而是通過 C/C++編寫的 Skia 渲染引擎完成介面渲染的。繪製介面的Dart 程式碼會被編譯成原生程式碼,但是使用的是 Skia 完成渲染。Flutter 內建了 Skia 渲染引擎,使得即便是使用者的手機沒有更新到最新版本的手機作業系統也能夠保持最新的渲染效能。

從互動到 GPU

以一個使用者輸入為例,整個互動到 CPU 的渲染過程如下圖所示,其中框起來的部分就是渲染的過程。

渲染過程.png

Build 環節

下面簡單的程式碼片段構建了一個層級簡單的元件樹。

Container(
  color: Colors.blue,
  child: Row(
    chindren: [
      Image.network('https://www.juejin.com/1.png'),
      const Text('A'),
    ],
  ),
);
複製程式碼

當 Flutter需要渲染上面這個元件樹時,它會呼叫 build 方法。build 方法會基於當前 app 的狀態返回繪製 UI 的元件樹。在這個過程中,build 方法也可以根據狀態來決定是否需要引入新的元件。例如在上面的示例程式碼中,Container 擁有一個 colorchild 屬性。從 Container 的原始碼可以看到,如果它的顏色屬性不為空,就會插入一個 ColoredBox 來表示其顏色。

if (color != null) current = ColoredBox(color: color!, child: current);
複製程式碼

相應地,ImageText 元件也可能 在構建過程中插入子元件,例如 RawImageRichText。最終的元件樹的層級會比程式碼上的層級更深,如下圖所示。這也是為什麼使用除錯工具(如 Flutter Inspector)時,會發現元件樹的層級相對程式碼的層級深很多。

元件樹.png

在構建階段,Flutter 會將程式碼的元件轉換為相應的元素樹(element tree),每個元素對應一個元件(widget)。每個元素代表了樹中對應位置的特定元件例項。元素有兩種基本型別:

  • ComponentElement:其他元素的宿主(host)。
  • RenderObjectElement:參與佈局和繪製的元素。RenderObjectElement是他們元件的中間媒介,是實際的 渲染物件(RenderObject)。BuildContext處理元件樹中 widget的位置,因此可以通過BuildContext獲取元素的引用。這個 context就是我們呼叫類似 Theme.of(context)context,會當做引數傳遞到 build 方法。

上面的元件樹轉換為元素樹後是下面的樣子。

渲染過程-元件樹-轉換元素樹.png

由於元件是不可變的,且在節點之間存在父子關係,任何元件樹的變化(例如前面的例子中 Text('A')改為 Text('B'))會返回一個新的元件集。但是這並不意味著底層的元素對映必須重建。元素樹在顯示幀切換的時候會保持,因此元素在效能上顯得十分關鍵——即便在元件樹完全銷燬的情況允許 Flutter通過快取的元素對映繼續正常執行。因此,只需要檢查元件樹中的變化,Flutter 可以僅僅對元素樹那些需要重新配置的元素做重建操作。

佈局和渲染

通常,應用不會只有單個元件。UI 框架很重要的一部分工作就是在繪製到螢幕上前,高效地對元件樹進行佈局,確定每個元素尺寸和位置。在渲染樹中的每個節點的基類是 RenderObject,定義了佈局和繪製的抽象模型。每個 RenderObject 知道其父節點,但是對子節點的資訊很少知道,也不知道子節點的佈局約束。這使得 有效抽象的RenderObject能夠處理各種各樣的場景。 在構建階段,對於元素樹中繼承自 RenderObject每個 RenderObjectElement,Flutter 建立或更新其物件。RenderObject 是一些基礎的類:RenderParagragh 負責渲染文字,RenderImage渲染圖片,RenderTransform 會在繪製其子元素前做轉換操作。上面的例子到了渲染環節後實際渲染的階段只會渲染那些 RenderObjectElement 物件。

渲染過程-元件樹-元素樹-渲染樹.png

大部分 Flutter 元件是通過繼承自 RenderBox的物件進行渲染的,這個物件代表二維笛卡爾座標系固定尺寸的 RenderObjectRenderBox 提供了盒子約束模型,為每個要被渲染的元件建立最小和最大的寬高。 執行佈局的時候,Flutter 使用深度優先遍歷(depth-first tranversal)的方式,然後將尺寸元素從父節點到子節點的方式傳遞下去。為了確定自身的尺寸,子元素必須遵循父節點傳遞下來的約束。在父節點建立的約束規則內,子元素會向父節點傳遞其尺寸資訊。

渲染過程-約束和尺寸傳遞.png 完成單次樹的遍歷後,每個物件在其父節點的佈局約束下,都會有一個設定的尺寸,然後就可以呼叫 paint 方法進行繪製了。

盒子約束模型非常強大,只需要O(n)的時間複雜度就可以完成物件的佈局:

  • 父節點可以通過設定最大最小的約束為同一個值規定子元素的尺寸。例如,對於手機 App 來說,最頂級的渲染物件會將其子節點的尺寸設定為螢幕尺寸(子元素可以選擇如何利用空間,例如可以在約束條件內設定居中渲染)。
  • 父節點可以規定子元素的寬度然後給子元素動態的高度(或者反過來給指定的高度,然後寬度動態設定)。實際的例子就是文字,可以在橫向上滿足約束,然後縱向上根據文字的數量動態設定高度。

在子節點物件需要知道還有多少可用空間,從而決定如何渲染自身內容時依然能夠發揮作用。通過使用 LayoutBuilder 元件,子節點物件可以檢查傳遞下來的約束,然後決定如何使用他們。例如:

Widget build(BuildContext context) {   
  return LayoutBuilder(
    builder: (context, constraints) {       
      if (constraints.maxWidth < 600) {         
        return const OneColumnLayout();       
      } else {         
        return const TwoColumnLayout();       
      }     
    },   
  ); 
}
複製程式碼

所有 RenderObject的根節點時 RenderView,這代表了渲染樹的全部輸出。當平臺請求渲染新的畫面幀時,將會呼叫 compositeFrame 方法,這是渲染樹根節點RenderView 的一部分。這會建立一個 SceneBuilder 來觸發畫面的更新。當場景完成後,RenderView 物件會將組合好的畫面傳遞給在 dart:ui 中定義的 Window.render方法,然後將控制權交給 GPU 去完成畫面的繪製。

總結

本篇介紹了 Flutter 渲染工作的基本機制,通過了解渲染機制能夠幫助我們瞭解 Widget 和實際渲染的對應關係,從而在後續的狀態管理中更好地理解狀態管理工具如何完成元件的更新。接下來我們將開啟Flutter狀態管理相關的篇章。


我是島上碼農,微信公眾號同名。這是Flutter 入門與實戰的專欄文章。

??:覺得有收穫請點個贊鼓勵一下!

?:收藏文章,方便回看哦!

?:評論交流,互相進步!

相關文章