在上一節(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
通常會通過子RenderObject
的parentData
存儲一些和子元素相關(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
。
在RenderBox
中,有個size
屬性用來保存控件的寬和高。RenderBox
的 layout 是通過在組件樹中從上往下傳遞BoxConstraints
對象的實現(xiàn)的。BoxConstraints
對象可以限制子節(jié)點的最大和最小寬高,子節(jié)點必須遵守父節(jié)點給定的限制條件。
在布局階段,父節(jié)點會調(diào)用子節(jié)點的layout()
方法,下面我們看看RenderObject
中layout()
方法的大致實現(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é)點。
上面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,我們看看RenderObject
的markNeedsLayout()
的部分源碼:
void markNeedsLayout() {
...
assert(_relayoutBoundary != null);
if (_relayoutBoundary != this) {
markParentNeedsLayout();
} else {
_needsLayout = true;
if (owner != null) {
...
owner._nodesNeedingLayout.add(this);
owner.requestVisualUpdate();
}
}
}
代碼大致邏輯是先判斷自身是不是relayoutBoundary
,如果不是就繼續(xù)向 parent 查找,一直向上查找到是 relayoutBoundary
的 RenderObject
為止,然后再將其標(biāo)記為 dirty 的。這樣來看它的作用就比較明顯了,意思就是當(dāng)一個控件的大小被改變時可能會影響到它的 parent,因此 parent 也需要被重新布局,那么到什么時候是個頭呢?答案就是 relayoutBoundary
,如果一個 RenderObject
是 relayoutBoundary
,就表示它的大小變化不會再影響到 parent 的大小了,于是 parent 也就不用重新布局了。
RenderBox
實際的測量和布局邏輯是在performResize()
和 performLayout()
兩個方法中, RenderBox 子類需要實現(xiàn)這兩個方法來定制自身的布局邏輯。根據(jù)layout()
源碼可以看出只有 sizedByParent
為 true
時,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)用。
當(dāng) layout 結(jié)束后,每個節(jié)點的位置(相對于父節(jié)點的偏移)就已經(jīng)確定了,RenderObject
就可以根據(jù)位置信息來進行最終的繪制。但是在 layout 過程中,節(jié)點的位置信息怎么保存?對于大多數(shù)RenderBox
子類來說如果子類只有一個子節(jié)點,那么子節(jié)點偏移一般都是Offset.zero
,如果有多個子節(jié)點,則每個子節(jié)點的偏移就可能不同。而子節(jié)點在父節(jié)點的偏移數(shù)據(jù)正是通過RenderObject
的parentData
屬性來保存的。在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';
}
一定要注意,
RenderObject
的parentData
只能通過父元素設(shè)置.
當(dāng)然,ParentData
并不僅僅可以用來存儲偏移信息,通常所有和子節(jié)點特定的數(shù)據(jù)都可以存儲到子節(jié)點的ParentData
中,如ContainerBox
的ParentData
就保存了指向兄弟節(jié)點的previousSibling
和nextSibling
,Element.visitChildren()
方法也正是通過它們來實現(xiàn)對子節(jié)點的遍歷。再比如KeepAlive
組件,它使用KeepAliveParentDataMixin
(繼承自ParentData
) 來保存子節(jié)的keepAlive
狀態(tài)。
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所示:
我們已經(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.isRepaintBoundary
為 true
則會調(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é)點查找,直到找到 一個isRepaintBoundary
為 true
的RenderObject
時,才會觸發(fā)重繪,這樣便可以實現(xiàn)局部重繪。當(dāng) 有RenderObject
繪制的很頻繁或很復(fù)雜時,可以通過 RepaintBoundary Widget 來指定isRepaintBoundary
為 true
,這樣在繪制時僅會重繪自身而無需重繪它的 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;
}
我們在“事件處理與通知”一章中已經(jīng)講過 Flutter 事件機制和命中測試流程,本節(jié)我們看一下其內(nèi)部實現(xiàn)原理。
一個對象是否可以響應(yīng)事件,取決于其對命中測試的返回,當(dāng)發(fā)生用戶事件時,會從根節(jié)點(RenderView
)開始進行命中測試,下面是RenderView
的hitTest()
源碼:
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()
則只需要簡單的返回true
或false
。
語義化即 Semantics,主要是提供給讀屏軟件的接口,也是實現(xiàn)輔助功能的基礎(chǔ),通過語義化接口可以讓機器理解頁面上的內(nèi)容,對于有視力障礙用戶可以使用讀屏軟件來理解 UI 內(nèi)容。如果一個RenderObject
要支持語義化接口,可以實現(xiàn) describeApproximatePaintClip
和 visitChildrenForSemantics
方法和semanticsAnnotator
getter。更多關(guān)于語義化的信息可以查看 API 文檔。
本節(jié)我們介紹了RenderObject
主要的功能和方法,理解這些內(nèi)容可以幫助我們更好的理解 Flutter UI 底層原理。我們也可以看到,如果要從頭到尾實現(xiàn)一個RenderObject
是比較麻煩的,我們必須去實現(xiàn) layout、繪制和命中測試邏輯,但是值得慶幸的是,大多數(shù)時候我們可以直接在 Widget 層通過組合或者CustomPaint
完成自定義UI。如果遇到只能定義一個新RenderObject
的場景時(如要實現(xiàn)一個新的 layout 算法的布局容器),可以直接繼承自RenderBox
,這樣可以幫我們減少一部分工作。
更多建議: