作為系列文章的第九篇,本篇主要深入了解 Widget 中繪制相關的原理,探索 Flutter 里的 RenderObject 最后是如何走完屏幕上的最后一步,結尾再通過實際例子理解如何設計一個 Flutter 的自定義繪制。
文章匯總地址:
在第六、第七篇中我們知道了 Widget
、Element
、RenderObject
的關系,同時也知道了Widget
的布局邏輯,最終所有 Widget
都轉化為 RenderObject
對象, 它們堆疊出我們想要的畫面。
所以在 Flutter 中,最終頁面的 Layout
、Paint
等都會發生在 Widget 所對應的 RenderObject
子類中,而 RenderObject
也是 Flutter 跨平臺的最大的特點之一:所有的控件都與平臺無關 ,這里簡單的人話就是: Flutter 只要求系統提供的 “Canvas”,然后開發者通過 Widget 生成 RenderObject
“直接” 通過引擎繪制到屏幕上。
ps 從這里開始篇幅略長,可能需要消費您的一點耐心。
一、繪制過程
我們知道 Widget
最終都轉化為 RenderObject
, 所以了解繪制我們直接先看 RenderObject
的 paint
方法。
如下圖所示,所有的 RenderObject
子類都必須實現 paint
方法,并且該方法并不是給用戶直接調用,需要更新繪制時,你可以通過 markNeddsPaint
方法去觸發界面繪制。
那么,按照“國際流程”,在經歷大小和布局等位置計算之后,最終 paint
方法會被調用,該方法帶有兩個參數: PaintingContext
和 Offset
,它們就是完成繪制的關鍵所在,那么相信此時大家肯定有個疑問就是:
-
PaintingContext
是什么? -
Offset
是什么?
通過飛速查閱源碼,我們可以首先了解到有 :
PaintingContext
的關鍵是 A place to paint ,同時它在父類ClipContext
是包含有Canvas
,并且PaintingContext
的構造方法是@protected
,只在PaintingContext.repaintCompositedChild
和pushLayer
時自動創建。Offset
在paint
中主要是提供當前控件在屏幕的相對偏移值,提供繪制時確定繪制的坐標。
OK,繼續往下走,那么既然 PaintingContext
叫 Context ,那它肯定是存在上下文關系,那它是在哪里開始創建的呢?
通過調試源碼可知,項目在 runApp
時通過 WidgetsFlutterBinding
啟動,而在以前的篇幅中我們知道, WidgetsFlutterBinding
是一個“膠水類”,它會觸發 mixin 的 RendererBinding
,如下圖創建出根 node 的 PaintingContext
。
好了,那么Offset
呢?如下圖,對于 Offset
的傳遞,是通過父控件和子控件的 offset 相加之后,一級一級的將需要繪制的坐標結合去傳遞的。
目前簡單來說,通過 PaintingContext
和 Offset
,在布局之后我們就可以在屏幕上準確的地方繪制會需要的畫面。
1、測試繪制
這里我們先做一個有趣的測試。
我們現在屏幕上通過 Container
限制一個高為 60 的綠色容器,如下圖,暫時忽略容器內的 Slider
控件 ,我們圖中繪制了一個 100 x 100 的紅色方塊,這時候我們會看到下圖右邊的效果是:納尼?為什么只有這么小?
事實上,因為正常 Flutter 在繪制 Container
的時候,AppBar
已經幫我們計算了狀態欄和標題欄高度偏差,但我們這里在用 Canvas
時直接粗暴的 drawRect
,繪制出來的紅色小方框,左部和頂部起點均為0,其實是從狀態欄開始計算繪制的。
那如果我們調整位置呢?把起點 top 調整到 300,出現了如下圖的效果:納尼?紅色小方塊居然畫出去了,明明 Container
只有綠色的大小。
其實這里的問題還是在于 PaintingContext
,它有一個參數是 estimatedBounds
,而 estimatedBounds
正常是在創建時通過 child.paintBounds
賦值的,但是對于 estimatedBounds
還有如下的描述:原來畫出去也是可以。
The canvas will allow painting outside these bounds.
The [estimatedBounds] rectangle is in the [canvas] coordinate system.
所以到這里你可以通俗的總結, 對于 Flutter 而言,整個屏幕都是一塊畫布,我們通過各種 Offset
和 Rect
確定了位置,然后通過 PaintingContext
的Canvas
繪制上去,目標是整個屏幕區域,整個屏幕就是一幀,每次改變都是重新繪制。
2、RepaintBoundary
當然,每次重新繪制并不是完全重新繪制 ,這里面其實是存在一些規制的。
還記得前面的 markNeedsPaint
方法嗎 ?我們先從 markNeedsPaint()
開始, 總結出其大致流程如下圖,可以看到 markNeedsPaint
在 requestVisualUpdate
時確實觸發了引擎去更新繪制界面。
接著我們看源碼,如源碼所示,當調用 markNeedsPaint()
時,RenderObject
就會往上的父節點去查找,根據 isRepaintBoundary
是否為 true,會決定是否從這里開始去觸發重繪。換個說法就是,確定要更新哪些區域。
所以其實流程應該是:通過isRepaintBoundary
往上確定了更新區域,通過 requestVisualUpdate
方法觸發更新往下繪制。
并且從源碼中可以看出, isRepaintBoundary
只有 get
,所以它只能被子類 override
,由子類表明是否是為重繪的邊緣,比如 RenderProxyBox
、RenderView
、RenderFlow
等 RenderObject
的 isRepaintBoundary
都是 true。
所以如果一個區域繪制很頻繁,且可以不影響父控件的情況下,其實可以將 override isRepaintBoundary
為 true。
3、Layer
上文我們知道了,當 isRepaintBoundary
為 true 時,那么該區域就是一個可更新繪制區域,而當這個區域形成時, 其實就會新創建一個 Layer
。
不同的 Layer
下的 RenderObject
是可以獨立的工作,比如 OffsetLayer
就在 RenderObject
中用到,它就是用來做定位繪制的。
同時這也引生出了一個結論:不是每個 RenderObject
都具有 Layer
的,因為這受 isRepaintBoundary
的影響。
其次在 RenderObject
中還有一個屬性叫 needsCompositing
,它會影響生成多少層的 Layer
,而這些 Layer
又會組成一棵 Layer Tree 。好吧,到這里又多了一個樹,實際上這顆樹才是所謂真正去給引擎繪制的樹。
到這里我們大概就了解了 RenderObject
的整個繪制流程,并且這個繪制時機我們是去“觸發”的,而不是主動調用,并且更新是判斷區域的。 嗯~有點 React 的味道!
二、Slider 控件的繪制實現
前面我們講了那么多繪制的流程,現在讓我們從 Slider
這個控件的源碼,去看看一個繪制控件的設計實現吧。
整個 Slider
的實現可以說是很 Flutter
了,大體結構如下圖。
在 _RenderSlider
中,除了 手勢 和 動畫 之外,其余的每個繪制的部分,都是獨立的 Component 去完成繪制,而這些 Component 都是通過 SliderTheme
的 SliderThemeData
提供的。
巧合的是,SliderTheme
本身就是一個 InheritedWidget
。看過以前篇章的同學應該會知道, InheritedWidget
一般就是用于做狀態共享的,所以如果你需要自定義 Slider
,完成可以通過 SliderTheme
嵌套,然后通過 SliderThemeData
選擇性的自定義你需要的模塊。
并且如下圖,在 _RenderSlider
中注冊時手勢和動畫,會在監聽中去觸發 markNeedsPaint
方法,這就是為什么你的觸摸能夠響應畫面的原因了。
同時可以看到 _SliderRender
內的參數都重寫了 get
、 set
方法, 在 set
時也會有 markNeedsPaint()
,或者調用 _updateLabelPainter
去間接調用 markNeedsLayout
。
至于 Slider
內的各種 Shape 的繪制這里就不展開了,都是 Canvas
標準的 pathTo
、drawRect
、translate
、drawPath
等熟悉的操作了。
自此,第九篇終于結束了!(///▽///)
資源推薦
- Github : https://github.com/CarGuo/
- 開源 Flutter 完整項目:https://github.com/CarGuo/GSYGithubAppFlutter
- 開源 Flutter 多案例學習型項目: https://github.com/CarGuo/GSYFlutterDemo
- 開源 Fluttre 實戰電子書項目:https://github.com/CarGuo/GSYFlutterBook