Flutter完整開發實戰詳解(九、 深入繪製原理)

戀貓de小郭發表於2019-03-31

作為系列文章的第九篇,本篇主要深入瞭解 Widget 中繪製相關的原理,探索 Flutter 裡的 RenderObject 最後是如何走完螢幕上的最後一步,結尾再通過實際例子理解如何設計一個 Flutter 的自定義繪製。

前文:

在第六、第七篇中我們知道了 WidgetElementRenderObject 的關係,同時也知道了Widget 的佈局邏輯,最終所有 Widget 都轉化為 RenderObject 物件, 它們堆疊出我們想要的畫面。

所以在 Flutter 中,最終頁面的 LayoutPaint 等都會發生在 Widget 所對應的 RenderObject 子類中,而 RenderObject 也是 Flutter 跨平臺的最大的特點之一:所有的控制元件都與平臺無關 ,這裡簡單的人話就是: Flutter 只要求系統提供的 “Canvas”,然後開發者通過 Widget 生成 RenderObject “直接” 通過引擎繪製到螢幕上。

ps 從這裡開始篇幅略長,可能需要消費您的一點耐心。

一、繪製過程

我們知道 Widget 最終都轉化為 RenderObject , 所以瞭解繪製我們直接先看 RenderObjectpaint 方法。

如下圖所示,所有的 RenderObject 子類都必須實現 paint 方法,並且該方法並不是給使用者直接呼叫,需要更新繪製時,你可以通過 markNeddsPaint 方法去觸發介面繪製。

image.png

那麼,按照“國際流程”,在經歷大小和佈局等位置計算之後,最終 paint 方法會被呼叫,該方法帶有兩個引數: PaintingContextOffset ,它們就是完成繪製的關鍵所在,那麼相信此時大家肯定有個疑問就是:

  • PaintingContext 是什麼?
  • Offset 是什麼?

通過飛速查閱原始碼,我們可以首先了解到有 :

  • PaintingContext 的關鍵是 A place to paint ,同時它在父類 ClipContext 是包含有 Canvas ,並且 PaintingContext 的構造方法是 @protected,只在 PaintingContext.repaintCompositedChildpushLayer 時自動建立。

  • Offsetpaint 中主要是提供當前控制元件在螢幕的相對偏移值,提供繪製時確定繪製的座標。

Flutter完整開發實戰詳解(九、 深入繪製原理)

OK,繼續往下走,那麼既然 PaintingContext 叫 Context ,那它肯定是存在上下文關係,那它是在哪裡開始建立的呢?

通過除錯原始碼可知,專案在 runApp 時通過 WidgetsFlutterBinding 啟動,而在以前的篇幅中我們知道, WidgetsFlutterBinding 是一個“膠水類”,它會觸發 mixinRendererBinding ,如下圖建立出根 node 的 PaintingContext

Flutter完整開發實戰詳解(九、 深入繪製原理)

好了,那麼Offset 呢?如下圖,對於 Offset 的傳遞,是通過父控制元件和子控制元件的 offset 相加之後,一級一級的將需要繪製的座標結合去傳遞的。

目前簡單來說,通過 PaintingContextOffset ,在佈局之後我們就可以在螢幕上準確的地方繪製會需要的畫面。

Flutter完整開發實戰詳解(九、 深入繪製原理)

1、測試繪製

這裡我們先做一個有趣的測試。

我們現在螢幕上通過 Container 限制一個高為 60 的綠色容器,如下圖,暫時忽略容器內的 Slider 控制元件 ,我們圖中繪製了一個 100 x 100 的紅色方塊,這時候我們會看到下圖右邊的效果是:納尼?為什麼只有這麼小?

事實上,因為正常 Flutter 在繪製 Container 的時候,AppBar 已經幫我們計算了狀態列和標題欄高度偏差,但我們這裡在用 Canvas 時直接粗暴的 drawRect,繪製出來的紅色小方框,左部和頂部起點均為0,其實是從狀態列開始計算繪製的。

Flutter完整開發實戰詳解(九、 深入繪製原理)

那如果我們調整位置呢?把起點 top 調整到 300,出現瞭如下圖的效果:納尼?紅色小方塊居然畫出去了,明明 Container 只有綠色的大小。

Flutter完整開發實戰詳解(九、 深入繪製原理)

其實這裡的問題還是在於 PaintingContext ,它有一個引數是 estimatedBounds ,而 estimatedBounds 正常是在建立時通過 child.paintBounds 賦值的,但是對於 estimatedBounds 還有如下的描述:原來畫出去也是可以。

The canvas will allow painting outside these bounds.
The [estimatedBounds] rectangle is in the [canvas] coordinate system.
複製程式碼

所以到這裡你可以通俗的總結, 對於 Flutter 而言,整個螢幕都是一塊畫布,我們通過各種 OffsetRect 確定了位置,然後通過 PaintingContextCanvas 繪製上去,目標是整個螢幕區域,整個螢幕就是一幀,每次改變都是重新繪製。

2、RepaintBoundary

當然,每次重新繪製並不是完全重新繪製 ,這裡面其實是存在一些規制的。

還記得前面的 markNeedsPaint 方法嗎 ?我們先從 markNeedsPaint() 開始, 總結出其大致流程如下圖,可以看到 markNeedsPaintrequestVisualUpdate 時確實觸發了引擎去更新繪製介面。

繪製大致流程圖

接著我們看原始碼,如原始碼所示,當呼叫 markNeedsPaint() 時,RenderObject 就會往上的父節點去查詢,根據 isRepaintBoundary 是否為 true,會決定是否從這裡開始去觸發重繪。換個說法就是,確定要更新哪些區域。

所以其實流程應該是:通過isRepaintBoundary 往上確定了更新區域,通過 requestVisualUpdate 方法觸發更新往下繪製。

markNeedsPaint

並且從原始碼中可以看出, isRepaintBoundary 只有 get ,所以它只能被子類 override ,由子類表明是否是為重繪的邊緣,比如 RenderProxyBoxRenderViewRenderFlowRenderObjectisRepaintBoundary 都是 true。

所以如果一個區域繪製很頻繁,且可以不影響父控制元件的情況下,其實可以將 override isRepaintBoundary 為 true。

3、Layer

上文我們知道了,當 isRepaintBoundary 為 true 時,那麼該區域就是一個可更新繪製區域,而當這個區域形成時, 其實就會新建立一個 Layer

不同的 Layer 下的 RenderObject 是可以獨立的工作,比如 OffsetLayer 就在 RenderObject 中用到,它就是用來做定位繪製的。

Flutter完整開發實戰詳解(九、 深入繪製原理)

同時這也引生出了一個結論:不是每個 RenderObject 都具有 Layer 的,因為這受 isRepaintBoundary 的影響。

其次在 RenderObject 中還有一個屬性叫 needsCompositing ,它會影響生成多少層的 Layer ,而這些 Layer 又會組成一棵 Layer Tree 。好吧,到這裡又多了一個樹,實際上這顆樹才是所謂真正去給引擎繪製的樹。

Flutter完整開發實戰詳解(九、 深入繪製原理)

到這裡我們大概就瞭解了 RenderObject 的整個繪製流程,並且這個繪製時機我們是去“觸發”的,而不是主動呼叫,並且更新是判斷區域的。 嗯~有點 React 的味道!

二、Slider 控制元件的繪製實現

前面我們講了那麼多繪製的流程,現在讓我們從 Slider 這個控制元件的原始碼,去看看一個繪製控制元件的設計實現吧。

Flutter完整開發實戰詳解(九、 深入繪製原理)

整個 Slider 的實現可以說是很 Flutter 了,大體結構如下圖。

_RenderSlider 中,除了 手勢動畫 之外,其餘的每個繪製的部分,都是獨立的 Component 去完成繪製,而這些 Component 都是通過 SliderThemeSliderThemeData 提供的。

巧合的是,SliderTheme 本身就是一個 InheritedWidget 。看過以前篇章的同學應該會知道, InheritedWidget 一般就是用於做狀態共享的,所以如果你需要自定義 Slider ,完成可以通過 SliderTheme 巢狀,然後通過 SliderThemeData 選擇性的自定義你需要的模組。

Flutter完整開發實戰詳解(九、 深入繪製原理)

並且如下圖,在 _RenderSlider 中註冊時手勢和動畫,會在監聽中去觸發 markNeedsPaint 方法,這就是為什麼你的觸控能夠響應畫面的原因了。

Flutter完整開發實戰詳解(九、 深入繪製原理)

同時可以看到 _SliderRender內的引數都重寫了 getset 方法, 在 set 時也會有 markNeedsPaint() ,或者呼叫 _updateLabelPainter 去間接呼叫 markNeedsLayout

image.png

至於 Slider 內的各種 Shape 的繪製這裡就不展開了,都是 Canvas 標準的 pathTodrawRecttranslatedrawPath等熟悉的操作了。

自此,第九篇終於結束了!(///▽///)

資源推薦

完整開源專案推薦:
文章

《Flutter完整開發實戰詳解(一、Dart語言和Flutter基礎)》

《Flutter完整開發實戰詳解(二、 快速開發實戰篇)》

《Flutter完整開發實戰詳解(三、 打包與填坑篇)》

《Flutter完整開發實戰詳解(四、Redux、主題、國際化)》

《Flutter完整開發實戰詳解(五、 深入探索)》

《Flutter完整開發實戰詳解(六、 深入Widget原理)》

《Flutter完整開發實戰詳解(七、 深入佈局原理)》

《Flutter完整開發實戰詳解(八、 實用技巧與填坑)》

《Flutter完整開發實戰詳解(九、 深入繪製原理)》

《跨平臺專案開源專案推薦》

《移動端跨平臺開發的深度解析》

我們還會再見嗎?

相關文章