大家好,我是郭樹煜,掘金 《Flutter 完整開發實戰詳解》 系列的作者,Github GSY 系列開源專案的維護人員,系列包括 GSYVideoPlayer 、
GSYGitGithubApp
(Flutter \ ReactNative \ Kotlin \ Weex 四大版本)、GSYFlutterBook 電子書等,系列總 star 數在 25k 左右,目前 Github 中國區粉絲數暫居 67 名,主要負責移動端專案開發,大前端方向,主要涉及領域有 Android、Flutter、React Native、Weex 、小程式等等。
這次分享的主題主要涉及:移動端跨平臺開發的發展、Flutter Widget 的實現原理 、 Flutter 的實戰技巧 、Flutter Web的現狀 四個方面,而整體主題將圍繞 Widget 為中心展開。
一、移動端跨平臺開發的發展
按照慣例,我們先介紹歷史程式,隨著使用者終端種類的百花齊放,如今跨平臺開發已然成為移動領域的熱門話題之一,移動端跨平臺開發技術的發展,也代表著開發者對於效能、複用、高效上不斷的追求。
移動端的跨平臺開發主要有三個階段,這些階段的代表框架主要有:Cordova
、React Native
、Flutter
等,如下圖所示,是移動端的跨平臺發展歷程:
Cordova
Cordova
作為早期跨平臺領域應用最廣泛的框架,為前端人員所熟知,其主要原理就是:
將 web 程式碼打包到本地,利用平臺的 WebView 進行載入,通過內部約定好的 JS 通訊協議,載入和呼叫具備平臺原生能力的插架。
Cordova
讓前端開發人員可以快速的構建移動應用,獲取平臺入口,對早期 web 上欠缺的如攝像機、本地快取、檔案讀寫等能力進行快速支援。
早期的移動開發市場除了 Android 和 iOS 之外,還有 WindowPhone、黑莓等,
Cordova
簡單又實用的理念,使得它成為早期熱門的跨平臺框架,至今仍在更新的ionic
框架,也是在其基礎上進行了封裝發展。
React Native
Cordova
雖然實用方便,但是由於 WebView
的效能瓶頸,開發者開始追求更高效能,且具備平臺特色的跨平臺能力,這時候由 Facebook 開源的 React Native
框架開始引領新潮流。
React Native
讓 JS 程式碼執行在框架內建的 JS 引擎(JavaScriptCore)上,利用 JS 引擎實現了跨平臺能力,同時又將 JS 控制元件,對應解析為平臺原生控制元件進行渲染,從而實現效能的優化與提升。
由於 React
框架的盛行, React Native
也開始成為 React
開發人員,將自身能力擴充到應用開發的最佳選擇之一。同時 React Native
也是應用開發人員,接觸前端的不錯嘗試。
後來阿里開源的
Weex
框架設計相似,利用了 V8 引擎實現跨平臺,不過使用了Vue
的設計理念,而Weex
因為種種原因,最終還是沒能大面積推廣開來。
Flutter
事實上 JS Bridge
同樣存在效能等限制,Facebook 也在著力優化這一問題,比如 HermesJS
、底層大規模重構等 ,而 JS -> 平臺控制元件對映,也導致了框架和平臺耦合過多,在版本相容和系統升級等問題上讓框架維護越發困難。
這時候谷歌開源了 Flutter
,它另闢蹊徑,只要求平臺提供一個 Surface
和一個 Canvas
,剩下的 Flutter
說:“你可以躺下了,我們來自己動”。
Flutter
的跨平臺思路快速讓他成為“新貴”,連跨平臺界的老大哥 “JS” 語言都“視而不見”,大膽的選擇 Dart
也讓 Flutter
在前期的推廣中飽受爭議。
短短兩年,不算 PR ,
Flutter
的 issue 已經有近 1.8 萬的 closed 和 8000+ open , 這代表了它的熱度,也代表著它需要面對的問題和挑戰。 不支援 Release 模式下的熱更新,也讓使用者更多徘徊於 React Native 不願嘗試。不過有一點可以確定的,那就是
Flutter
的版本號上是徹底戰勝了React Naitve
。
總結起來,我們可以看到,移動端跨平臺的發展,從單純的套殼打包,到提供高效能的跨平臺控制元件封裝,再到現在的控制元件與平臺脫離的發展。 整個發展歷程,就是對 效能、複用、高效 的不斷追求。
題外話,什麼要學習跨平臺?
1、開發成本
我直接學 Java
/Kotlin
、Object-C
/Switf
、JavaScript
/CSS
去寫各平臺的程式碼可以嗎?
當然可以,這樣的效能肯定最有保證,但是跨平臺的主要優勢在於程式碼邏輯的複用,減少各平臺同一邏輯,因人而異的開發成本。
2、學習機會
一般情況下,各平臺開發者容易侷限在自己的領域開發,而作為應用開發者,跨平臺是接觸另一平臺或領域的過渡機會。
下面開始今天的主題 Flutter ,Flutter 整體涉及的內容很多,由於篇幅問題,本篇我們的主題整體都圍繞一個
Widget
展開。Flutter 作為跨平臺 UI 框架,Widget
是其靈魂設定之一。
二、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 的實戰技巧
3.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
去實現的。
3.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
上進行顯示。
3.3、錯誤處理
Flutter 中比較有趣的情況是,在 Dart 中的一些錯誤,並不會導致應用閃退,而是通過如下的紅色堆疊 UI ,錯誤區域不同,可能是全屏紅,也可能區域性紅,這種狀態就和傳統 APP 的“崩潰”狀態不大一樣了。
在開發過程中這樣的顯示沒太大問題,但事實釋出線上版本就不合適了,所以我們一般會選擇自定義錯誤顯示。
如下圖所示,一般我們可以通過如下處理,自定義我們的錯誤頁面,並且收集錯誤資訊。
重寫 ErrorWidget
的 builder
方法,然後將資訊收集到 Zone
中,返回自己的自定義錯誤顯示,最後在 Zone
內利用 onError
統一處理錯誤。
ps 圖中的
Zone
等概念這裡就不展開了,有興趣的可以去以前的文章詳細檢視。
四、Flutter Web
最後簡單說下 Flutter Web ,Flutter 在支援 Web 平臺上的優勢在於 Flutter UI 與平臺的耦合度很低,而 Dart 起初就是為了 Web 而生,一拍即合下 Flutter 支援 Web 並不是什麼意外。
但是 Web 平臺就繞不過 JS ,在 Web 平臺,實際上 Image
控制元件最後會通過 dart2js 轉化為 <img>
標籤並通過 src
賦值顯示。
同時,多了一個平臺就多了需要相容的,目前 Flutter 的 issue 仍然不少,而 Web 支援雖然已經合併到主專案中,但是在相容、效能等問題上還需要繼續優化,比如 Flutter Web 中 canvas.drawColor(Colors.black, BlendMode.clear);
是會出現執行錯誤的,因為不支援 BlendMode.clear
。
資源推薦
- Github : github.com/CarGuo
- 開源 Flutter 完整專案:github.com/CarGuo/GSYG…
- 開源 Flutter 多案例學習型專案: github.com/CarGuo/GSYF…
- 開源 Fluttre 實戰電子書專案:github.com/CarGuo/GSYF…
- 開源 React Native 專案:github.com/CarGuo/GSYG…