Flutter實(shí)戰(zhàn) RenderObject和RenderBox

2021-03-09 14:42 更新

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

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

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

#14.3.1 布局過程

#Constraints

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

在布局階段,父節(jié)點(diǎn)會(huì)調(diào)用子節(jié)點(diǎn)的layout()方法,下面我們看看RenderObjectlayout()方法的大致實(shí)現(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方法需要傳入兩個(gè)參數(shù),第一個(gè)為constraints,即 父節(jié)點(diǎn)對(duì)子節(jié)點(diǎn)大小的限制,該值根據(jù)父節(jié)點(diǎn)的布局邏輯確定。另外一個(gè)參數(shù)是 parentUsesSize,該值用于確定 relayoutBoundary,該參數(shù)表示子節(jié)點(diǎn)布局變化是否影響父節(jié)點(diǎn),如果為true,當(dāng)子節(jié)點(diǎn)布局發(fā)生變化時(shí)父節(jié)點(diǎn)都會(huì)標(biāo)記為需要重新布局,如果為false,則子節(jié)點(diǎn)布局發(fā)生變化后不會(huì)影響父節(jié)點(diǎn)。

#relayoutBoundary

上面layout()源碼中定義了一個(gè)relayoutBoundary變量,什么是 relayoutBoundary?在前面介紹Element時(shí),我們講過當(dāng)一個(gè)Element標(biāo)記為 dirty 時(shí)便會(huì)重新 build,這時(shí)RenderObject便會(huì)重新布局,我們是通過調(diào)用 markNeedsBuild() 來標(biāo)記Element為 dirty 的。在RenderObject中有一個(gè)類似的markNeedsLayout()方法,它會(huì)將RenderObject的布局狀態(tài)標(biāo)記為 dirty,這樣在下一個(gè) frame 中便會(huì)重新 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)一個(gè)控件的大小被改變時(shí)可能會(huì)影響到它的 parent,因此 parent 也需要被重新布局,那么到什么時(shí)候是個(gè)頭呢?答案就是 relayoutBoundary,如果一個(gè) RenderObjectrelayoutBoundary,就表示它的大小變化不會(huì)再影響到 parent 的大小了,于是 parent 也就不用重新布局了。

#performResize 和 performLayout

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

performLayout() 方法中除了完成自身布局,也必須完成子節(jié)點(diǎn)的布局,這是因?yàn)橹挥懈缸庸?jié)點(diǎn)全部完成后布局流程才算真正完成。所以最終的調(diào)用棧將會(huì)變成:layout() > performResize()/performLayout() > child.layout() > ... ,如此遞歸完成整個(gè)UI的布局。

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

#ParentData

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

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

BoxParentData定義如下:

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


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

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

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

#14.3.2 繪制過程

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

void paint(PaintingContext context, Offset offset) { }

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

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

@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)容,省略
    // 繪制溢出部分的錯(cuò)誤提示樣式
    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;
  }());
}

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

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

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

當(dāng)需要繪制的內(nèi)容大小溢出當(dāng)前空間時(shí),將會(huì)執(zhí)行paintOverflowIndicator() 來繪制溢出部分提示,這個(gè)就是我們經(jīng)??吹降囊绯鎏崾?,如圖14-3所示:

#RepaintBoundary

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

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

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

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

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

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

很明顯了,獨(dú)立繪制是通過在不同的 layer(層)上繪制的。所以,很明顯,正確使用isRepaintBoundary屬性可以提高繪制效率,避免不必要的重繪。具體原理是:和觸發(fā)重新 build 和 layout 類似,RenderObject也提供了一個(gè)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, 會(huì)和一個(gè)祖先節(jié)點(diǎn)共用一個(gè)layer  
    assert(_layer == null);
    final RenderObject parent = this.parent;
    // 向父級(jí)遞歸查找  
    parent.markNeedsPaint();
    assert(parent == this.parent);
  } else {
    // 如果直到根節(jié)點(diǎn)也沒找到一個(gè)Layer,那么便需要繪制自身,因?yàn)闆]有其它節(jié)點(diǎn)可以繪制根節(jié)點(diǎn)。  
    if (owner != null)
      owner.requestVisualUpdate();
  }
}

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

還有一個(gè)問題,通過RepaintBoundary 如何設(shè)置isRepaintBoundary屬性呢?其實(shí),如果使用了RepaintBoundary,其對(duì)應(yīng)的RenderRepaintBoundary會(huì)自動(dòng)將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 命中測(cè)試

我們?cè)凇笆录幚砼c通知”一章中已經(jīng)講過 Flutter 事件機(jī)制和命中測(cè)試流程,本節(jié)我們看一下其內(nèi)部實(shí)現(xiàn)原理。

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

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

我們?cè)倏纯?code>RenderBox默認(rèn)的hitTest()實(shí)現(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)的實(shí)現(xiàn)里調(diào)用了hitTestSelf()hitTestChildren()兩個(gè)方法,這兩個(gè)方法默認(rèn)實(shí)現(xiàn)如下:

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

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

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

#14.3.4 語義化

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

#14.3.5 總結(jié)

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

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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)