一,概述
每個Element都對應一個RenderObject,我們可以通過Element.renderObject 來獲取。並且我們也說過RenderObject的主要職責是Layout和繪制,所有的RenderObject會組成一棵渲染樹Render Tree。
- RenderObject就是渲染樹中的一個對象,它擁有一個parent和一個parentData 插槽(slot),所謂插槽,就是指預留的一個接口或位置,這個接口和位置是由其它對象來接入或占據的,這個接口或位置在軟件中通常用預留變量來表示,而parentData正是一個預留變量,它正是由parent 來賦值的,parent通常會通過子RenderObject的parentData存儲一些和子元素相關的數據,如在Stack布局中,RenderStack就會將子元素的偏移數據存儲在子元素的parentData中(具體可以查看Positioned實現)。
- RenderObject類本身實現了一套基礎的layout和繪制協議,但是並沒有定義子節點模型(如一個節點可以有幾個子節點,沒有子節點?一個?兩個?或者更多?)。 它也沒有定義坐標系統(如子節點定位是在笛卡爾坐標中還是極坐標?)和具體的布局協議(是通過寬高還是通過constraint和size?,或者是否由父節點在子節點布局之前或之后設置子節點的大小和位置等)。
- Flutter提供了一個RenderBox類,它繼承自RenderObject,布局坐標系統采用笛卡爾坐標系,這和Android和iOS原生坐標系是一致的,都是屏幕的top、left是原點,然后分寬高兩個軸,大多數情況下,我們直接使用RenderBox就可以了,除非遇到要自定義布局模型或坐標系統的情況。
二,布局
- Constraints:
在 RenderBox 中,有個 size屬性用來保存控件的寬和高。RenderBox的layout是通過在組件樹中從上往下傳遞BoxConstraints對象的實現的。BoxConstraints對象可以限制子節點的最大和最小寬高,子節點必須遵守父節點給定的限制條件。
在布局階段,父節點會調用子節點的layout()方法。
/// Compute the layout for this render object. /// /// This method is the main entry point for parents to ask their children to /// update their layout information. The parent passes a constraints object, /// which informs the child as which layouts are permissible. The child is /// required to obey the given constraints. /// /// If the parent reads information computed during the child's layout, the /// parent must pass true for `parentUsesSize`. In that case, the parent will /// be marked as needing layout whenever the child is marked as needing layout /// because the parent's layout information depends on the child's layout /// information. If the parent uses the default value (false) for /// `parentUsesSize`, the child can change its layout information (subject to /// the given constraints) without informing the parent. /// /// Subclasses should not override [layout] directly. Instead, they should /// override [performResize] and/or [performLayout]. The [layout] method /// delegates the actual work to [performResize] and [performLayout]. /// /// The parent's [performLayout] method should call the [layout] of all its /// children unconditionally. It is the [layout] method's responsibility (as /// implemented here) to return early if the child does not need to do any /// work to update its layout information. void layout(Constraints constraints, { bool parentUsesSize = false }) { assert(constraints != null); assert(constraints.debugAssertIsValid( isAppliedConstraint: true, informationCollector: (StringBuffer information) { final List<String> stack = StackTrace.current.toString().split('\n'); int targetFrame; final Pattern layoutFramePattern = RegExp(r'^#[0-9]+ +RenderObject.layout \('); for (int i = 0; i < stack.length; i += 1) { if (layoutFramePattern.matchAsPrefix(stack[i]) != null) { targetFrame = i + 1; break; } } if (targetFrame != null && targetFrame < stack.length) { information.writeln( 'These invalid constraints were provided to $runtimeType\'s layout() ' 'function by the following function, which probably computed the ' 'invalid constraints in question:' ); final Pattern targetFramePattern = RegExp(r'^#[0-9]+ +(.+)$'); final Match targetFrameMatch = targetFramePattern.matchAsPrefix(stack[targetFrame]); if (targetFrameMatch != null && targetFrameMatch.groupCount > 0) { information.writeln(' ${targetFrameMatch.group(1)}'); } else { information.writeln(stack[targetFrame]); } } } )); assert(!_debugDoingThisResize); assert(!_debugDoingThisLayout); RenderObject relayoutBoundary; if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) { relayoutBoundary = this; } else { final RenderObject parent = this.parent; relayoutBoundary = parent._relayoutBoundary; } assert(() { _debugCanParentUseSize = parentUsesSize; return true; }()); if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) { assert(() { // in case parentUsesSize changed since the last invocation, set size // to itself, so it has the right internal debug values. _debugDoingThisResize = sizedByParent; _debugDoingThisLayout = !sizedByParent; final RenderObject debugPreviousActiveLayout = _debugActiveLayout; _debugActiveLayout = this; debugResetSize(); _debugActiveLayout = debugPreviousActiveLayout; _debugDoingThisLayout = false; _debugDoingThisResize = false; return true; }()); return; } _constraints = constraints; _relayoutBoundary = relayoutBoundary; assert(!_debugMutationsLocked); assert(!_doingThisLayoutWithCallback); assert(() { _debugMutationsLocked = true; if (debugPrintLayouts) debugPrint('Laying out (${sizedByParent ? "with separate resize" : "with resize allowed"}) $this'); return true; }()); if (sizedByParent) { assert(() { _debugDoingThisResize = true; return true; }()); try { performResize(); assert(() { debugAssertDoesMeetConstraints(); return true; }()); } catch (e, stack) { _debugReportException('performResize', e, stack); } assert(() { _debugDoingThisResize = false; return true; }()); } RenderObject debugPreviousActiveLayout; assert(() { _debugDoingThisLayout = true; debugPreviousActiveLayout = _debugActiveLayout; _debugActiveLayout = this; return true; }()); try { performLayout(); markNeedsSemanticsUpdate(); assert(() { debugAssertDoesMeetConstraints(); return true; }()); } catch (e, stack) { _debugReportException('performLayout', e, stack); } assert(() { _debugActiveLayout = debugPreviousActiveLayout; _debugDoingThisLayout = false; _debugMutationsLocked = false; return true; }()); _needsLayout = false; markNeedsPaint(); }
上述源碼中,layout方法需要傳入兩個參數,第一個為constraints,即 父節點對子節點大小的限制,該值根據父節點的布局邏輯確定。另外一個參數是 parentUsesSize,該值用於確定 relayoutBoundary,該參數表示子節點布局變化是否影響父節點,如果為true,當子節點布局發生變化時父節點都會標記為需要重新布局,如果為false,則子節點布局發生變化后不會影響父節點。
【注意上述源碼中有如下變量】
- relayoutBoundary:
當一個Element標記為 dirty 時便會重新build,這時 RenderObject 便會重新布局,我們是通過調用 markNeedsBuild() 來標記Element為dirty的。在 RenderObject中有一個類似的markNeedsLayout()方法,它會將 RenderObject 的布局狀態標記為 dirty,這樣在下一個frame中便會重新layout。
#RenderObject#markNeedsLayout():
/// Mark this render object's layout information as dirty, and either register /// this object with its [PipelineOwner], or defer to the parent, depending on /// whether this object is a relayout boundary or not respectively. /// /// ## Background /// /// Rather than eagerly updating layout information in response to writes into /// a render object, we instead mark the layout information as dirty, which /// schedules a visual update. As part of the visual update, the rendering /// pipeline updates the render object's layout information. /// /// This mechanism batches the layout work so that multiple sequential writes /// are coalesced, removing redundant computation. /// /// If a render object's parent indicates that it uses the size of one of its /// render object children when computing its layout information, this /// function, when called for the child, will also mark the parent as needing /// layout. In that case, since both the parent and the child need to have /// their layout recomputed, the pipeline owner is only notified about the /// parent; when the parent is laid out, it will call the child's [layout] /// method and thus the child will be laid out as well. /// /// Once [markNeedsLayout] has been called on a render object, /// [debugNeedsLayout] returns true for that render object until just after /// the pipeline owner has called [layout] on the render object. /// /// ## Special cases /// /// Some subclasses of [RenderObject], notably [RenderBox], have other /// situations in which the parent needs to be notified if the child is /// dirtied. Such subclasses override markNeedsLayout and either call /// `super.markNeedsLayout()`, in the normal case, or call /// [markParentNeedsLayout], in the case where the parent needs to be laid out /// as well as the child. /// /// If [sizedByParent] has changed, calls /// [markNeedsLayoutForSizedByParentChange] instead of [markNeedsLayout]. void markNeedsLayout() { assert(_debugCanPerformMutations); if (_needsLayout) { assert(_debugSubtreeRelayoutRootAlreadyMarkedNeedsLayout()); return; } assert(_relayoutBoundary != null); if (_relayoutBoundary != this) { markParentNeedsLayout(); } else { _needsLayout = true; if (owner != null) { assert(() { if (debugPrintMarkNeedsLayoutStacks) debugPrintStack(label: 'markNeedsLayout() called for $this'); return true; }()); owner._nodesNeedingLayout.add(this); owner.requestVisualUpdate(); } } }
大致邏輯是先判斷自身是不是 relayoutBoundary,如果不是就繼續向 parent 查找,一直向上查找到是 relayoutBoundary 的 RenderObject為止,然后再將其標記為 dirty 的。這樣來看它的作用就比較明顯了,意思就是當一個控件的大小被改變時可能會影響到它的 parent,因此 parent 也需要被重新布局,那么到什么時候是個頭呢?答案就是 relayoutBoundary,如果一個 RenderObject 是 relayoutBoundary,就表示它的大小變化不會再影響到 parent 的大小了,於是 parent 也就不用重新布局了。 - performResize 和 performLayout
RenderBox實際的測量和布局邏輯是在performResize() 和 performLayout()兩個方法中,RenderBox子類需要實現這兩個方法來定制自身的布局邏輯。根據layout() 源碼可以看出只有 sizedByParent 為 true時,performResize() 才會被調用,而 performLayout() 是每次布局都會被調用的。sizedByParent 意為該節點的大小是否僅通過 parent 傳給它的 constraints 就可以確定了,即該節點的大小與它自身的屬性和其子節點無關,比如如果一個控件永遠充滿 parent 的大小,那么 sizedByParent就應該返回true,此時其大小在 performResize() 中就確定了,在后面的 performLayout() 方法中將不會再被修改了,這種情況下 performLayout() 只負責布局子節點。
在 performLayout() 方法中除了完成自身布局,也必須完成子節點的布局,這是因為只有父子節點全部完成后布局流程才算真正完成。所以最終的調用棧將會變成:layout() > performResize()/performLayout() > child.layout() > ...,如此遞歸完成整個UI的布局。
RenderBox子類要定制布局算法不應該重寫layout()方法,因為對於任何RenderBox的子類來說,它的layout流程基本是相同的,不同之處只在具體的布局算法,而具體的布局算法子類應該通過重寫performResize() 和 performLayout()兩個方法來實現,他們會在layout()中被調用。
- ParentData
當layout結束后,每個節點的位置(相對於父節點的偏移)就已經確定了,RenderObject就可以根據位置信息來進行最終的繪制。但是在layout過程中,節點的位置信息怎么保存?對於大多數RenderBox子類來說如果子類只有一個子節點,那么子節點偏移一般都是Offset.zero ,如果有多個子節點,則每個子節點的偏移就可能不同。而子節點在父節點的偏移數據正是通過RenderObject的parentData屬性來保存的。在RenderBox中,其parentData屬性默認是一個BoxParentData對象,該屬性只能通過父節點的setupParentData()方法來設置:
一定要注意,RenderObject的parentData 只能通過父元素設置.ParentData並不僅僅可以用來存儲偏移信息,通常所有和子節點特定的數據都可以存儲到子節點的ParentData中,如ContainerBox的ParentData就保存了指向兄弟節點的previousSibling和nextSibling,Element.visitChildren()方法也正是通過它們來實現對子節點的遍歷。再比如KeepAlive Widget,它使用KeepAliveParentDataMixin(繼承自ParentData) 來保存子節的keepAlive狀態。
三,繪制:
RenderObject可以通過paint()方法來完成具體繪制邏輯,流程和布局流程相似,子類可以實現paint()方法來完成自身的繪制邏輯,paint()簽名如下:
void paint(PaintingContext context, Offset offset) { }
通過context.canvas可以取到Canvas對象,接下來就可以調用Canvas API來實現具體的繪制邏輯。如果節點有子節點,它除了完成自身繪制邏輯之外,還要調用子節點的繪制方法。以RenderFlex對象為例說明:
@override void paint(PaintingContext context, Offset offset) { // 如果子元素未超出當前邊界,則繪制子元素 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 = '...'; //溢出提示內容,省略 // 繪制溢出部分的錯誤提示樣式 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; }()); }
首先判斷有無溢出,如果沒有則調用defaultPaint(context, offset)
來完成繪制。
void defaultPaint(PaintingContext context, Offset offset) { ChildType child = firstChild; while (child != null) { final ParentDataType childParentData = child.parentData; //繪制子節點, context.paintChild(child, childParentData.offset + offset); child = childParentData.nextSibling; } }
由於Flex本身沒有需要繪制的東西,所以直接遍歷其子節點,然后調用paintChild()來繪制子節點,同時將子節點ParentData中在layout階段保存的offset加上自身偏移作為第二個參數傳遞給paintChild()。而如果子節點還有子節點時,paintChild()方法還會調用子節點的paint()方法,如此遞歸完成整個節點樹的繪制,最終調用棧為: paint() > paintChild() > paint() ... 。
當需要繪制的內容大小溢出當前空間時,將會執行paintOverflowIndicator() 來繪制溢出部分提示,就是我們經常看到的溢出提示。
- RepaintBoundary
與 RelayoutBoundary 相似,RepaintBoundary是用於在確定重繪邊界的,與 RelayoutBoundary 不同的是,這個繪制邊界需要由開發者通過RepaintBoundary Widget自己指定,如:
CustomPaint( size: Size(300, 300), //指定畫布大小 painter: MyPainter(), child: RepaintBoundary( child: Container(...), ), ),
RepaintBoundary的原理,RenderObject有一個isRepaintBoundary屬性,該屬性決定這個RenderObject重繪時是否獨立於其父元素,如果該屬性值為true ,則獨立繪制,反之則一起繪制。那獨立繪制是怎么實現的呢? 答案就在paintChild()源碼中:
void paintChild(RenderObject child, Offset offset) { ... if (child.isRepaintBoundary) { stopRecordingIfNeeded(); _compositeChild(child, offset); } else { child._paintWithContext(this, offset); } ... }
在繪制子節點時,如果child.isRepaintBoundary 為 true則會調用_compositeChild()方法,_compositeChild()源碼如下:
void _compositeChild(RenderObject child, Offset offset) { // 給子節點創建一個layer ,然后再上面繪制子節點 if (child._needsPaint) { repaintCompositedChild(child, debugAlsoPaintedParent: true); } else { ... } assert(child._layer != null); child._layer.offset = offset; appendLayer(child._layer); }
獨立繪制是通過在不同的layer(層)上繪制的。所以,很明顯,正確使用isRepaintBoundary屬性可以提高繪制效率,避免不必要的重繪。具體原理是:和觸發重新build和layout類似,RenderObject也提供了一個markNeedsPaint()方法,其源碼如下:
void markNeedsPaint() { ... //如果RenderObject.isRepaintBoundary 為true,則該RenderObject擁有layer,直接繪制 if (isRepaintBoundary) { ... if (owner != null) { //找到最近的layer,繪制 owner._nodesNeedingPaint.add(this); owner.requestVisualUpdate(); } } else if (parent is RenderObject) { // 沒有自己的layer, 會和一個祖先節點共用一個layer assert(_layer == null); final RenderObject parent = this.parent; // 向父級遞歸查找 parent.markNeedsPaint(); assert(parent == this.parent); } else { // 如果直到根節點也沒找到一個Layer,那么便需要繪制自身,因為沒有其它節點可以繪制根節點。 if (owner != null) owner.requestVisualUpdate(); } }
當調用 markNeedsPaint() 方法時,會從當前 RenderObject 開始一直向父節點查找,直到找到 一個isRepaintBoundary 為 true的RenderObject 時,才會觸發重繪,這樣便可以實現局部重繪。當 有RenderObject 繪制的很頻繁或很復雜時,可以通過RepaintBoundary Widget來指定isRepaintBoundary 為 true,這樣在繪制時僅會重繪自身而無需重繪它的 parent,如此便可提高性能。
還有一個問題,通過RepaintBoundary Widget如何設置isRepaintBoundary屬性呢?其實如果使用了RepaintBoundary Widget,其對應的RenderRepaintBoundary會自動將isRepaintBoundary設為true的:class RenderRepaintBoundary extends RenderProxyBox { /// Creates a repaint boundary around [child]. RenderRepaintBoundary({ RenderBox child }) : super(child); @override bool get isRepaintBoundary => true; }
四,命中測試:(Flutter事件機制和命中測試流程)
一個對象是否可以響應事件,取決於其對命中測試的返回,當發生用戶事件時,會從根節點(RenderView)開始進行命中測試,下面是RenderView的hitTest()源碼:
bool hitTest(HitTestResult result, { Offset position }) { if (child != null) child.hitTest(result, position: position); //遞歸子RenderBox進行命中測試 result.add(HitTestEntry(this)); //將測試結果添加到result中 return true; }
RenderBox默認的hitTest()
實現:
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; }
默認的實現里調用了hitTestSelf()和hitTestChildren()兩個方法,這兩個方法默認實現如下:
@protected bool hitTestSelf(Offset position) => false; @protected bool hitTestChildren(HitTestResult result, { Offset position }) => false;
hitTest 方法用來判斷該 RenderObject 是否在被點擊的范圍內,同時負責將被點擊的 RenderBox 添加到 HitTestResult 列表中,參數 position 為事件觸發的坐標(如果有的話),返回 true 則表示有 RenderBox 通過了命中測試,需要響應事件,反之則認為當前RenderBox沒有命中。在繼承RenderBox時,可以直接重寫hitTest()方法,也可以重寫 hitTestSelf() 或 hitTestChildren(), 唯一不同的是 hitTest()中需要將通過命中測試的節點信息添加到命中測試結果列表中,而 hitTestSelf() 和 hitTestChildren()則只需要簡單的返回true或false。
五,語義化
語義化即Semantics,主要是提供給讀屏軟件的接口,也是實現輔助功能的基礎,通過語義化接口可以讓機器理解頁面上的內容,對於有視力障礙用戶可以使用讀屏軟件來理解UI內容。如果一個RenderObject要支持語義化接口,可以實現 describeApproximatePaintClip和 visitChildrenForSemantics方法和semanticsAnnotator getter。
六,結語
如果要從頭到尾實現一個RenderObject是比較麻煩的,我們必須去實現layout、繪制和命中測試邏輯,但是值得慶幸的是,大多數時候我們可以直接在Widget層通過組合或者CustomPaint完成自定義UI。如果遇到只能定義一個新RenderObject的場景時(如要實現一個新的layout算法的布局容器),可以直接繼承自RenderBox,這樣可以幫我們減少一部分工作。
原文:https://blog.csdn.net/qq_39969226/article/details/94215076