移動開發新利器 | 一文深入了解 Flutter 界面開發
阿里妹導讀:談到移動端開發,大家心中肯定會涌現出一系列名詞:iOS、Android、Weex,H5... 那為何還使用 Flutter?其實,Flutter 通過自建繪制引擎,具備與 Native 媲美的性能指數,且有很好的兩端一致性,因此 Flutter 提供了一種新的可選項。閑魚寶貝詳情頁實踐上線也證明了這點,可以在性能無損前提下降低 iOS&Android 開發成本。
本文由閑魚技術團隊出品。它將為你深入介紹 Flutter framework 關于視圖樹的創建與管理機制、布局、渲染的原理,以及 Flutter 布局與渲染相關性能優化的設計思路的文章。同時介紹在使用 Flutter 開發過程中,遇到的一些坑和相應的解決方案。
Flutter 框架簡介
-
跨平臺應用的框架,沒有使用 WebView 或者系統平臺自帶的控件,使用自身的高性能渲染引擎(Skia)自繪。
-
界面開發語言使用 dart,底層渲染引擎使用C, C++。
-
組合大于繼承,控件本身通常由許多小型、單用途的控件組成,結合起來產生強大的效果,類的層次結構是扁平的,以***化可能的組合數量。
Rendering Pipeline
本文主要介紹 build、layout、paint 的三個階段。
視圖樹
Widget&Element&RenderObject
Flutter 視圖樹包含了三種樹,上圖只是介紹了三顆樹的基礎 class 的對應關系和功能介紹。
創建樹
-
創建 widget 樹
-
調用 runApp (rootWidget),將 rootWidget 傳給 rootElement,做為 rootElement 的子節點,生成 Element 樹,由 Element 樹生成 Render 樹
-
Widget:存放渲染內容、視圖布局信息,widget 的屬性***都是 immutable (如何更新數據呢?查看后續內容)
-
Element:存放上下文,通過 Element 遍歷視圖樹,Element 同時持有 Widget 和 RenderObject
-
RenderObject:根據 Widget 的布局屬性進行 layout,paint Widget 傳人的內容
更新樹
★為什么 widget 都是 immutable?
Flutter 界面開發是一種響應式編程,主張 simple is fast,Flutter 設計的初衷希望數據變更時發送通知到對應的可變更節點(可能是一個 StatefullWidget 子節點,也可以是 rootWidget),由上到下重新 create widget 樹進行刷新,這種思路比較簡單,不用關心數據變更會影響到哪些節點。
★widget 重新創建,element 樹和 renderObject 樹是否也重新創建?
widget 只是一個配置數據結構,創建是非常輕量的,加上 Flutter 團隊對 widget 的創建/銷毀做了優化,不用擔心整個 widget 樹重新創建所帶來的性能問題,但是 renderobject 就不一樣了,renderobject 涉及到 layout、paint 等復雜操作,是一個真正渲染的 view,整個 view 樹重新創建開銷就比較大,所以答案是否定的。
★樹的更新規則
-
找到 widget 對應的 element 節點,設置 element 為 dirty,觸發 drawframe, drawframe 會調用 element 的 performRebuild ()進行樹重建
-
widget.build () == null, deactive element.child,刪除子樹,流程結束
-
element.child.widget == NULL, mount 的新子樹,流程結束
-
element.child.widget == widget.build () 無需重建,否則進入流程5
-
Widget.canUpdate (element.child.widget, newWidget) == true,更新 child 的 slot,element.child.update (newWidget)(如果 child 還有子節點,則遞歸上面的流程進行子樹更新),流程結束,否則轉6
-
Widget.canUpdate (element.child.widget, newWidget) != true(widget 的 classtype 或者 key 不相等),deactivew element.child,mount 新子樹
注意事項:
-
element.child.widget == widget.build (),不會觸發子樹的 update,當觸發 update 的時候,如果沒有生效,要注意 widget 是否使用舊 widget,沒有 new widget,導致 update 流程走到該 widget 就停止了。
-
子樹的深度變化,會引起子樹重建,如果子樹是一個復雜度很高的樹,可以使用 GlobalKey 做為子樹 widget 的 key。GlobalKey 具有緩存功能。
★如何觸發樹更新
-
全局更新:調用 runApp (rootWidget),一般 flutter 啟動時調用后不再會調用。
-
局部子樹更新, 將該子樹做 StatefullWidget 的一個子 widget,并創建對應的 State 類實例,通過調用 state.setState () 觸發該子樹的刷新。
Widget
StatefullWidget vs StatelessWidget
-
StatelessWidget:無中間狀態變化的 widget,需要更新展示內容就得通過重新 new,Flutter 推薦盡量使用 StatelessWidget。
-
StatefullWidget:存在中間狀態變化,那么問題來了,widget 不是都 immutable 的,狀態變化存儲在哪里?Flutter 引入 state 的類用于存放中間態,通過調用 state.setState ()進行此節點及以下的整個子樹更新。
State 生命周期
-
initState (): state create 之后被 insert 到 tree 時調用的
-
didUpdateWidget (newWidget):祖先節點 rebuild widget 時調用
-
deactivate ():widget 被 remove 的時候調用,一個 widget 從 tree 中 remove 掉,可以在 dispose 接口被調用前,重新 instert 到一個新 tree 中
-
didChangeDependencies ():
-
初始化時,在 initState ()之后立刻調用
-
當依賴的 InheritedWidget rebuild,會觸發此接口被調用
-
build ():
-
After calling [initState].
-
After calling [didUpdateWidget].
-
After receiving a call to [setState].
-
After a dependency of this [State] object changes (e.g., an[InheritedWidget] referenced by the previous [build] changes).
-
After calling [deactivate] and then reinserting the [State] object into the tree at another location.
-
dispose ():Widget 徹底銷毀時調用
-
reassemble (): hot reload 調用
注意事項:
-
A頁面 push 一個新的頁面B,A頁面的 widget 樹中的所有 state 會依次調用 deactivate (), didUpdateWidget (newWidget)、build ()(這里懷疑是 bug,A頁面 push 一個新頁面,理論上并沒有將A頁面進行 remove 操作),當然從功能上,沒有看出來有什么異常。
-
當 ListView 中的 item 滾動出可顯示區域的時候,item 會被從樹中 remove 掉,此 item 子樹中所有的 state 都會被 dispose,state 記錄的數據都會銷毀,item 滾動回可顯示區域時,會重新創建全新的 state、element、renderobject。
-
使用 hot reload 功能時,要特別注意 state 實例是沒有重新創建的,如果該 state 中存在一下復雜的資源更新需要重新加載才能生效,那么需要在 reassemble ()添加處理,不然當你使用 hot reload 時候可能會出現一些意想不到的結果,例如,要將顯示本地文件的內容到屏幕上,當你開發過程中,替換了文件中的內容,但是 hot reload 沒有觸發重新讀取文件內容,頁面顯示還是原來的舊內容。
數據流轉
★從上往下
數據從根往下傳數據,常規做法是一層層往下,當深度變大,數據的傳輸變的困難,Flutter 提供 InheritedWidget 用于子節點向祖先節點獲取數據的機制,如下例子:
child 及其以下的節點可以通過調用下面的接口讀取 color 數據:
說明:BuildContext 就是 Element 的一個接口類
context.inheritFromWidgetOfExactType (FrogColor)其實是通過 context/element 往上遍歷樹,查找到***個 FrogColor 的祖先節點,取該節點的 widget 對象。
★從下往上
子節點狀態變更,向上上報通過發送通知的方式
-
定義通知類,繼承至 Notification
-
父節點使用 NotificationListener 進行監聽捕獲通知
-
子節點有數據變更調用下面接口進行數據上報
★閑魚 Flutter 的界面框架設計
Layout
★Size 計算
parent 傳入約束條件,在 dramframe 的 layout 階段,child 根據自身的渲染內容返回 size。
問題:在 build ()階段獲取不到 size,很多時候需要提前知道部分 widget size 來進行布局,解決方案當 widget 在對應 renderobject 的 layout 階段之后,發送一個 LayoutChangeNotification,參考 SizeChangedLayoutNotifier class,但是 SizeChangedLayoutNotifier 沒有上報 init layout size,可以自己參考這個實現封裝一個 Notifier。
★Offset 計算
-
renderObject 拿到計算好的 size,再加上一些布局屬性(align、paddig)等,計算 child 相對 parent 的 offset。
-
offset 存放在每個 child renderObject 的 BoxParentData 中。
-
當 parent 擁有 mutil children 時,BoxParentData 還用來存 children 兄弟節點之間的遍歷順序。
★Relayout boundary
renderObject 在 layout 階段做了 Relayout boundary 的優化,當子樹進行 relayout 時,滿足下面三種中的一種:
-
parentUsesSize == false
-
sizedByParent == true
-
constraints.isTight
那么該 renderObject 設置為 Relayout boundary,也就是該 renderObject 的重新 layout 不觸發 parent 的 layout,一般情況下開發人員不需要關心 Relayout boundary,除非是使用 CustomMultiChildLayout。
Paint
★Layer
iOS 的每一個 UIView 都有一個 layer,Flutter 的 render object 不一定存在 layer,一般情況下一個 renderObject 子樹都渲染在一個 layer 上,那么什么 renderObject 具有 layer,子 renderObject 怎么渲染到這個 layer?
1. 當一個 renderObject 的
或者
,renderOject 會有對應的 compositing layer。
2. 子 renderObject 會對目標 layer 返回對應的 offsetLayer, 目標 compositing layer 再根據 offset 合成一個渲染的紋理 buffer。
★Repaint Boundary
類似 Relayout boundary,Paint 階段也有 Repaint Boundary,目的和 layout 一樣,就是對應子樹的 paint 不會導致外部的 repaint,但是 Relayout boundary 需要開發人員自己設置,使用 RepaintBoundary widget 進行設置,ListView 在渲染的 item 默認都是使用了 RepaintBoundary,顯而易見 ListView 的 children 之間都是相互獨立的。Flutter 建議復雜的 image 渲染使用 RepaintBoundary,image 的渲染需要 io 操作,然后解碼,***渲染,使用 RepaintBoundary 可以進行 gpu 的緩存,但是不一定就會緩存,engine 會判斷這個 image 是否足夠復雜,畢竟 gpu 緩存還是非常珍貴的,同時 RepaintBoundary 還會對一些反復渲染的 layer 進行緩存處理(反復渲染 3 次及以上,這個是 Flutter 的視頻中提到的)。
結語
Flutter 還處于 Beta 階段,有些界面編程的接口設計還不夠成熟,相比 iOS 和安卓生態還很不成熟,需要我們共同的創建,Flutter 提供的調試工具相比一開始接觸的時候,已經完善很多,讓我們給 Flutter 更多的耐心和包容,期待 Flutter 越來越完善。
參考資料