在上一節(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ì)通過子RenderObject
的parentData
存儲(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
。
在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()
方法,下面我們看看RenderObject
中layout()
方法的大致實(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)。
上面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,我們看看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)一個(gè)控件的大小被改變時(shí)可能會(huì)影響到它的 parent,因此 parent 也需要被重新布局,那么到什么時(shí)候是個(gè)頭呢?答案就是 relayoutBoundary
,如果一個(gè) RenderObject
是 relayoutBoundary
,就表示它的大小變化不會(huì)再影響到 parent 的大小了,于是 parent 也就不用重新布局了。
RenderBox
實(shí)際的測(cè)量和布局邏輯是在performResize()
和 performLayout()
兩個(gè)方法中, RenderBox 子類需要實(shí)現(xiàn)這兩個(gè)方法來定制自身的布局邏輯。根據(jù)layout()
源碼可以看出只有 sizedByParent
為 true
時(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)用。
當(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ù)正是通過RenderObject
的parentData
屬性來保存的。在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';
}
一定要注意,
RenderObject
的parentData
只能通過父元素設(shè)置.
當(dāng)然,ParentData
并不僅僅可以用來存儲(chǔ)偏移信息,通常所有和子節(jié)點(diǎn)特定的數(shù)據(jù)都可以存儲(chǔ)到子節(jié)點(diǎn)的ParentData
中,如ContainerBox
的ParentData
就保存了指向兄弟節(jié)點(diǎn)的previousSibling
和nextSibling
,Element.visitChildren()
方法也正是通過它們來實(shí)現(xiàn)對(duì)子節(jié)點(diǎn)的遍歷。再比如KeepAlive
組件,它使用KeepAliveParentDataMixin
(繼承自ParentData
) 來保存子節(jié)的keepAlive
狀態(tài)。
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所示:
我們已經(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.isRepaintBoundary
為 true
則會(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è)isRepaintBoundary
為 true
的RenderObject
時(shí),才會(huì)觸發(fā)重繪,這樣便可以實(shí)現(xiàn)局部重繪。當(dāng) 有RenderObject
繪制的很頻繁或很復(fù)雜時(shí),可以通過 RepaintBoundary Widget 來指定isRepaintBoundary
為 true
,這樣在繪制時(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;
}
我們?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è)試,下面是RenderView
的hitTest()
源碼:
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)單的返回true
或false
。
語義化即 Semantics,主要是提供給讀屏軟件的接口,也是實(shí)現(xiàn)輔助功能的基礎(chǔ),通過語義化接口可以讓機(jī)器理解頁(yè)面上的內(nèi)容,對(duì)于有視力障礙用戶可以使用讀屏軟件來理解 UI 內(nèi)容。如果一個(gè)RenderObject
要支持語義化接口,可以實(shí)現(xiàn) describeApproximatePaintClip
和 visitChildrenForSemantics
方法和semanticsAnnotator
getter。更多關(guān)于語義化的信息可以查看 API 文檔。
本節(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
,這樣可以幫我們減少一部分工作。
更多建議: