# RenderObject和RenderBox
在上一節我們說過每個Element都對應一個RenderObject,我們可以通過`Element.renderObject` 來獲取。并且我們也說過RenderObject的主要職責是Layout和繪制,所有的RenderObject會組成一棵渲染樹Render Tree。本節我們將重點介紹一下RenderObject的作用。
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就可以了,除非遇到要自定義布局模型或坐標系統的情況,下面我們重點介紹一下RenderBox。
## 布局過程
### Constraints
在 RenderBox 中,有個 size屬性用來保存控件的寬和高。RenderBox的layout是通過在組件樹中從上往下傳遞`BoxConstraints`對象的實現的。`BoxConstraints`對象可以限制子節點的最大和最小寬高,子節點必須遵守父節點給定的限制條件。
在布局階段,父節點會調用子節點的`layout()`方法,下面我們看看RenderObject中`layout()`方法的大致實現(刪掉了一些無關代碼和異常捕獲):
```
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`方法需要傳入兩個參數,第一個為constraints,即 父節點對子節點大小的限制,該值根據父節點的布局邏輯確定。另外一個參數是 parentUsesSize,該值用于確定 `relayoutBoundary`,該參數表示子節點布局變化是否影響父節點,如果為`true`,當子節點布局發生變化時父節點都會標記為需要重新布局,如果為`false`,則子節點布局發生變化后不會影響父節點。
#### relayoutBoundary
上面`layout()`源碼中定義了一個`relayoutBoundary`變量,什么是 relayoutBoundary?在前面介紹Element時,我們講過當一個Element標記為 dirty 時便會重新build,這時 RenderObject 便會重新布局,我們是通過調用 `markNeedsBuild()` 來標記Element為dirty的。在 RenderObject中有一個類似的`markNeedsLayout()`方法,它會將 RenderObject 的布局狀態標記為 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,如果不是就繼續向 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()`方法來設置:
```
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表示在子節點在父節點坐標系中的繪制偏移
Offset offset = Offset.zero;
@override
String toString() => 'offset=$offset';
}
```
> 一定要注意,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
我們已經在CustomPaint一節中介紹過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。更多關于語義化的信息可以查看API文檔。
## 總結
本節我們介紹了RenderObject主要的功能和方法,理解這些內容可以幫助我們更好的理解Flutter UI底層原理。我們也可以看到,如果要從頭到尾實現一個RenderObject是比較麻煩的,我們必須去實現layout、繪制和命中測試邏輯,但是值得慶幸的是,大多數時候我們可以直接在Widget層通過組合或者CustomPaint完成自定義UI。如果遇到只能定義一個新RenderObject的場景時(如要實現一個新的layout算法的布局容器),可以直接繼承自RenderBox,這樣可以幫我們減少一部分工作。
- 緣起
- 起步
- 移動開發技術簡介
- Flutter簡介
- 搭建Flutter開發環境
- 常見配置問題
- Dart語言簡介
- 第一個Flutter應用
- 計數器示例
- 路由管理
- 包管理
- 資源管理
- 調試Flutter APP
- Dart線程模型及異常捕獲
- 基礎Widgets
- Widget簡介
- 文本、字體樣式
- 按鈕
- 圖片和Icon
- 單選框和復選框
- 輸入框和表單
- 布局類Widgets
- 布局類Widgets簡介
- 線性布局Row、Column
- 彈性布局Flex
- 流式布局Wrap、Flow
- 層疊布局Stack、Positioned
- 容器類Widgets
- Padding
- 布局限制類容器ConstrainedBox、SizeBox
- 裝飾容器DecoratedBox
- 變換Transform
- Container容器
- Scaffold、TabBar、底部導航
- 可滾動Widgets
- 可滾動Widgets簡介
- SingleChildScrollView
- ListView
- GridView
- CustomScrollView
- 滾動監聽及控制ScrollController
- 功能型Widgets
- 導航返回攔截-WillPopScope
- 數據共享-InheritedWidget
- 主題-Theme
- 事件處理與通知
- 原始指針事件處理
- 手勢識別
- 全局事件總線
- 通知Notification
- 動畫
- Flutter動畫簡介
- 動畫結構
- 自定義路由過渡動畫
- Hero動畫
- 交錯動畫
- 自定義Widget
- 自定義Widget方法簡介
- 通過組合現有Widget實現
- 實例:TurnBox
- CustomPaint與Canvas
- 實例:圓形漸變進度條(自繪)
- 文件操作與網絡請求
- 文件操作
- Http請求-HttpClient
- Http請求-Dio package
- 實例:Http分塊下載
- WebSocket
- 使用Socket API
- Json轉Model
- 包與插件
- 開發package
- 插件開發:平臺通道簡介
- 插件開發:實現Android端API
- 插件開發:實現IOS端API
- 系統能力調用
- 國際化
- 讓App支持多語言
- 實現Localizations
- 使用Intl包
- Flutter核心原理
- Flutter UI系統
- Element和BuildContext
- RenderObject與RenderBox
- Flutter從啟動到顯示