Flutter實戰(zhàn) RenderObject和RenderBox

2021-03-09 14:42 更新

在上一節(jié)我們說過每個Element都對應(yīng)一個RenderObject,我們可以通過Element.renderObject 來獲取。并且我們也說過RenderObject的主要職責(zé)是 Layout 和繪制,所有的RenderObject會組成一棵渲染樹 Render Tree。本節(jié)我們將重點介紹一下RenderObject的作用。

RenderObject就是渲染樹中的一個對象,它擁有一個parent和一個parentData 插槽(slot),所謂插槽,就是指預(yù)留的一個接口或位置,這個接口和位置是由其它對象來接入或占據(jù)的,這個接口或位置在軟件中通常用預(yù)留變量來表示,而parentData正是一個預(yù)留變量,它正是由parent 來賦值的,parent通常會通過子RenderObjectparentData存儲一些和子元素相關(guān)的數(shù)據(jù),如在 Stack 布局中,RenderStack就會將子元素的偏移數(shù)據(jù)存儲在子元素的parentData中(具體可以查看Positioned實現(xiàn))。

RenderObject類本身實現(xiàn)了一套基礎(chǔ)的 layout 和繪制協(xié)議,但是并沒有定義子節(jié)點模型(如一個節(jié)點可以有幾個子節(jié)點,沒有子節(jié)點?一個?兩個?或者更多?)。 它也沒有定義坐標(biāo)系統(tǒng)(如子節(jié)點定位是在笛卡爾坐標(biāo)中還是極坐標(biāo)?)和具體的布局協(xié)議(是通過寬高還是通過 constraint 和 size?,或者是否由父節(jié)點在子節(jié)點布局之前或之后設(shè)置子節(jié)點的大小和位置等)。為此,F(xiàn)lutter 提供了一個RenderBox類,它繼承自`RenderObject,布局坐標(biāo)系統(tǒng)采用笛卡爾坐標(biāo)系,這和 Android 和 iOS 原生坐標(biāo)系是一致的,都是屏幕的 top、left 是原點,然后分寬高兩個軸,大多數(shù)情況下,我們直接使用RenderBox就可以了,除非遇到要自定義布局模型或坐標(biāo)系統(tǒng)的情況,下面我們重點介紹一下RenderBox。

#14.3.1 布局過程

#Constraints

RenderBox 中,有個size屬性用來保存控件的寬和高。RenderBox的 layout 是通過在組件樹中從上往下傳遞BoxConstraints對象的實現(xiàn)的。BoxConstraints對象可以限制子節(jié)點的最大和最小寬高,子節(jié)點必須遵守父節(jié)點給定的限制條件。

在布局階段,父節(jié)點會調(diào)用子節(jié)點的layout()方法,下面我們看看RenderObjectlayout()方法的大致實現(xiàn)(刪掉了一些無關(guān)代碼和異常捕獲):

void layout(Constraints constraints, { bool parentUsesSize = false }) {
   ...
   RenderObject relayoutBoundary; 
    if (!parentUsesSize || sizedByParent || constraints.isTight 
        || parent is! RenderObject) {
      relayoutBoundary = this;
    } else {
      final RenderObject parent = this.parent;
      relayoutBoundary = parent._relayoutBoundary;
    }
    ...
    if (sizedByParent) {
        performResize();
    }
    performLayout();
    ...
}

可以看到layout方法需要傳入兩個參數(shù),第一個為constraints,即 父節(jié)點對子節(jié)點大小的限制,該值根據(jù)父節(jié)點的布局邏輯確定。另外一個參數(shù)是 parentUsesSize,該值用于確定 relayoutBoundary,該參數(shù)表示子節(jié)點布局變化是否影響父節(jié)點,如果為true,當(dāng)子節(jié)點布局發(fā)生變化時父節(jié)點都會標(biāo)記為需要重新布局,如果為false,則子節(jié)點布局發(fā)生變化后不會影響父節(jié)點。

#relayoutBoundary

上面layout()源碼中定義了一個relayoutBoundary變量,什么是 relayoutBoundary?在前面介紹Element時,我們講過當(dāng)一個Element標(biāo)記為 dirty 時便會重新 build,這時RenderObject便會重新布局,我們是通過調(diào)用 markNeedsBuild() 來標(biāo)記Element為 dirty 的。在RenderObject中有一個類似的markNeedsLayout()方法,它會將RenderObject的布局狀態(tài)標(biāo)記為 dirty,這樣在下一個 frame 中便會重新 layout,我們看看RenderObjectmarkNeedsLayout()的部分源碼:

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

代碼大致邏輯是先判斷自身是不是relayoutBoundary,如果不是就繼續(xù)向 parent 查找,一直向上查找到是 relayoutBoundaryRenderObject為止,然后再將其標(biāo)記為 dirty 的。這樣來看它的作用就比較明顯了,意思就是當(dāng)一個控件的大小被改變時可能會影響到它的 parent,因此 parent 也需要被重新布局,那么到什么時候是個頭呢?答案就是 relayoutBoundary,如果一個 RenderObjectrelayoutBoundary,就表示它的大小變化不會再影響到 parent 的大小了,于是 parent 也就不用重新布局了。

#performResize 和 performLayout

RenderBox實際的測量和布局邏輯是在performResize()performLayout()兩個方法中, RenderBox 子類需要實現(xiàn)這兩個方法來定制自身的布局邏輯。根據(jù)layout() 源碼可以看出只有 sizedByParenttrue 時,performResize() 才會被調(diào)用,而 performLayout() 是每次布局都會被調(diào)用的。sizedByParent 意為該節(jié)點的大小是否僅通過 parent 傳給它的 constraints 就可以確定了,即該節(jié)點的大小與它自身的屬性和其子節(jié)點無關(guān),比如如果一個控件永遠(yuǎn)充滿 parent 的大小,那么 sizedByParent就應(yīng)該返回true,此時其大小在 performResize() 中就確定了,在后面的 performLayout() 方法中將不會再被修改了,這種情況下 performLayout() 只負(fù)責(zé)布局子節(jié)點。

performLayout() 方法中除了完成自身布局,也必須完成子節(jié)點的布局,這是因為只有父子節(jié)點全部完成后布局流程才算真正完成。所以最終的調(diào)用棧將會變成:layout() > performResize()/performLayout() > child.layout() > ... ,如此遞歸完成整個UI的布局。

RenderBox子類要定制布局算法不應(yīng)該重寫layout()方法,因為對于任何 RenderBox 的子類來說,它的 layout 流程基本是相同的,不同之處只在具體的布局算法,而具體的布局算法子類應(yīng)該通過重寫performResize()performLayout()兩個方法來實現(xiàn),他們會在layout()中被調(diào)用。

#ParentData

當(dāng) layout 結(jié)束后,每個節(jié)點的位置(相對于父節(jié)點的偏移)就已經(jīng)確定了,RenderObject就可以根據(jù)位置信息來進行最終的繪制。但是在 layout 過程中,節(jié)點的位置信息怎么保存?對于大多數(shù)RenderBox子類來說如果子類只有一個子節(jié)點,那么子節(jié)點偏移一般都是Offset.zero ,如果有多個子節(jié)點,則每個子節(jié)點的偏移就可能不同。而子節(jié)點在父節(jié)點的偏移數(shù)據(jù)正是通過RenderObjectparentData屬性來保存的。在RenderBox中,其parentData屬性默認(rèn)是一個BoxParentData對象,該屬性只能通過父節(jié)點的setupParentData()方法來設(shè)置:

abstract class RenderBox extends RenderObject {
  @override
  void setupParentData(covariant RenderObject child) {
    if (child.parentData is! BoxParentData)
      child.parentData = BoxParentData();
  }
  ...
}

BoxParentData定義如下:

/// Parentdata 會被RenderBox和它的子類使用.
class BoxParentData extends ParentData {
  /// offset表示在子節(jié)點在父節(jié)點坐標(biāo)系中的繪制偏移  
  Offset offset = Offset.zero;


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

一定要注意,RenderObjectparentData 只能通過父元素設(shè)置.

當(dāng)然,ParentData并不僅僅可以用來存儲偏移信息,通常所有和子節(jié)點特定的數(shù)據(jù)都可以存儲到子節(jié)點的ParentData中,如ContainerBoxParentData就保存了指向兄弟節(jié)點的previousSiblingnextSiblingElement.visitChildren()方法也正是通過它們來實現(xiàn)對子節(jié)點的遍歷。再比如KeepAlive 組件,它使用KeepAliveParentDataMixin(繼承自ParentData) 來保存子節(jié)的keepAlive狀態(tài)。

#14.3.2 繪制過程

RenderObject可以通過paint()方法來完成具體繪制邏輯,流程和布局流程相似,子類可以實現(xiàn)paint()方法來完成自身的繪制邏輯,paint()簽名如下:

void paint(PaintingContext context, Offset offset) { }

通過context.canvas可以取到Canvas對象,接下來就可以調(diào)用Canvas API 來實現(xiàn)具體的繪制邏輯。

如果節(jié)點有子節(jié)點,它除了完成自身繪制邏輯之外,還要調(diào)用子節(jié)點的繪制方法。我們以RenderFlex對象為例說明:

@override
void paint(PaintingContext context, Offset offset) {


  // 如果子元素未超出當(dāng)前邊界,則繪制子元素  
  if (_overflow <= 0.0) {
    defaultPaint(context, offset);
    return;
  }


  // 如果size為空,則無需繪制
  if (size.isEmpty)
    return;


  // 剪裁掉溢出邊界的部分
  context.pushClipRect(needsCompositing, offset, Offset.zero & size, defaultPaint);


  assert(() {
    final String debugOverflowHints = '...'; //溢出提示內(nèi)容,省略
    // 繪制溢出部分的錯誤提示樣式
    Rect overflowChildRect;
    switch (_direction) {
      case Axis.horizontal:
        overflowChildRect = Rect.fromLTWH(0.0, 0.0, size.width + _overflow, 0.0);
        break;
      case Axis.vertical:
        overflowChildRect = Rect.fromLTWH(0.0, 0.0, 0.0, size.height + _overflow);
        break;
    }  
    paintOverflowIndicator(context, offset, Offset.zero & size,
                           overflowChildRect, overflowHints: debugOverflowHints);
    return true;
  }());
}

代碼很簡單,首先判斷有無溢出,如果沒有則調(diào)用defaultPaint(context, offset)來完成繪制,該方法源碼如下:

void defaultPaint(PaintingContext context, Offset offset) {
  ChildType child = firstChild;
  while (child != null) {
    final ParentDataType childParentData = child.parentData;
    //繪制子節(jié)點, 
    context.paintChild(child, childParentData.offset + offset);
    child = childParentData.nextSibling;
  }
}

很明顯,由于 Flex 本身沒有需要繪制的東西,所以直接遍歷其子節(jié)點,然后調(diào)用paintChild()來繪制子節(jié)點,同時將子節(jié)點ParentData中在 layout 階段保存的 offset 加上自身偏移作為第二個參數(shù)傳遞給paintChild()。而如果子節(jié)點還有子節(jié)點時,paintChild()方法還會調(diào)用子節(jié)點的paint()方法,如此遞歸完成整個節(jié)點樹的繪制,最終調(diào)用棧為: paint() > paintChild() > paint() ... 。

當(dāng)需要繪制的內(nèi)容大小溢出當(dāng)前空間時,將會執(zhí)行paintOverflowIndicator() 來繪制溢出部分提示,這個就是我們經(jīng)常看到的溢出提示,如圖14-3所示:

#RepaintBoundary

我們已經(jīng)在CustomPaint一節(jié)中介紹過RepaintBoundary,現(xiàn)在我們深入的了解一些。與 RelayoutBoundary 相似,RepaintBoundary是用于在確定重繪邊界的,與RelayoutBoundary不同的是,這個繪制邊界需要由開發(fā)者通過RepaintBoundary 組件自己指定,如:

CustomPaint(
  size: Size(300, 300), //指定畫布大小
  painter: MyPainter(),
  child: RepaintBoundary(
    child: Container(...),
  ),
),

下面我們看看RepaintBoundary的原理,RenderObject有一個isRepaintBoundary屬性,該屬性決定這個RenderObject重繪時是否獨立于其父元素,如果該屬性值為true ,則獨立繪制,反之則一起繪制。那獨立繪制是怎么實現(xiàn)的呢? 答案就在paintChild()源碼中:

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

我們可以看到,在繪制子節(jié)點時,如果child.isRepaintBoundarytrue則會調(diào)用_compositeChild()方法,_compositeChild()源碼如下:

void _compositeChild(RenderObject child, Offset offset) {
  // 給子節(jié)點創(chuàng)建一個layer ,然后再上面繪制子節(jié)點 
  if (child._needsPaint) {
    repaintCompositedChild(child, debugAlsoPaintedParent: true);
  } else {
    ...
  }
  assert(child._layer != null);
  child._layer.offset = offset;
  appendLayer(child._layer);
}

很明顯了,獨立繪制是通過在不同的 layer(層)上繪制的。所以,很明顯,正確使用isRepaintBoundary屬性可以提高繪制效率,避免不必要的重繪。具體原理是:和觸發(fā)重新 build 和 layout 類似,RenderObject也提供了一個markNeedsPaint()方法,其源碼如下:

void markNeedsPaint() {
 ...
  //如果RenderObject.isRepaintBoundary 為true,則該RenderObject擁有l(wèi)ayer,直接繪制  
  if (isRepaintBoundary) {
    ...
    if (owner != null) {
      //找到最近的layer,繪制  
      owner._nodesNeedingPaint.add(this);
      owner.requestVisualUpdate();
    }
  } else if (parent is RenderObject) {
    // 沒有自己的layer, 會和一個祖先節(jié)點共用一個layer  
    assert(_layer == null);
    final RenderObject parent = this.parent;
    // 向父級遞歸查找  
    parent.markNeedsPaint();
    assert(parent == this.parent);
  } else {
    // 如果直到根節(jié)點也沒找到一個Layer,那么便需要繪制自身,因為沒有其它節(jié)點可以繪制根節(jié)點。  
    if (owner != null)
      owner.requestVisualUpdate();
  }
}

可以看出,當(dāng)調(diào)用 markNeedsPaint() 方法時,會從當(dāng)前 RenderObject 開始一直向父節(jié)點查找,直到找到 一個isRepaintBoundarytrueRenderObject 時,才會觸發(fā)重繪,這樣便可以實現(xiàn)局部重繪。當(dāng) 有RenderObject 繪制的很頻繁或很復(fù)雜時,可以通過 RepaintBoundary Widget 來指定isRepaintBoundarytrue,這樣在繪制時僅會重繪自身而無需重繪它的 parent,如此便可提高性能。

還有一個問題,通過RepaintBoundary 如何設(shè)置isRepaintBoundary屬性呢?其實,如果使用了RepaintBoundary,其對應(yīng)的RenderRepaintBoundary會自動將isRepaintBoundary設(shè)為true的:

class RenderRepaintBoundary extends RenderProxyBox {
  /// Creates a repaint boundary around [child].
  RenderRepaintBoundary({ RenderBox child }) : super(child);


  @override
  bool get isRepaintBoundary => true;
}

#14.3.3 命中測試

我們在“事件處理與通知”一章中已經(jīng)講過 Flutter 事件機制和命中測試流程,本節(jié)我們看一下其內(nèi)部實現(xiàn)原理。

一個對象是否可以響應(yīng)事件,取決于其對命中測試的返回,當(dāng)發(fā)生用戶事件時,會從根節(jié)點(RenderView)開始進行命中測試,下面是RenderViewhitTest()源碼:

bool hitTest(HitTestResult result, { Offset position }) {
  if (child != null)
    child.hitTest(result, position: position); //遞歸子RenderBox進行命中測試
  result.add(HitTestEntry(this)); //將測試結(jié)果添加到result中
  return true;
}

我們再看看RenderBox默認(rèn)的hitTest()實現(xiàn):

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;
}

我們看到默認(rèn)的實現(xiàn)里調(diào)用了hitTestSelf()hitTestChildren()兩個方法,這兩個方法默認(rèn)實現(xiàn)如下:

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

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

hitTest 方法用來判斷該RenderObject 是否在被點擊的范圍內(nèi),同時負(fù)責(zé)將被點擊的 RenderBox 添加到 HitTestResult 列表中,參數(shù) position 為事件觸發(fā)的坐標(biāo)(如果有的話),返回 true 則表示有RenderBox 通過了命中測試,需要響應(yīng)事件,反之則認(rèn)為當(dāng)前RenderBox沒有命中。在繼承RenderBox時,可以直接重寫hitTest()方法,也可以重寫 hitTestSelf()hitTestChildren(), 唯一不同的是 hitTest()中需要將通過命中測試的節(jié)點信息添加到命中測試結(jié)果列表中,而 hitTestSelf()hitTestChildren()則只需要簡單的返回truefalse。

#14.3.4 語義化

語義化即 Semantics,主要是提供給讀屏軟件的接口,也是實現(xiàn)輔助功能的基礎(chǔ),通過語義化接口可以讓機器理解頁面上的內(nèi)容,對于有視力障礙用戶可以使用讀屏軟件來理解 UI 內(nèi)容。如果一個RenderObject要支持語義化接口,可以實現(xiàn) describeApproximatePaintClipvisitChildrenForSemantics方法和semanticsAnnotator getter。更多關(guān)于語義化的信息可以查看 API 文檔。

#14.3.5 總結(jié)

本節(jié)我們介紹了RenderObject主要的功能和方法,理解這些內(nèi)容可以幫助我們更好的理解 Flutter UI 底層原理。我們也可以看到,如果要從頭到尾實現(xiàn)一個RenderObject是比較麻煩的,我們必須去實現(xiàn) layout、繪制和命中測試邏輯,但是值得慶幸的是,大多數(shù)時候我們可以直接在 Widget 層通過組合或者CustomPaint完成自定義UI。如果遇到只能定義一個新RenderObject的場景時(如要實現(xiàn)一個新的 layout 算法的布局容器),可以直接繼承自RenderBox,這樣可以幫我們減少一部分工作。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號