本文基於1.12.13+hotfix.8版本源碼分析。
一、RenderBox的用法
1、RenderBox的使用基本流程
在flutter中,我們最常接觸的,莫過於各種各樣的widget了,但是,實際負責渲染的RenderObject是很少接觸的(它們之間的關聯可以看看閑魚的這篇文章:https://www.yuque.com/xytech/flutter/tge705)。而作為一名天天向上的程序員,我們自然要去學習一下它的原理,做到知其然且知其所以然。本文會先來看看RenderBox的用法,以此拋磚引玉,便於后面繼續深入flutter的繪制原理。
使用RenderBox進行繪制,我們需要做三件事:
(1)測量
第一步,我們需要確定視圖大小,並賦值給父類的size屬性。測量有兩種情況,第一種是size由自身決定,第二種是由parent決定。
首先,由自身決定size的情況,需要在performLayout方法中完成測量,通過父類的constraints可得到滿足約束的值:
@override
void performLayout() {
size = Size(
constraints.constrainWidth(200),
constraints.constrainHeight(200),
);
}
第二種情況,size由parent決定,這種情況下視圖大小應該完全通過parent提供的constraints測量,不存在其它因素。這種情況下,只要parent的約束不發生變化,就不會重新測量。
這種情況需要重寫sizedByParent並返回true,然后在performResize中完成測量。
@override
void performResize() {
size = constraints.biggest;
}
@override
bool get sizedByParent => true;
看到這里,你可能會疑惑了,這兩個方法什么時候會被調用?順序是怎樣的?答案在RenderObject的layout方法中:
void layout(Constraints constraints, { bool parentUsesSize = false }) {
//計算relayoutBoundary
......
//layout
_constraints = constraints;
if (sizedByParent) {
performResize();
}
performLayout();
......
}
}
(2)繪制
RenderBox的繪制與android原生的view繪制非常相似,同樣是Paint+Canvas的組合,而且api也非常接近,會非常容易上手。
@override
void paint(PaintingContext context, Offset offset) {
Paint paint = Paint()
..color = _color
..style = PaintingStyle.fill;
context.canvas.drawRect(
Rect.fromLTRB(
0,
0,
size.width,
size.height,
),
paint);
}
這樣是不是就萬事大吉了呢?如果通過上面的代碼進行繪制,你會發現,不管在外層怎么設置位置,繪制出來的矩形都是固定在屏幕左上角的!怎么回事?
這里就是flutter中繪制與android的最大不同:在這里繪制的坐標系是全局坐標系,即原點在屏幕左上角,而非視圖左上角。
細心的同學可能已經發現,paint方法中還有一個offset參數,這就是經過parent的約束后,當前視圖的偏移量,繪制時應該將它考慮進去:
@override
void paint(PaintingContext context, Offset offset) {
Paint paint = Paint()
..color = _color
..style = PaintingStyle.fill;
context.canvas.drawRect(
Rect.fromLTRB(
offset.dx,
offset.dy,
offset.dx + size.width,
offset.dy + size.height,
),
paint);
}
(3)更新
在flutter中,是由Widget的配置發生變更而引起的rebuild,而這就是我們要實現的第三步:當視圖屬性發生變更時,標記重新布局或重新繪制,當屏幕刷新時就會做相應的刷新。
這里涉及到兩個方法:markNeedsLayout、markNeedsPaint。顧名思義,前者標記重布局,后者標記重繪。
我們需要做的,就是根據屬性的影響范圍,在更新屬性時,調用合適的標記方法,例如color變化時調用markNeedsPaint,width變化時調用markNeedsLayout。另外,兩者都需要更新的情況下,只調用markNeedsLayout即可,不需要兩個方法都調。
set width(double width) {
if (width != _width) {
_width = width;
markNeedsLayout();
}
}
set color(Color color) {
if (color != _color) {
_color = color;
markNeedsPaint();
}
}
2、RenderObjectWidget
(1)簡介
上面講了一大堆RenderBox的用法,但是,這玩意兒怎么用到我們熟知的Widget里面去?
按照正常流程,我們得實現一個Element和一個Widget,然后在Widget中創建Element,在Element中創建和更新RenderObject,另外還得管理一大堆狀態,處理非常繁瑣。所幸flutter為我們封裝了這一套邏輯,即RenderObjectWidget。
相信看到這里的同學都對StatelessWidget和StatefulWidget不會陌生,但其實,StatelessWidget和StatefulWidget僅負責屬性、生命周期等的管理,在它們的build方法實現中都會創建RenderObjectWidget,通過它來實現與RenderObject的關聯。
舉個栗子,我們經常使用的Image是個StatefulWidget,對應的state的build方法中實際返回了一個RawImage對象,而這個RawImage是繼承自LeafRenderObjectWidget的,這正是RenderObjectWidget的一個子類;再比如Text,它build方法中創建的RichText是繼承自MultiChildRenderObjectWidget,這同樣是RenderObjectWidget的一個子類。
我們再看看RenderObjectWidget頂部的注釋即可明白:
RenderObjectWidgets provide the configuration for [RenderObjectElement]s,
which wrap [RenderObject]s, which provide the actual rendering of the
application.
大概意思就是RenderObject才是實際負責渲染應用的,而RenderObjectWidget提供包裝了RenderObject的配置,方便我們使用。
另外,flutter還分別實現了幾個子類,進一步封裝了RenderObjectWidget,它們分別是LeafRenderObjectWidget、SingleChildRenderObjectWidget、MultiChildRenderObjectWidget。其中,LeafRenderObjectWidget是葉節點,不含子Widget;SingleChildRenderObjectWidget僅有一個child;而MultiChildRenderObjectWidget則是含有children列表。這幾個子類根據child的情況分別創建了對應的Element,所以通過這幾個子類,我們只需要關注RenderObject的創建和更新。
(2)用法
以最簡單的LeafRenderObjectWidget為例,我們需要實現createRenderObject、updateRenderObject兩個方法:
class CustomRenderWidget extends LeafRenderObjectWidget {
CustomRenderWidget({
this.width = 0,
this.height = 0,
this.color,
});
final double width;
final double height;
final Color color;
@override
RenderObject createRenderObject(BuildContext context) {
return CustomRenderBox(width, height, color);
}
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
CustomRenderBox renderBox = renderObject as CustomRenderBox;
renderBox
..width = width
..height = height
..color = color;
}
}
3、非容器控件的hitTest
通過上面的內容,我們已經可以實現自定義控件並用到界面開發中,但是距離一個完整的控件還差最后一步:命中測試。當用戶使用手勢,flutter會將手勢信息交由控件進行檢查是否命中。
RenderBox中命中測試的方法有仨:hitTest、hitTestSelf、hitTestChildren,其中hitTest默認實現是調用另外兩個方法的:
bool hitTest(BoxHitTestResult result, { @required Offset position }) {
if (_size.contains(position)) {
// 從這里也能看到,當命中children時,不會再進行自身的命中測試
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
所以重寫命中測試方法有兩個方案,一是重寫hitTest,這種方法需要將命中測試的信息加到BoxHitTestResult中;二是重寫hitTestSelf和hitTestChildren,這種方法就簡單地返回是否命中即可。
非容器類型的控件,只需要重寫hitTestSelf,返回true即命中,例如RawImage中:
@override
bool hitTestSelf(Offset position) => true;
二、容器類型的RenderBox
1、介紹
在繪制篇中,我們已經了解到如何使用RenderObjectWidget和RenderBox進行基礎的繪制,在本篇中,我們將繼續學習RenderBox如何管理子對象。首先,我們來看看RenderBox頂部的一段注釋:
For render objects with children, there are four possible scenarios:
* A single [RenderBox] child. In this scenario, consider inheriting from
[RenderProxyBox] (if the render object sizes itself to match the child) or
[RenderShiftedBox] (if the child will be smaller than the box and the box
will align the child inside itself).
* A single child, but it isn't a [RenderBox]. Use the
[RenderObjectWithChildMixin] mixin.
* A single list of children. Use the [ContainerRenderObjectMixin] mixin.
* A more complicated child model.
從上面我們可以了解到,帶有子對象的情況有四種:
(1)子對象只有一個,並且是RenderBox的子類。如果當前視圖需要根據子對象調整大小,則繼承RenderProxyBox;如果子對象小於當前視圖,且在當前視圖內部對齊,則繼承RenderShiftedBox(想一下Align會好理解一點);
(2)子對象只有一個,且非RenderBox子類,這種情況使用RenderObjectWithChildMixin;
(3)有多個子對象則使用ContainerRenderObjectMixin;
(4)更復雜的情況。
第四種情況是要用非鏈表的children結構時需要考慮的,比如children要用map或list等結構,這種情況需要繼承RenderObject去實現一套繪制協議,我們這里暫且先不討論。
而前三種情況其實注釋里的描述不夠明確,其實情況只有兩種,第一是帶有單一的child,第二是帶有一個children列表,上面的第一第二兩種情況其實可以合並為一種,為什么這么說呢?看下去吧~
2、單個子對象
(1)RenderProxyBox
這種情況其實就是當前容器沒有跟大小相關的屬性,size由子類決定,具體邏輯flutter已經在RenderProxyBoxMixin實現了,我們來看看:
void performLayout() {
if (child != null) {
child.layout(constraints, parentUsesSize: true);
size = child.size;
} else {
performResize();
}
}
邏輯非常簡單,如果有child,則直接使用child的size;如果沒有,就走performResize,而這里並沒有實現performResize,即走RenderBox的默認實現,取約束的最小值:
void performResize() {
size = constraints.smallest;
assert(size.isFinite);
}
而繪制方法中,通過PaintingContext的paintChild方法,即可繪制child:
@override
void paint(PaintingContext context, Offset offset) {
if (child != null)
context.paintChild(child, offset);
}
(2)RenderShiftedBox
這種情況則與RenderProxyBox相反,即當前容器有跟大小相關的屬性,比如padding。接下來就以非常常見的Padding為例,看看RenderPadding的布局方法:
@override
void performLayout() {
// 將padding的值按照語言方向解析
_resolve();
assert(_resolvedPadding != null);
if (child == null) {
// 如果沒有child,就按照垂直、水平方向的padding值計算得出size
size = constraints.constrain(Size(
_resolvedPadding.left + _resolvedPadding.right,
_resolvedPadding.top + _resolvedPadding.bottom,
));
return;
}
// 如果有child,則將當前約束減去padding值以后,再傳給child進行測量
final BoxConstraints innerConstraints = constraints.deflate(_resolvedPadding);
child.layout(innerConstraints, parentUsesSize: true);
// 測量完畢以后,計算出坐標偏移量,提供給child繪制時使用
// parentData是RenderObject的屬性,提供給父布局使用,用來存取child在父布局中的一些信息,包括位置等
final BoxParentData childParentData = child.parentData;
childParentData.offset = Offset(_resolvedPadding.left, _resolvedPadding.top);
// 最后得出大小是padding加上child的大小
size = constraints.constrain(Size(
_resolvedPadding.left + child.size.width + _resolvedPadding.right,
_resolvedPadding.top + child.size.height + _resolvedPadding.bottom,
));
}
可以看到,這里有三個關鍵步驟:第一,根據屬性將約束減去需要額外占用的寬高,然后傳給child進行測量;第二,測量完畢后計算出child需要用到的繪制偏移量;第三,根據屬性和child的size得出總寬高。
另外,RenderShiftedBox的paint方法邏輯與RenderProxyBox稍微有點不同,會對offset進行處理:
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
final BoxParentData childParentData = child.parentData;
context.paintChild(child, childParentData.offset + offset);
}
}
(3)RenderObjectWithChildMixin
回到上面的問題,為什么說RenderBox和非RenderBox的單一子對象是一樣的呢?其實,RenderProxyBox和RenderShiftedBox是專門為RenderBox的子類再封裝了一層便於使用,它們本身還是with了RenderObjectWithChildMixin:
class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {
/// 略
}
abstract class RenderShiftedBox extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
/// 略
}
經過前面的分析,我們知道RenderProxyBox和RenderShiftedBox只負責測量和繪制,那么RenderObjectWithChildMixin是做什么的呢?借助Android Studio的Structure窗口,我們可以看到:
除去debug的方法以外,這個類方法並不多。以attach為例:
@override
void attach(PipelineOwner owner) {
super.attach(owner);
if (_child != null)
_child.attach(owner);
}
代碼很少,就是在上層attach過來時,再attach自己的child,這里就涉及到渲染樹的知識點,這又是另一個話題了,現在我也還沒看到這里,后續我們再來分析這玩意兒~
一言蔽之,RenderObjectWithChildMixin實現了與渲染樹相關的child的管理。
(4)SingleChildRenderObjectWidget
同樣,定義完RenderBox以后,需要在一個Widget中進行創建,單個child的情況我們可以使用SingleChildRenderObjectWidget,與LeafRenderObjectWidget不同的地方在於需要在構造函數將child傳入:
class CustomRenderWidget extends SingleChildRenderObjectWidget {
CustomRenderWidget(Widget child) : super(child: child);
}
3、多個子對象
(1)ContainerRenderObjectMixin
相對於上面只有單個child的情況,多個子對象的情況稍微復雜一點,但也只是一點,其實區別不太大。同樣,關於與渲染樹相關的子對象管理,flutter也是提供了一個ContainerRenderObjectMixin,這里我們就不再分析它的原理了,只需要注意一個地方,當RenderBox被創建時,需要調一下addAll方法將children加入:
RenderListBody({
List<RenderBox> children,
AxisDirection axisDirection = AxisDirection.down,
}) : assert(axisDirection != null),
_axisDirection = axisDirection {
// 把children交給ContainerRenderObjectMixin管理
addAll(children);
}
(2)ContainerParentDataMixin
另外,ContainerDefaultsMixin指定了使用的ParentData必須是ContainerParentDataMixin的子類。ContainerParentDataMixin並不復雜,它的作用僅僅是實現了雙向鏈表結構的ParentData:
mixin ContainerParentDataMixin<ChildType extends RenderObject> on ParentData {
ChildType previousSibling;
ChildType nextSibling;
}
指定了ParentData的類型后,還需要在RenderBox的setupParentData檢查child使用的data類型是否符合,不符合則重新創建並替換:
@override
void setupParentData(RenderObject child) {
super.setupParentData(child);
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
(3)案例源碼分析
下面我們再以RenderStack為例,看看它的測量(函數寫得有點長,但分段看挺容易理解的):
@override
void performLayout() {
// 根據textDirection解析alignment
_resolve();
assert(_resolvedAlignment != null);
_hasVisualOverflow = false;
bool hasNonPositionedChildren = false;
// 如果沒有子對象,stack會充滿父布局
if (childCount == 0) {
size = constraints.biggest;
assert(size.isFinite);
return;
}
double width = constraints.minWidth;
double height = constraints.minHeight;
// 根據fit屬性調整約束
BoxConstraints nonPositionedConstraints;
assert(fit != null);
switch (fit) {
case StackFit.loose:
nonPositionedConstraints = constraints.loosen();
break;
case StackFit.expand:
nonPositionedConstraints = BoxConstraints.tight(constraints.biggest);
break;
case StackFit.passthrough:
nonPositionedConstraints = constraints;
break;
}
assert(nonPositionedConstraints != null);
// 遍歷所有沒有通過Positioned指定位置或大小的子對象,進行布局
RenderBox child = firstChild;
while (child != null) {
final StackParentData childParentData = child.parentData;
if (!childParentData.isPositioned) {
hasNonPositionedChildren = true;
// 這種情況通過根據fit轉換后的約束測量子對象
child.layout(nonPositionedConstraints, parentUsesSize: true);
// 測量完以后對比大小取最大值
final Size childSize = child.size;
width = math.max(width, childSize.width);
height = math.max(height, childSize.height);
}
child = childParentData.nextSibling;
}
if (hasNonPositionedChildren) {
// 如果存在沒用Positioned指定位置或大小的子對象,則取這些子對象的最大size(上面測量后得到的)
size = Size(width, height);
assert(size.width == constraints.constrainWidth(width));
assert(size.height == constraints.constrainHeight(height));
} else {
// 否則充滿父布局
size = constraints.biggest;
}
assert(size.isFinite);
// 遍歷計算約束、offset
child = firstChild;
while (child != null) {
final StackParentData childParentData = child.parentData;
if (!childParentData.isPositioned) {
// 沒指定位置或大小,則根據alignment來計算offset
childParentData.offset = _resolvedAlignment.alongOffset(size - child.size);
} else {
BoxConstraints childConstraints = const BoxConstraints();
if (childParentData.left != null && childParentData.right != null)
// 指定了left和right,根據stack的寬度算出child的寬度
childConstraints = childConstraints.tighten(width: size.width - childParentData.right - childParentData.left);
else if (childParentData.width != null)
// 這里直接指定了寬度
childConstraints = childConstraints.tighten(width: childParentData.width);
// 跟上面邏輯一樣
if (childParentData.top != null && childParentData.bottom != null)
childConstraints = childConstraints.tighten(height: size.height - childParentData.bottom - childParentData.top);
else if (childParentData.height != null)
childConstraints = childConstraints.tighten(height: childParentData.height);
// 測量child
child.layout(childConstraints, parentUsesSize: true);
// 計算offset
double x;
if (childParentData.left != null) {
x = childParentData.left;
} else if (childParentData.right != null) {
x = size.width - childParentData.right - child.size.width;
} else {
x = _resolvedAlignment.alongOffset(size - child.size).dx;
}
if (x < 0.0 || x + child.size.width > size.width)
// 標記溢出,在paint的時候會用
_hasVisualOverflow = true;
double y;
if (childParentData.top != null) {
y = childParentData.top;
} else if (childParentData.bottom != null) {
y = size.height - childParentData.bottom - child.size.height;
} else {
y = _resolvedAlignment.alongOffset(size - child.size).dy;
}
if (y < 0.0 || y + child.size.height > size.height)
_hasVisualOverflow = true;
childParentData.offset = Offset(x, y);
}
assert(child.parentData == childParentData);
child = childParentData.nextSibling;
}
}
抽絲剝繭以后,不難理解,其實多個子對象和單個子對象本質上是一樣的,提供子對象約束讓它進行測量,然后根據測量結果決定自己的size,最后再計算子對象繪制的offset。就這樣~
最后再看看繪制方法:
@protected
void paintStack(PaintingContext context, Offset offset) {
// 其它情況則直接使用RenderBoxContainerDefaultsMixin提供的默認繪制方法
defaultPaint(context, offset);
}
@override
void paint(PaintingContext context, Offset offset) {
// 處理方式為clip時,溢出部分裁減掉,_hasVisualOverflow在上面計算offset時進行了標記
if (_overflow == Overflow.clip && _hasVisualOverflow) {
context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintStack);
} else {
paintStack(context, offset);
}
}
4、getXxxIntrinsicXxx和computeXxxIntrinsicXxx的作用、用法
細心的同學可能會發現,實現了performLayout的類中都重寫了一系列compute開頭的方法,另外也會有些地方調用了getMaxIntrinsicWidth等幾個"get系列"的方法。從名字上看,這幾個方法分別是用來計算和獲取最大最小寬高的,但按照我們前面的說法,直接在performLayout或performResize中通過constrains計算寬高也可以,那么這幾個方法有什么作用?跟我們前面的做法又有什么區別呢?別着急,接下來我們就來解開這些疑惑。
根據getMinIntrinsicWidth方法的注釋,可以得出幾個要點:
(1)getMinIntrinsicWidth用來獲取能夠完整繪制所有內容的最小寬度;
(2)這個方法是給父布局使用的,如果父布局調用了某個child的這個方法,當child調用markNeedsLayout時,父布局也會被通知刷新;
(3)這個方法的算法復雜的是O(N^2),所以非必要的情況不要用它;
(4)不要重寫這個方法,有需要的話重寫computeMinIntrinsicWidth。
結合這些說明,情況基本明確了。compute系列的方法是需要重寫,並計算返回相應的大小;而get系列的方法則是提供給父布局使用,讓父布局能夠在child測量前就知道child的size。這么實現的原因是規避android原生那種measure兩次的問題,詳情可以看看閑魚這篇文章:https://zhuanlan.zhihu.com/p/90195812
5、容器類控件的hitTest
相對於非容器類的控件,容器控件的命中測試需要額外考慮child的命中情況,結合上述內容,我們只需要實現hitTestChildren即可,不過需要注意一點,這個方法接收的postion需要是相對於當前控件的(即原點在當前控件左上角),在對child進行命中測試前,我們需要把position轉成原點在child左上角的相對坐標位置。 HitTestResult類提供的一些方法會幫助我們完成這個轉換。我們來看看RenderBoxContainerDefaultsMixin中的默認實現:
bool defaultHitTestChildren(BoxHitTestResult result, { Offset position }) {
ChildType child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData;
// addWithPaintOffset會將根據offset將position轉換成child的相對位置
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
// 這里的offset已經經過轉換
return child.hitTest(result, position: transformed);
},
);
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
舉個栗子,一個寬高為200的正方形容器中,裝有一個寬高為100的小正方形,小正方形位於容器右下角:
這個時候childParentData中offset是(100,100),假設點擊到正方形容器的左上角,那么容器的hitTestChildren方法拿到的position為(0,0),經過轉換后,小正方形的hitTest方法中拿到的postion就應該是(-100, -100)。