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