flutter填坑之旅(widget原理篇)


Flutter 的跨平台思路快速讓他成為“新貴”,連跨平台界的老大哥 “JS” 語言都“視而不見”,大膽的選擇 Dart 也讓 Flutter 在前期的推廣中飽受爭議。
短短兩年,不算 PR ,Flutter 的 issue 已經有近 1.8 萬的 closed 和 8000+ open , 這代表了它的熱度,也代表着它需要面對的問題和挑戰。不支持 Release 模式下的熱更新,也讓用戶更多徘徊於 React Native 不願嘗試。
不過有一點可以確定的,那就是 Flutter 的版本號上是徹底戰勝了 React Naitve 。

我們可以看到,移動端跨平台的發展,從單純的套殼打包,到提供高性能的跨平台控件封裝,再到現在的控件與平台脫離的發展。 整個發展歷程,就是對 性能、復用、高效 的不斷追求。

先來看一張關於flutter框架的簡易圖

在這里插入圖片描述

一、Flutter Widget 的實現原理

Flutter 是 UI 框架,Flutter 內一切皆 Widget ,每個 Widget 狀態都代表了一幀,Widget 是不可變的。 那么 Widget 是怎么工作的呢?

如下圖可以看到,是一個簡單的 Flutter Widget 頁面代碼,頁面包含了一個標題和容易,那在頁面 build 時,它是怎么表繪制出來的呢?同時它是如何保證性能?而Widget 又是怎么樣的一個概念?后面我們將逐步揭曉。
在這里插入圖片描述
首先看上圖代碼,其實如圖的代碼並不是真正的 View 級別代碼,它們更像是配置文件。
而要知道 Widget 是如何工作的,這就涉及到 Flutter 的三大金剛:Widget 、 Element 、RenderObject 。 事實上,這三大金剛才能組成了 Flutter Framework 的基礎渲染閉環。

在這里插入圖片描述

如上圖所示,當一個 Widget 被“加載“的時候,它並不是馬上被繪制出來,而是會對應先創建出它的 Element ,然后通過 Element 將 Widget 的配置信息轉化為 RenderObject 實現繪制。

所以,在 Flutter 中大部分時候我們寫的是 Widget ,但是 Widget 的角色反而更像是“配置文件” ,真正觸發工作的其實是 RenderObject。
小結一下這里的關系就是:

  • Widget 是配置文件。
  • Element 是橋梁和倉庫。
  • RenderObject 是解析后的繪制和布局。

對應詳細的解釋就是:

  • 所以我們寫的 Widget,它需要轉化為相應的 RenderObject 去工作;
  • Element 持有 Widget 和 RenderObject ,作為兩者的橋梁,並保存着一些狀態參數,
  • 我們在 Flutter 框架中常見到的 BuildContext ,其實就是 Element 的抽象 ;
  • 最后框架會將 Widget 的配置信息,轉化到 RenderObject 內,告訴 Canvas 應該在哪個 Rect 內,繪制多大 Size 的數據。

所以 Widget 和我們以前的布局概念不一樣,因為 Widget 是不可變的(immutable),且只有一幀,且不是真正工作的對象,每次畫面變化,都會導致一些 Widget 重新 build 。

那到這里,我們可能就會關心性能的問題,Flutter 是如何保證性能呢?

1.1、Widget 的輕量級

其實就是回歸到了 Widget 的定位,作為“配置文件”,Widget 的變化,是否也會導致 Element 和 RenderObject 也會重新創建?

答案是不一定會, Widget 只是一個 “配置文件” 的作用,是非常輕量級的,它的存在,只是起到對 RenderObject 的數據進行配置的作用。

但是 RenderObject 就不一樣了,它涉及到了 layout、paint 等真實 的繪制操作,可以認為是一個真正的 “View” ,如果頻繁創建就會導性能出現問題。

所以在 Flutter 中,會有一系列的判斷,來處理 Widget 到 RenderObject 轉化的性能問題 ,這部分操作通常是在 Element 中進行的 , 例如 updateChild時,會有如下圖所示的判斷:
在這里插入圖片描述

  • 當 element.child.widget == widget.build() 時,就不會觸發 update 操作;

  • 在 update 時,canUpdate(element.child.widget, newWidget)返回 true, Element 才會被更新;(這里代碼中的 slot 一般為 Element 對象,有時候會傳空)

  • 其他還有利用 isRelayoutBoundary 、 isRepaintBoundary 等參數,來實現局部的更新判斷,比如:當執行 markNeedsPaint() 觸發繪制時,會通過 isRepaintBoundary 是否為 true , 往上確定了更新區域,通過 requestVisualUpdate 方法觸發更新往下繪制。

通過 isRepaintBoundary 參數, 對應的 RenderObject 可以組成一個 Layer 。

所以這就可以解答一些初學者的疑問,嵌套那么多 Widget ,性能會不會有問題?

這也體現出 Flutter 在布局上和其他框架不同的地方,你寫的 Widget 只是配置文件,堆疊嵌套了一堆控件,對最終的 RenderObject 而言,可能只是多幾個 Offset 和 Size 計算而已。

結合上面的理解,可以知道 Widget 大部分時候,其實只是輕量級的配置,對於性能問題,你更需要關心的是 Clip 、Overlay 、透明合成等行為,因為它們會需要產生 saveLayer 的操作,因為 saveLayer 會清空GPU繪制的緩存。

最后總結個面試點:

  • 同一個 Widget 可以同時描述多個渲染樹中的節點,作為配置文件是可以復用的。Widget 和 RenderObject 一般情況是一對多的關系。 ( 前提是在 Widget 存在 RenderObject 的情況。)

  • Element 是 Widget 的某個固定實例,與 RenderObject 一一對應。(前提是在 Element 存在 RenderObject 的情況。)

  • RenderObject 內 isRepaintBoundary 標示使得它們組成了一個個 Layer 區域。

isRepaintBoundary 為 true 時,該區域就是一個可更新繪制區域,而當這個區域形成時,就會新創建一個 Layer 。 但不是每個 RenderObject 都會有 Layer , 因為這受 isRepaintBoundary 的影響。

在這里插入圖片描述

注意,Flutter 中常見的 BuildContext ,其實就是 Element 的抽象,通過 BuildContext ,我們一般情況就可以對應獲得 Element ,也就是拿到了“倉庫的鑰匙” ,通過 context 就可以去獲取 Element 內持有的東西,比如前面所說的 RenderObject ,還有后面我們會談到 State 等。

1.2 Widget 的分類

這里我們將 Widget 分為如下圖所示分類:是否存在 State 、是否存在RenderObject 。

在這里插入圖片描述

其實還可以按照 RenderBox 和 RenderSliver 分類,但是篇幅原因以后再介紹。

1.2.1 是否存在 State

Flutter 中我們常用的 Widget 有:StatelessWidget 和 StatefulWidget 。

如下圖, StatelessWidget 的代碼很簡單,因為 Widget 是不可變的,傳入的 text 決定了它顯示的內容,並且 text 也算是 final 的。
在這里插入圖片描述

注意圖中 DemoPage 有個黃色警告,這是因為我們定義了 int i = 0 不是 final 導致的,在 StatelessWidget 中, 非 final 的變量起始容易產生誤解,因為 Widget 本事就是不可變的。

前面我們說過 Widget 都是不可變的,在這個基礎上,StatefulWidget 的 State ,幫我們實現了 Widget 的跨幀繪制 , 也就是在每次 Widget 重構時,可以通過 State 重新賦予 Widget 需要的配置信息,而這里的 State對象,就是存在每個 Element 里的。

同時,前面我們說過,Flutter 內的 BuildContext 其實就是 Element 的抽象,這說明我們可以通過 context 去獲取 Element 內的東西,比如 State 、RenderObject 、 Widget


Widget ancestorWidgetOfExactType
 State ancestorStateOfType
 State rootAncestorStateOfType
 RenderObject ancestorRenderObjectOfType
 

如下圖所示,保存在 State 中的 text ,當我們點擊按鍵時,setState 時它被標志為 “變化了” , 它可以主動發生改變,保存變量,不再只是“只讀”狀態了。

在這里插入圖片描述

1.2.2 容器 Widget/渲染 Widget

在 Flutter 中還有 容器 Widget 和 渲染Widget 的區別,一般情況下:

  • Text、Slider 、ListTile 等都是屬於渲染 Widget ,其內部主要是 RenderObjectElement ,對應有 RenderObject 參數。

  • StatelessWidget / StatefulWidget 等屬於容器 Widget ,其內部使用的是 ComponentElement , ComponentElement 本身是不存在 RenderObject 的。

所以作為容器 Widget, 獲取它們的 RenderObject 時,獲取到的是 build后的樹結構里,最上面渲染 Widget的 RenderObject 。

在這里插入圖片描述

如上圖所示 findRenderObject 的實現,最終就是獲取 renderObject,在遇到 ComponentElement 時,執行的是 element.visitChildren(visit); , 遞歸直到找到 RenderObjectElement ,再返回它的 renderObject。

獲取 RenderObject 在 Flutter 里很重要的,因為獲取控件的位置和大小等,都需要通過 RenderObject 獲取。

1.3 RenderObject

Flutter 中各類 RenderObject 的實現,大多都是顆粒度很細,功能很單一的存在 :
在這里插入圖片描述
然而接觸過 Flutter 的同學應該知道 Container 這個 Widget ,Container的功能卻不顯單一,這是為什么呢?

如下圖,因為 Container 其實是容器 Widget , 它只是把其他“單一”的 Widget 做了二次封裝,然后通過配置參數來達到 “多功能的效果” 而已。
在這里插入圖片描述
所以 Flutter 開發中,我們經常會根據功能定義出各類如 Continer、Scaffold 等腳手架模版,實現靈活與復用的界面開發。

回歸到 RenderObject ,事實上 RenderObject 還屬於比較“低級”的階段,因為繪制到屏幕上我們還需要坐標體系和布局協議等,所以 大部分 Widget 的 RenderObject 會是子類 RenderBox (RenderSliver 例外) , 因為 RenderObject 本身只實現了基礎的 layout 和 paint ,而繪制到屏幕上,我們需要的坐標和大小等,這些內容是在 RenderBox 中開始實現。

RenderSliver 主要是在滾動控件中繼承使用。

比如控件被繪制在 x=10,y=20 的位置,然后大小由 parent 對它進行約束顯示,RenderBox 繼承了 RenderObject,在其基礎上實現了 笛卡爾坐標系和布局協議。

這里我們通過 Offstage 這個 Widget ,看下其 RenderBox 子類的實現邏輯, Offstage 是用於控制 child 是否顯示的作用,如下圖,可以看到 RenderOffstage 對於 offstage 標志位的內部邏輯:
在這里插入圖片描述
那么 Flutter 中的布局協議是什么呢?

簡單來說就是 child 和 parent 之間的大小應該怎么顯示,由誰決定顯示區域。 相信從 Android 到接觸 Flutter 的同學有這樣的疑惑, Flutter 中的 match_parent 和 wrap_content 邏輯需要怎么設置?

就我們從一個簡單的代碼分析,如下圖所示,Row 布局我們沒有設置任何大小,它是怎么確定自身大小的呢?

在這里插入圖片描述
我們翻閱源碼,可以發現其實 Flutter 中常用的 Row 、Column 等其實都是 Flex 的子類,只是對 Flex 做了簡單默認配置。

在這里插入圖片描述
那按照我們前面的理解,看一個 Widget 的實現邏輯,就應該看它的 RenderObject , 而在 Flex 布對應的 RenderFlex 中,我們可以看到如下一段代碼:
在這里插入圖片描述
可以看到在布局的時候,RenderFlex 首先要求 constraints != null,Flex 布局的上層中必須存在約束,不然肯定會報錯

之后,在布局時,Row 布局的 direction 是橫向的,所以 maxMainSize 為上層布局的最大寬度,然后根據我們配置的 mainAxisSize 的參數:

  • 當 mainAxisSize 為 max 時,我們 Row 的橫向布局就是 maxMainSize ;

  • 當 mainAxisSize 為 min 時,我們 Row 的橫向布局就是 allocatedSize ;

前面 maxMainSize 我們知道了是父布局的最大寬度,而 allocatedSize 其實就是 child 的寬度之和。所以結果很明顯了:

對於 Row 來說, mainAxisSize 為 max 時就是 match_parent;mainAxisSize 為 min 時就是 wrap_content 。

而高度 crossSize ,其實是由 math.max(crossSize, _getCrossSize(child)); 決定,也就是 child中最高的一個作為其高度。

最后小結一個知識點:

布局一般都是由上層往下傳遞 Constraints ,然后由下往上返回 Size。

在這里插入圖片描述

那如何直接自定義 RenderObject 布局?

拋開 Flutter 為我們封裝的好的,三大金剛 Widget 、Element、RednerObject 一個不少,當然, Flutter 內置了很多封裝幫我們節省代碼。

一般情況下自定義 RenderObject 布局:

  • 我們會繼承 MultiChildRenderObjectWidget 和 RenderBox 這兩個 abstract 類,實現自己的Widget 和 RenderObject 對象;

  • 然后利用 MultiChildRenderObjectElement 關聯起它們;

  • 除此之外,還有幾個關鍵的類:ContainerRenderObjectMixin 、 RenderBoxContainerDefaultsMixin 和 ContainerBoxParentData 等可以幫你減少代碼量。
    在這里插入圖片描述

總結起來, 對於 Flutter 而言,整個屏幕都是一塊畫布,我們通過各種 Offset 和 Rect 確定了位置,然后通過 Canvas 繪制上去,目標是整個屏幕區域,整個屏幕就是一幀,每次改變都是重新繪制。

這里沒有介紹 RenderSliver 相關,它的輸入和輸出和 Renderbox 又不大一樣,有機會我們后面再詳細介紹。

二、Flutter 的實戰技巧

2.1 InheritedWidget

InheritedWidget 是 Flutter 的靈魂設定之一。

InheritedWidget 共享的是 Widget ,只是這個 Widget 是一個 ProxyWidget ,它自己本身並不繪制什么,但共享這個 Widget 內保存有的數據,從而到了共享狀態的目的。

如下圖所示,是 Flutter 中常見的 Theme ,其內部就是使用了 _InheritedTheme 這個 InheritedWidget 來實現主題的全局共享的。那么 InheritedWidget 是如何實現全局共享的呢?

在這里插入圖片描述

其實在 Element 的內部有一個 Map<Type, InheritedElement> _inheritedWidgets; 參數,

_inheritedWidgets 一般情況下是空的,只有當父控件是 InheritedWidget 或者本身是 InheritedWidget 時,它才會被初始化,而當父控件是 InheritedWidget 時,這個 Map 會被一級一級往下傳遞與合並。

所以當我們通過 context 調用 inheritFromWidgetOfExactType 時,就可以通過這個 Map 往上查找,從而找到這個上級的 InheritedWidget 。(畢竟 context is Element)

在這里插入圖片描述
如我們的 Theme/ThemeData 、Text/DefaultTextStyle、Slider / SliderTheme 等,如下代碼所示,我們可以定義全局的 ThemeData 或者局部的 DefaultTextStyle ,從而實現全局的自定義和局部的自定義共享等。

在這里插入圖片描述在這里插入圖片描述

其實,Flutter 中大部分的狀態管理控件,其狀態共享方法,也是基於 InheritedWidget 去實現的。

2.2 支持原生控件

前面我們說過, Flutter 既然不依賴於原生控件,那么如何集成一些平台已有的控件呢?比如 WebView 和 Map ?

我們這里以 WebView 為例子:

在官方 WebView 控件支持出來之前 , 第三方是直接在 FlutterView 上覆蓋了一個新的原生控件,利用 Dart 中的占位控件傳遞位置和大小。

如下圖,在 Flutter 端 push 出一個 設定好位置和大小 的 SingleChildRenderObjectWidget ,從而得到需要顯示的大小和位置,將這些信息通過 MethodChannel 傳遞到原生層,在原生層 addContentView 一個指定大小和位置的 WebView 。

這時候 WebView 和 SingleChildRenderObjectWidget 處於一樣的大小和位置,而空白部分則用 FLutter 的 Appbar 顯示。
在這里插入圖片描述

這樣看起來就像是在 Flutter 中添加了 WebView ,但實際這脫離了 Flutter 的渲染樹,其中一個問題就是,當你跳轉 Flutter 其他頁面的時候,會被 WebView 擋住;並且打開頁面的動畫,Appbar 和 WebView 難以保持一致。

在這里插入圖片描述

后面 官方 WebView 控件支持出來后,這時候官方是利用 PlatformView 的設計,完成了不脫離 Flutter 渲染堆棧,也能集成平台原生控件的功能。

以 Android 為例,Android 上是利用了副屏顯示的底層邏輯,使用 VirtualDisplay 類,創建一個虛擬顯示器,需要調用 DisplayManager 的 createVirtualDisplay() 方法,將虛擬顯示器的內容渲染在一個內存的 Surface 上 ,生成一個唯一的 textureId 。

如下圖,之后渲染時將 textureId 傳遞給 Dart 層,渲染引擎會根據 textureId , 獲取到內存里已渲染數據,繪制到 AndroidView 上進行顯示。

在這里插入圖片描述

2.3 錯誤處理

Flutter 中比較有趣的情況是,在 Dart 中的一些錯誤,並不會導致應用閃退,而是通過如下的紅色堆棧 UI ,錯誤區域不同,可能是全屏紅,也可能局部紅,這種狀態就和傳統 APP 的“崩潰”狀態不大一樣了。

在這里插入圖片描述
在開發過程中這樣的顯示沒太大問題,但事實發布線上版本就不合適了,所以我們一般會選擇自定義錯誤顯示。

如下圖所示,一般我們可以通過如下處理,自定義我們的錯誤頁面,並且收集錯誤信息。

在這里插入圖片描述

重寫 ErrorWidget 的 builder 方法,然后將信息收集到 Zone 中,返回自己的自定義錯誤顯示,最后在 Zone 內利用 onError 統一處理錯誤。

三、Flutter Web

最后簡單說下 Flutter Web ,Flutter 在支持 Web 平台上的優勢在於 Flutter UI 與平台的耦合度很低,而 Dart 起初就是為了 Web 而生,一拍即合下 Flutter 支持 Web 並不是什么意外。
但是 Web 平台就繞不過 JS ,在 Web 平台,實際上 Image 控件最后會通過 dart2js 轉化為 標簽並通過 src 賦值顯示。

在這里插入圖片描述

同時,多了一個平台就多了需要兼容的,目前 Flutter 的 issue 仍然不少,而 Web 支持雖然已經合並到主項目中,但是在兼容、性能等問題上還需要繼續優化,比如 Flutter Web 中 canvas.drawColor(Colors.black, BlendMode.clear);是會出現運行錯誤的,因為不支持 BlendMode.clear 。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM