Flutter源碼分析之自定義控件(RenderBox)指南

前言

Flutter 本身提供瞭大量Widget以供開發,但是難免有通過組合完成不瞭的效果,此時就需要我們自己來實現 RenderObject 瞭,本文會介紹一下實現一個 RenderObject 的基本步驟,幫助大傢快速熟悉開發自定義控件的流程,當然這對於讀懂原生 Widget 的實現源碼也有很大的益處。

RenderObject 類繼承層級解析

首先,介紹一下 RenderObject 子類的繼承關系,通過 Android Studio 的 Hierarchy 功能可以直觀地對類繼承關系進行查看:

RenderObject 類繼承關系

看過源碼分析系列相關文章中對 runApp() 方法的解析後應該知道,RenderView 對應的是 RenderObject 樹的根節點,打開該類的註釋,發現有這樣一句話:

The view has a unique child [RenderBox], which is required to fill the entire output surface.

意為 RenderView 根節點下隻有唯一一個 RenderBox 作為葉節點,它的大小會充滿整個繪制表面,由此可以看出,RenderBox 就是繪制上使用的基類瞭。繼續觀察一下 RenderObject 的子類繼承樹,發現有 3 個 Mixin 以及 RenderAbstractViewport 和 RenderSliver 沒有繼承自 RenderBox,這些類都是幹什麼用的呢?這裡簡單介紹下:

RenderAbstractViewport 和 RenderSliver 主要處理滑動相關的控件展示,如 ListView 和 ScrollView。滑動相關的內容就不在本文中講瞭,大傢可以期待後續的文章。DebugOverflowIndicatorMixin 用於在 debug 下提示繪制是否溢出,該類僅用於 debug,自定義控件時一般用不到。

剩下的兩個 mixin 還是比較關鍵的:

RenderObjectWithChildMixin 用於為隻有 1 個 child 的 RenderObject 提供 child 管理模型。

ContainerRenderObjectMixin 用於為有多個 child 的 RenderObject 提供 child 管理模型。

這兩個 mixin 是非常常用的,看一下 Hierarchy 可以發現基本上每個 RenderBox 都混入瞭他們,省去瞭自己管理 child 的代碼。

除此之外還有一個類也有相當多的子類:RenderProxyBox,接下來就分別詳細介紹一下繼承 RenderBox 和 RenderProxyBox 實現自定義控件的正確姿勢。

RenderBox

一個看源碼的好習慣就是看到一個新類先看註釋,第一句話如下:

A render object in a 2D Cartesian coordinate system.

這句話可以解釋 Box 的含義瞭,實際上就是表示使用瞭 2D 笛卡爾坐標系來標識位置,這與原生開發是一致的,坐標系原點位於左上,x 軸正向指向屏幕右側,y 軸正向指向屏幕下側。

葉節點與父節點

在安卓中,有 View 和 ViewGroup 的區分,前者不能有子 View,即為葉節點,後者可以有多個子 View,即父節點,那麼 Flutter 中呢?答案是都是 RenderBox,child 的邏輯區別以 mixin 來解決,如果想擁有 child,混入上一節所講的 RenderObjectWithChildMixin 或 ContainerRenderObjectMixin 就可以瞭。

控件的測量與佈局

在 RenderBox 中,控件大小的值為 _size 成員,它隻包含寬高兩個屬性值,我們可以通過該成員的 set 和 get 方法訪問或修改它的值。在測量時,parent 會傳給當前 RenderBox 一個大小的限制,為 BoxConstraints 類型,通過 constraints 這個 get 方法可以獲取到,最後測量得到的 size 必須滿足這個限制,在 Flutter 的 debug 模式下對 size 是否滿足 constraints 做瞭 assert 檢查,如果檢查未通過就會佈局失敗。所以測量上我們要做的是下面兩點:

  1. 如果沒有 child,那麼根據自身的屬性計算出滿足 constraints 的 size.
  2. 如果有 child,那麼綜合自身的屬性和 child 的測量結果計算出滿足 constraints 的 size.

performResize 和 performLayout

通過查看 size 的註釋,發現測量的時機在 performResize() 和 performLayout() 方法中,問題來瞭,為什麼有兩個測量的方法呢?分析下 RenderObject 類中調用它們的 layout 方法源碼:

if (sizedByParent) {
  try {
    performResize();
  } catch (e, stack) {}
}
try {
  performLayout();
} catch (e, stack) {}

可以看出隻有 sizedByParent 為 true 時,performResize() 才會被調用,而 performLayout() 是每次佈局都會被調用的。

sizedByParent 意為該控件的大小是否能僅通過 parent 賦予它的 constraints 就可以被確定下來瞭,即該控件的大小與它自身的屬性和與它的 child 都無關,比如如果一個控件永遠充滿 parent 的大小,那麼 sizedByParent 就應該返回 true。

這裡還有另外一個限制,如果 sizedByParent 為 true,大小應在 performResize() 中就確認,並且不能在 performLayout() 方法中再修改瞭,此時 performLayout() 隻負責佈局 child。

回到 sizedByParent,為什麼有這樣一個屬性呢?註釋中發現是為瞭優化性能,這裡分析一下 RenderObject 中用到它的代碼:

if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
  relayoutBoundary = this;
} else {
  final RenderObject parent = this.parent;
  relayoutBoundary = parent._relayoutBoundary;
}

可以看到如果 sizedByParent 為 true,relayoutBoundary 就設置為瞭自己,否則繼續向 parent 查找。除瞭 sizedByParent 以外,還有其他幾個判斷項,分別是 !parentUsesSize(parent 的測量不依賴該 RenderObject 的大小)、constraints.isTight(parent 賦予的限制是個定值)、parent is! RenderObject(滿足該條件的隻能是根節點 RenderView 瞭)。

relayoutBoundary

這裡引出瞭另外一個問題,什麼是 relayoutBoundary?

首先來講一下如何觸發佈局的測量,之前有源碼分析系列有提到過,在每一幀的繪制 drawFrame 方法中,會對標記為 dirty 的 RenderObject 進行重新佈局,我們可以通過調用 markNeedsLayout() 方法將 RenderObject 的佈局狀態標記為 dirty。分析一下該方法的源碼:

void markNeedsLayout() {
  if (_needsLayout) {
    return;
  }
  if (_relayoutBoundary != this) {
    markParentNeedsLayout();
  } else {
    _needsLayout = true;
    if (owner != null) {
      owner._nodesNeedingLayout.add(this);
      owner.requestVisualUpdate();
    }
  }
}

如果自身不是 relayoutBoundary,就繼續向 parent 查找,一直向上查找到是 relayoutBoundary 的 RenderObject,再將這個 RenderObject 標記為 dirty 的。這樣來看它的作用就比較明顯瞭,意思就是當一個控件的大小被改變時可能會影響到它的 parent,因此 parent 也需要被重新佈局,那麼到什麼時候是個頭呢?答案就是 relayoutBoundary,如果一個 RenderObject 是 relayoutBoundary,就表示它的大小變化不會再影響到 parent 的大小瞭,於是 parent 也就不用重新佈局瞭。知道這點後可以再重新考慮一下之前設置 relayoutBoundary 的四個判斷條件,這麼判斷的原因應該很明確瞭,這裡就不具體講瞭。

葉節點

葉節點的測量和佈局比較簡單,首先根據需求確認 sizedByParent的值,然後通過自身屬性和 constraints 計算出大小後調用 size 的 set 方法直接賦值給 size 就好瞭。由於是葉節點,是不用處理如何佈局的問題的,隻要知道自身的大小就足夠瞭。

父節點

父節點的流程就相對復雜一些,因為除瞭測量外還要對子節點進行佈局,步驟如下:

  1. 根據 child 的個數選擇 RenderObjectWithChildMixin 或 ContainerRenderObjectMixin.
  2. 確認 sizedByParent 的值,如果 sizedByParent 為 true,直接在 performResize() 方法中確認自己的大小.
  3. 在 performLayout() 方法中對 child 進行佈局.

重點在於第三個步驟,下面進行詳細介紹。

首先要說明的是,與安卓的 onMeasure() 和 onLayout() 不同的是,Flutter 中測量和佈局的過程都在 performLayout() 這一個方法中完成。

ParentData

首先要介紹的是一個名為 ParentData 的類,在 Flutter 的佈局系統中,該類負責存儲父節點所需要的子節點的佈局信息,當然該信息偶爾也會用於子節點的佈局。

每個 RenderObject 類中都有 parentData 這樣一個成員,該成員隻能通過 setupParentData 方法賦值,RenderObject 的子類可以通過重寫該方法將 ParentData 的子類賦值給 parentdata,以擴展 ParentData 的功能:

void setupParentData(covariant RenderObject child) {
  if (child.parentData is! ParentData)
    child.parentData = ParentData();
}

接下來看一下該類的 Hierarchy 結構:

ParentData 類繼承結構

先無視用於滑動的 Sliver 相關的類和用於表格佈局的 TabelCellParentData,我們來分析一下剩餘的 ParentData類的作用。

ParentData

class ParentData {
  /// Called when the RenderObject is removed from the tree.
  @protected
  @mustCallSuper
  void detach() { }

  @override
  String toString() => '<none>';
}

這是所有 ParentData 的基類,沒有存儲任何信息也沒有實現功能,隻定義瞭一個空實現的 detach() 方法,該方法會在 RenderObject 被移出 tree 的時候調用,這給子類提供瞭一個在 RenderObject 移出時更新信息的時機。

BoxParentData

/// Parent data used by [RenderBox] and its subclasses.
class BoxParentData extends ParentData {
  /// The offset at which to paint the child in the parent's coordinate system.
  Offset offset = Offset.zero;

  @override
  String toString() => 'offset=$offset';
}

該類註釋寫的很明確,用於 RenderBox 和它的子類,隻有一個 offset 屬性,該屬性用於存儲 child 的佈局信息,也就是 child 應該被擺在哪個位置,通常在 child 大小確定後,parent 負責根據自身邏輯將 child 的位置賦值到這裡。

ContainerBoxParentData

查看源碼後發現該類是個空類,隻是為瞭方便子類混入 ContainerParentDataMixin。

ContainerParentDataMixin

該類使用頻率很高,基本上所有父節點的 ParentData 都混入瞭該類,該類需要與ContainerRenderObjectMixin 共同使用,主要解決瞭對 child 的管理,它用雙鏈表存儲瞭所有子節點並提供瞭方便的接口去獲取他們。對於開發者,一般來說隻用到 ContainerRenderObjectMixin 中的 firstChild、lastChild、childCount,用來獲取首末 child,child的個數,配合使用 ContainerParentDataMixin 中的 previousSibling、nextSibling就可以對 child 進行遍歷瞭。

這些 ParentData 的基類解決瞭 child 的佈局位置信息的存儲和 child 的管理以及引用的獲取,再往下的子類就是與各佈局的功能相關的類瞭,如 FlexParentData,存儲瞭 flex 和 fit 的值,分別表示該 child 的 flex 比重和 佈局的 fit 策略。

測量 child 大小

測量一個 child 需要調用 RenderObject 中的 void layout(Constraints constraints, { bool parentUsesSize = false }),需要傳入兩個參數,constraints 即為父節點對子節點大小的限制,該值根據父節點的佈局邏輯確定。調用完這個方法後,就可以通過 child.size 拿到 child 測量後的大小瞭。另外一個參數是 parentUsesSize,該值用於確定 relayoutBoundary,意為 child 的佈局變化是否影響 parent,根據實際情況傳入該值即可,默認為 false。

佈局 child

佈局 child 即計算出 child 相對 parent 展示的位置,將該位置賦值給 childParentData 的 offset 中就可以瞭,該 offset 會在後面的繪制過程中用到。

控件的繪制

繪制方法在 void paint(PaintingContext context, Offset offset) { } 中實現,RenderBox 需要在該方法中實現對自身的繪制以及所有 child 的繪制。

繪制自身內容

通過 context.canvas 獲取到 Canvas 對象,之後就可以開始繪制瞭,需要註意每次繪制都要帶上 offset 的偏移量,否則繪制的位置會與佈局階段的預期不同。

繪制 child

對於 child 可以遍歷所有 child 並調用 context.paintChild(child, childParentData.offset + offset)方法完成 child 的繪制。除瞭這種方法以外,Flutter 還提供瞭 RenderBoxContainerDefaultsMixin,該類提供瞭一些 RenderBox 默認的行為方法,如上面繪制 child 的流程調用該類中的 defaultPaint(PaintingContext context, Offset offset) 就可以瞭,可以簡化一些模板代碼。

repaintBoundary

與 relayoutBoundary 相對應,對於繪制,也有一個 isRepaintBoundary 屬性,與 relayoutBoundary 不同的是,這個屬性需要由我們自己設置,默認為 false。註釋中的第一句話表示瞭該屬性的含義:

Whether this render object repaints separately from its parent.

即該 RenderObject 的繪制是否與它的 parent 相獨立,如何做到獨立呢?看下 paintChild 方法的源碼:

void paintChild(RenderObject child, Offset offset) {
  if (child.isRepaintBoundary) {
    stopRecordingIfNeeded();
    _compositeChild(child, offset);
  } else {
    child._paintWithContext(this, offset);
  }
}

void _compositeChild(RenderObject child, Offset offset) {
  // Create a layer for our child, and paint the child into it.
  if (child._needsPaint) {
    repaintCompositedChild(child, debugAlsoPaintedParent: true);
  } else {
    // 省略assert邏輯
  }
  child._layer.offset = offset;
  appendLayer(child._layer);
}

可以看出在繪制 child 時,如果 isRepaintBoundary 為 true,那麼會為該 child 新創建一個 layer,隻有在不同 layer 的 RenderObject 才可以各自獨立進行繪制。該屬性很明顯是為瞭提高渲染效率而存在的,它能夠實現區域重繪功能,具體原理如下:

類似觸發佈局的方法,為瞭觸發繪制,需要調用 markNeedsPaint(),分析下該方法的源碼:

void markNeedsPaint() {
  if (_needsPaint)
    return;
  _needsPaint = true;
  if (isRepaintBoundary) {
    if (owner != null) {
      owner._nodesNeedingPaint.add(this);
      owner.requestVisualUpdate();
    }
  } else if (parent is RenderObject) {
    final RenderObject parent = this.parent;
    parent.markNeedsPaint();
  } else {
    if (owner != null)
      owner.requestVisualUpdate();
  }
}

可以看出當調用 markNeedsPaint() 方法時,會從當前 RenderObject 開始一直向父節點查找,直到 isRepaintBoundary 為 true 時,才標記當前 RenderObject 為需要繪制的,也由此實現瞭區域重繪。當 RenderObject 繪制的很頻繁時,可以指定該值為 true,這樣在每幀繪制時可以縮小重繪范圍,僅重繪自身而不用重繪它的 parent,以此來提高性能。

對繪制區域的限制

控件的點擊事件處理

根據上述流程完成佈局與繪制後,我們理所應當的可能利用 GestureDetector 監聽瞭一些手勢,但是運行起來後發現手勢完全沒有生效,這是因為我們漏掉瞭關於點擊事件處理相關方法的實現。在 RenderBox 中有三個方法與點擊事件相關:

bool hitTest(HitTestResult result, { @required Offset position }) {
  if (_size.contains(position)) {
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

@protected
bool hitTestSelf(Offset position) => false;

@protected
bool hitTestChildren(HitTestResult result, { Offset position }) => false;

hitTest 方法用來判斷該 RenderObject 是否在被點擊的范圍內,同時負責將被點擊的 RenderObject 添加到 HitTestResult 列表中,參數 position 為點擊坐標,返回 true 則表示有 RenderObject 被點擊瞭,反之沒有。在默認實現中,簡單的判斷瞭 position 是否在 size 范圍內,如果在自身范圍內的話,繼續判斷是否有 child 在點擊范圍內,若沒有 child 被點擊,再判斷自己是否被點擊瞭。一般在子類中實現 hitTestSelf 和 hitTestChildren 即可。在 RenderBoxContainerDefaultsMixin 中有 hitTestChildren 的默認實現,即根據 child 的 hitTest 方法來判斷是否被點擊,如果沒有特殊邏輯,直接使用該方法即可。

RenderProxyBox

除瞭 RenderBox 之外,還有一個類比較常用,那就是 RenderProxyBox,該類將佈局繪制點擊事件等方法的處理全部交由 child 來實現,可以理解為 child 的代理,具體代理瞭哪些方法可以參見 RenderProxyBoxMixin 的源碼。

通常對一個已有的 RenderObject 做一些附加處理時會用到該類,如常見的 Opacity、DecoratedBox 等控件就是用該類實現的,它的各屬性和 child 完全一致,因此我們專心處理對 child 的額外效果就可以瞭,避免瞭邏輯的拷貝。

RenderBox 子類的常規寫法

回顧一下之前所講的內容,本節總結一下 RenderBox 子類的常規寫法。

命名

RenderBox子類的名稱一般以Render開頭。

mixin

根據 child 的數量選擇混入 RenderObjectWithChildMixin 或 ContainerRenderObjectMixin,前者對應一個 child,後者對應多個 child。

成員變量

RenderObject 的成員一般聲明為 private,配以 set 和 get 方法,get 方法直接返回該成員即可,用來在類中獲取該屬性,set 方法一般先判斷值是否與原值相同,若不同的話根據需要調用 markNeedsLayout 或 markNeedsPaint。

示例:

Axis get direction => _direction;
Axis _direction;
set direction(Axis value) {
  if (_direction != value) {
    _direction = value;
    markNeedsLayout();
  }
}

佈局、繪制、點擊事件

確定 sizedByParent 的值,若該值為 true,則還需要實現 performResize(),然後在該方法中計算出 size,後續 performLayout() 的過程中不能再對 size 進行改動。

對 child 的佈局在 performLayout() 中實現,佈局後將 child 的 offset 放入 ParentData 中,註意調用 paintChild 時傳入正確的 parentUsesSize 屬性以優化性能。如果需要擴展 ParentData,那麼重寫 setupParentData 方法,ParentData 一般選擇繼承 ContainerBoxParentData。

在 paint 方法中實現自身與 child 的繪制,如果自身會頻繁繪制,記得重寫 isRepaintBoundary 的值為 true。

根據需要實現hitTestSelf 和 hitTestChildren。

繪制 child 和處理 child 點擊事件的默認邏輯在 RenderBoxContainerDefaultsMixin 中。

對應 Widget 的常規寫法

RenderObject 最終也需要對應到 Widget,除瞭熟知的 StatelessWidget 和 StatefulWidget 以外,直接對應到 RenderObject 的是 RenderObjectWidget,它有三個實現類:

  1. SingleChildRenderObjectWidget,對應有一個 child 的 RenderObject.
  2. MultiChildRenderObjectWidget,對應有多個 child 的 RenderObject.
  3. LeafRenderObjectWidget 對應葉節點的 RenderObject.

繼承所需的類後,需要實現 createRenderObject 和 updateRenderObject 兩個方法,前者用於創建新的 Object 實例,後者用於更新 RenderObject 的屬性,示例如下:

/// 連續點贊Widget,對應連續點贊一幀的信息描述
class _RawMultiLike extends SingleChildRenderObjectWidget {

  final List<List<_SplashImage>> splashImages;
  final _DescriptionInfo descriptionInfo;
  final Size screenSize;

  const _RawMultiLike({
    Widget child,
    this.splashImages,
    this.descriptionInfo,
    this.screenSize,
  }): super(child: child);

  @override
  _RenderMultiLike createRenderObject(BuildContext context) {
    return _RenderMultiLike(
      splashImageInfos: splashImages,
      descriptionInfo: descriptionInfo,
      screenSize: screenSize,
      configuration: createLocalImageConfiguration(context),
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderMultiLike renderObject) {
    renderObject
      ..splashImageInfos = splashImages
      ..descriptionInfo = descriptionInfo
      ..screenSize = screenSize
      ..configuration = createLocalImageConfiguration(context);
  }

}

Element 層在 Widget 基類已經處理瞭,一般不用我們關心瞭。

一些自定義控件相關的 Widget

Flutter 原生提供瞭一些方便自定義功能的 Widget,如果可以滿足需求的話,直接使用這些 Widget 是最方便的,下面列舉一下:

自定義畫佈:CustomPaint

自定義單 child 佈局:CustomSingleChildLayout

自定義多 child 佈局:CustomMultiChildLayout

動態指定 RepaintBoundary:RepaintBoundary

總結

到此這篇關於Flutter源碼分析之自定義控件(RenderBox)的文章就介紹到這瞭,更多相關Flutter自定義控件(RenderBox)內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: