作為系列文章的第九篇,本篇主要深入瞭解 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 : github.com/CarGuo
- 本文程式碼 :github.com/CarGuo/GSYG…
完整開源專案推薦:
文章
《Flutter完整開發實戰詳解(一、Dart語言和Flutter基礎)》
《Flutter完整開發實戰詳解(四、Redux、主題、國際化)》