深入浅出 Flutter Framework 之自定义渲染型 Widget

语言: CN / TW / HK

本文是『 深入浅出 Flutter Framework 』系列文章的第八篇,也是收官之作。通过自定义渲染型 Widget,我们一步步地实现了一个评分组件。

本文同时发表于我的个人博客

本系列文章将深入 Flutter Framework 内部逐步去分析其核心概念和流程,主要包括: + 『 深入浅出 Flutter Framework 之 Widget 』 + 『 深入浅出 Flutter Framework 之 BuildOwner 』 + 『 深入浅出 Flutter Framework 之 Element 』 + 『 深入浅出 Flutter Framework 之 PaintingContext 』 + 『 深入浅出 Flutter Framework 之 Layer 』 + 『 深入浅出 Flutter Framework 之 PipelineOwner 』 + 『 深入浅出 Flutter Framework 之 RenderObejct 』 + 『 深入浅出 Flutter Framework 之自定义渲染型 Widget 』

Overview


本文作为『 深入浅出 Flutter Framework 』系列文章的收官之作,为了对本系列文章所涉重点内容的回顾和总结,动手实现一个渲染型 Widget (Render-Widget)。

如下图,最终成品是一个评分组件 (源码已上传 Github: Score):

Score.gif

通过前面系列文章的介绍,我们知道 Render-Widget 大致有三类: + 作为『 Widget Tree 』的叶节点,也是最小的 UI 表达单元,一般继承自LeafRenderObjectWidget; + 有一个子节点 ( Single Child ),一般继承自SingleChildRenderObjectWidget; + 有多个子节点 ( Multi Child ),一般继承自MultiChildRenderObjectWidget

Widget 间的继承关系如下图: Widget.png

Widget、Element、RenderObject 间的对应关系如下: Widget-Element-RenderObject.png

其中,Element 与 RenderObject 间用的是虚线,因为它们间的对应关系是基于 RenderBox 系列下的一种建议 (不是强制)。

Sliver 系列就不是基于RenderBox,而是RenderSliver

通过Render-Widget#createRenderObject方法可以返回任意 RenderObject (如果你愿意)。

对于RenderBox系列来说,如果要自定义子类,根据自定义子类子节点模型的不同需要有不同的处理: + 自定义子类本身是『 Render Tree 』的叶子节点,一般直接继承自RenderBox; + 有一个子节点 (Single Child),且子节点属于RenderBox系列: + 如果其自身的 size 完全 match 子节点的 size,则可以选择继承自RenderProxyBox(如:RenderOffstage); + 如果其自身的 size 大于子节点的 size,则可以选择继承自RenderShiftedBox(如:RenderPadding); + 有一个子节点 (Single Child),但子节点不属于RenderBox系列,自定义子类可以 mixin RenderObjectWithChildMixin,其提供了管理一个子节点的模型; + 有多个子节点 (Multi Child),自定义子类可以 mixin ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin,前者提供了管理多个子节点的模型,后者提供了基于ContainerRenderObjectMixin的一些默认实现。

下面,我们一步步地来实现上面提到的评分组件。

Custom Leaf Render Widget


首先,我们来实现评分组件里的五星部分 (ScoreStar Widget):

LeafRenderObjectWidget

ScoreStar作为叶子节点,继承自LeafRenderObjectWidget,并实现了2个重要方法:createRenderObjectupdateRenderObjectDart 1 class ScoreStar extends LeafRenderObjectWidget { 2 final Color backgroundColor; 3 final Color foregroundColor; 4 final double score; 5 6 ScoreStar(this.backgroundColor, this.foregroundColor, this.score); 7 8 @override 9 RenderObject createRenderObject(BuildContext context) { 10 return RenderScoreStar(backgroundColor, foregroundColor, score); 11 } 12 13 @override 14 void updateRenderObject(BuildContext context, covariant RenderScoreStar renderObject) { 15 renderObject 16 ..backgroundColor = backgroundColor 17 ..foregroundColor = foregroundColor 18 ..score = score; 19 } 20 } 其中,updateRenderObject方法会在 Widget re-build 时调用,用于更新复用的 Render Object 的属性。

在本例中,score会随着用户点击不同的区域而变化,就需要通过updateRenderObject方法来更新RenderScoreStar#score,以便刷新 UI。

Leaf Render Object

从上一小节ScoreStar#createRenderObject可知,ScoreStar 对应的 Render Object 是RenderScoreStar

RenderScoreStar继承自RenderBox

如下代码: + 在 socre setter 中调用了markNeedsPaint方法,以便在score变化后及时 re-paint (由于 socre 变化不会引起 layout 变化,故此处只需调用markNeedsPaint,若会引起 layout 变化,则需要调用markNeedsLayout); + 关于sizedByParent,在该例子中设为true or false都可以,因为RenderScoreStar#size完全由constraints决定: + 从性能角度考虑,sizedByParent应设为true,以便满足RepaintBoundary的条件 (详情请参见[ 深入浅出 Flutter Framework 之 RenderObject ] ); + 若sizedByParent设为true,需要重写performResize方法来计算 size,由于RenderScoreStar没有 layout 操作需要执行,故不需要重写performLayout; + 若sizedByParent设为false,则需要重写performLayout,并在该方法中完成 size 的计算; + 22~3032~40两个代码片段随便使用哪个都可以。 + 关于IntrinsicWidth/Height,若重写了performLayout方法,则进而需要重写以下四个方法: + double computeMaxIntrinsicWidth(double height):用于计算一个最小宽度(没错,是最小宽度),在最终 size.width 超过该宽度时,也不会减少 size.height (如,对 text 排版,将 text 排成一行需要的最小宽度就是这里的 MaxIntrinsicWidth,因为再增加宽度也不会减少 text 的高度); + double computeMinIntrinsicWidth(double height):排版需要的最小宽度,若小于这个宽度内容就会被裁剪; + computeMinIntrinsicHeightcomputeMaxIntrinsicHeight与上面介绍的computeMinIntrinsicWidthcomputeMaxIntrinsicWidth类似,不再赘述; + 在一些特殊 RenderObject 排版时才会用到这些方法,在此我们根据 constraints 简单计算了一下。 + 为了响应点击事件,重写hitTestSelf方法,并返回true,表示该 Render Object 需要响应用户事件; + 关于paint方法中五角星 ★★★★★ 的绘制: + 对于背景 ★★★★★,设置好 path 后,直接通过context.canvas.drawPath绘制即可; + 对于前景 ★★★★★,先通过context.pushClipRect对画布进行裁剪 ( rect.width 由 score 决定 ),再行绘制。

Dart 1 // 为了缩减篇幅,精简了部分代码 2 // 3 class RenderScoreStar extends RenderBox { 4 Color _backgroundColor; 5 ... 6 7 Color _foregroundColor; 8 ... 9 10 double _score; 11 double get score => _score; 12 set score(double value) { 13 _score = value; 14 15 // score 变化时需要re-paint 16 // 17 markNeedsPaint(); 18 } 19 20 RenderScoreStar(this._backgroundColor, this._foregroundColor, this._score); 21 22 @override 23 bool get sizedByParent => false; 24 25 @override 26 void performLayout() { 27 double height = min(constraints.biggest.height, constraints.biggest.width / 5); 28 height = max(height, constraints.smallest.height); 29 size = Size(constraints.biggest.width, height); 30 } 31 32 // @override 33 // bool get sizedByParent => true; 34 // 35 // @override 36 // void performResize() { 37 // double height = min(constraints.biggest.height, constraints.biggest.width / 5); 38 // height = max(height, constraints.smallest.height); 39 // size = Size(constraints.biggest.width, height); 40 // } 41 42 @override 43 double computeMaxIntrinsicWidth(double height) { 44 return constraints.biggest.width; 45 } 46 47 @override 48 double computeMaxIntrinsicHeight(double width) { 49 double height = min(constraints.biggest.height, constraints.biggest.width / 5); 50 height = max(height, constraints.smallest.height); 51 52 return height; 53 } 54 55 @override 56 bool hitTestSelf(Offset position) { 57 return true; 58 } 59 60 @override 61 void paint(PaintingContext context, Offset offset) { 62 void _backgroundStarPainter(PaintingContext context, Offset offset) { 63 _starPainter(context, offset, backgroundColor); 64 } 65 66 void _foregroundStarPainter(PaintingContext context, Offset offset) { 67 _starPainter(context, offset, foregroundColor); 68 } 69 70 _backgroundStarPainter(context, offset); 71 context.pushClipRect( 72 needsCompositing, 73 offset, 74 Rect.fromLTRB(0, 0, size.width * score / 5, size.height), 75 _foregroundStarPainter 76 ); 77 } 78 79 void _starPainter(PaintingContext context, Offset offset, Color color) { 80 Paint paint = Paint(); 81 paint.color = color; 82 paint.style = PaintingStyle.fill; 83 84 double radius = min(size.height / 2, size.width/ (2 * 5)); 85 86 Path path = Path(); 87 _addStarLine(radius, path); 88 for (int i = 0; i < 4; i++) { 89 path = path.shift(Offset(radius * 2, 0.0)); 90 _addStarLine(radius, path); 91 } 92 93 path = path.shift(offset); 94 path.close(); 95 96 context.canvas.drawPath(path, paint); 97 } 98 99 void _addStarLine(double radius, Path path) { 100 ... 101 } 102 } 至此,RenderScoreStar基本完成,完整代码请参见 [ Github:Score ]

动态评分


如下图,我们希望评分组件不仅能展示分数,还能评分:

Score_small.gif

在 Flutter UI 中,一个重要的思想就是:『 组合 』。

为了实现上图所示效果,只需组合StatefulWidget +ScoreStar即可: Dart 1 typedef ScoreCallback = void Function(double score); 2 3 class Score extends StatefulWidget { 4 final double score; 5 final ScoreCallback callback; 6 7 const Score({Key key, this.score = 0, this.callback}) : super(key: key); 8 9 @override 10 _ScoreState createState() => _ScoreState(); 11 } 12 13 class _ScoreState extends State<Score> { 14 15 double score; 16 17 @override 18 void initState() { 19 super.initState(); 20 21 score = widget.score ?? 0; 22 } 23 24 @override 25 void didUpdateWidget(Score oldWidget) { 26 super.didUpdateWidget(oldWidget); 27 28 score = widget.score ?? 0; 29 } 30 31 @override 32 Widget build(BuildContext context) { 33 void _changeScore(Offset offset) { 34 Size _size = context.size; 35 double offsetX = min(offset.dx, _size.width); 36 offsetX = max(0, offsetX); 37 38 setState(() { 39 score = double.parse(((offsetX / _size.width) * 5).toStringAsFixed(1)); 40 }); 41 42 if (widget.callback != null) { 43 widget.callback(score); 44 } 45 } 46 47 return GestureDetector( 48 child: ScoreStar(Colors.grey, Colors.amber, score), 49 onTapDown: (TapDownDetails details) { 50 _changeScore(details.localPosition); 51 }, 52 onLongPressMoveUpdate:(LongPressMoveUpdateDetails details) { 53 _changeScore(details.localPosition); 54 }, 55 ); 56 } 57 } 代码比较简单,就不赘述了。

其中的关键还是上节介绍的RenderScoreStar#hitTestSelf需要返回true

Custom MultiChild RenderObject Widget


Score.gif

我们希望通过自定义 MultiChild RenderObject Widget 实现如上图所示的效果。

没错,就是加了一个显示分数的 Text。

本来,这完全没必要通过自定义 MultiChild RenderObject Widget 来实现,一般的 Widget 组合即可。

我们只是为了实践自定义 MultiChild RenderObject Widget 才这么做的。

MultiChildRenderObjectWidget

RichScore继承自MultiChildRenderObjectWidget

在其初始化方法中,向父类传递了2个 children:ScoreText

重写了createRenderObject方法,以便返回RenderRichScore实例。 由于RenderRichScore没有属性,故无需重写updateRenderObject方法。

Dart 1 class RichScore extends MultiChildRenderObjectWidget { 2 RichScore({ 3 Key key, 4 double score, 5 ScoreCallback callback, 6 }) : super( 7 key: key, 8 children: [ 9 Score(score: score, callback: callback), 10 Text('$score分', style: TextStyle(fontSize: 28)), 11 ] 12 ); 13 14 @override 15 RenderObject createRenderObject(BuildContext context) { 16 return RenderRichScore(); 17 } 18 }

RichScoreParentData

还记得 ParentData 吗?

『 深入浅出 Flutter Framework 之 Widget 』中有过简单介绍。

对于含有子节点的 RenderObject,一般都需要自定义自己的 ParentData 子类,用于辅助 layout。 Dart class RichScoreParentData extends ContainerBoxParentData<RenderBox> { double scoreTextWidth; } RichScoreParentData继承自ContainerBoxParentDataDart /// Abstract ParentData subclass for RenderBox subclasses that want the /// ContainerRenderObjectMixin. /// /// This is a convenience class that mixes in the relevant classes with /// the relevant type arguments. abstract class ContainerBoxParentData<ChildType extends RenderObject> extends BoxParentData with ContainerParentDataMixin<ChildType> { } ContainerBoxParentData是抽象类,但其 mixinContainerParentDataMixinDart /// Parent data to support a doubly-linked list of children. mixin ContainerParentDataMixin<ChildType extends RenderObject> on ParentData { /// The previous sibling in the parent's child list. ChildType previousSibling; /// The next sibling in the parent's child list. ChildType nextSibling; } ContainerParentDataMixin在子节点间提供了双向链接的支持。

RichScoreParentData中定义了唯一一个属性:scoreTextWidth,其作用在后面再介绍。

MultiChild RenderObject

RenderRichScore继承自RenderBox并 minix 了ContainerRenderObjectMixin以及RenderBoxContainerDefaultsMixin: + 由于RenderRichScore#size受子节点的影响,即不完全由 Constraints 决定,故sizedByParent设为false,同时在调用子节点的layout方法时parentUsesSize参数需设为true (下面代码第4055行); + 由于其子节点 (RenderScoreStar)需要响应用户事件,故重写了hitTestChildren方法; + 在performLayout方法中,完成了所有子节点的排版、设置相应的 ParentData 并计算出了 size; + 对于有子节点的 RenderObject 需要重写computeDistanceToActualBaseline方法,这里我们用了RenderBoxContainerDefaultsMixin提供的默认实现; + paint方法的功能很简单,依次绘制每个子节点(defaultPaintRenderBoxContainerDefaultsMixin提供); + setupParentData用于给子节点设置parentData

Dart 1 class RenderRichScore extends RenderBox with ContainerRenderObjectMixin<RenderBox, RichScoreParentData>, 2 RenderBoxContainerDefaultsMixin<RenderBox, RichScoreParentData>, 3 DebugOverflowIndicatorMixin { 4 5 RenderRichScore({ 6 List<RenderBox> children, 7 }) { 8 addAll(children); 9 } 10 11 @override 12 bool get sizedByParent => false; 13 14 final double horizontalSpace = 10; 15 final double scoreTextWidthDifference = 10; 16 17 @override 18 bool hitTestChildren(BoxHitTestResult result, { Offset position }) { 19 assert(childCount == 2); 20 21 RenderBox scoreChild = firstChild; 22 return scoreChild?.hitTest(result, position: position) ?? false; 23 } 24 25 @override 26 void performLayout() { 27 assert(childCount == 2); 28 29 RenderBox scoreStarChild = firstChild; 30 RenderBox scoreTextChild = lastChild; 31 32 if (scoreStarChild == null || scoreTextChild == null) { 33 size = constraints.smallest; 34 return; 35 } 36 37 // infinity constraints 38 // 39 BoxConstraints descConstraints = BoxConstraints(); 40 scoreTextChild.layout(descConstraints, parentUsesSize: true); 41 42 final RichScoreParentData descChildParentData = scoreTextChild.parentData as RichScoreParentData; 43 double descWidth = descChildParentData.scoreTextWidth; 44 if (descWidth == null) { 45 descWidth = scoreTextChild.size.width + scoreTextWidthDifference; 46 descChildParentData.scoreTextWidth = descWidth; 47 } 48 49 BoxConstraints scoreConstraints = BoxConstraints( 50 minWidth: 0, 51 maxWidth: max(constraints.maxWidth - descWidth - horizontalSpace, 0), 52 minHeight: 0, 53 maxHeight: constraints.maxHeight 54 ); 55 scoreStarChild.layout(scoreConstraints, parentUsesSize: true); 56 57 descChildParentData.offset = Offset( 58 scoreStarChild.size.width + horizontalSpace, 59 (scoreStarChild.size.height - scoreTextChild.size.height) / 2 60 ); 61 62 if (constraints.isTight) { 63 size = constraints.biggest; 64 } 65 else { 66 double width = min(constraints.biggest.width, scoreStarChild.size.width + descWidth + horizontalSpace); 67 width = max(constraints.smallest.width, width); 68 69 double height = max(scoreStarChild.size.height, scoreTextChild.size.height); 70 height = min(constraints.biggest.height, height); 71 height = max(constraints.smallest.height, height); 72 73 size = Size(width, height); 74 } 75 } 76 77 ... 78 79 @override 80 double computeDistanceToActualBaseline(TextBaseline baseline) { 81 return defaultComputeDistanceToFirstActualBaseline(baseline); 82 } 83 84 @override 85 void paint(PaintingContext context, Offset offset) { 86 assert(childCount == 2); 87 88 if (childCount != 2) { 89 return; 90 } 91 92 defaultPaint(context, offset); 93 } 94 95 @override 96 void setupParentData(RenderObject child) { 97 if (child.parentData is! RichScoreParentData) { 98 child.parentData = RichScoreParentData(); 99 } 100 } 101 }

RichScoreParentData#scoreTextWidth

上面我们提到RichScoreParentData有唯一一个属性:scoreTextWidth。 那么它的作用是啥呢?

根据RenderRichScore的排版算法,先计算 text 的宽度,★★★★★ 的宽度等于 constraints.biggest.width - textWidth。

这个算法有点小问题:

bad_score.gif

由于 textWidth 会因分数的不同,而有细微的差异,最终导致 ★★★★★ 有点闪烁。

为了解决这个问题,我们将 textWidth 的宽度固定为首次计算的 text 宽度+10,并将其存储在RichScoreParentData中(上述代码第42~47行)。

这种解决方法不一定是最好的,这里主要是演示一下 ParentData 的作用。

至此,自定义 MultiChild RenderObject 基本完成了。

小结


本文通过实现评分组件,逐步实践了如何自定义 Leaf Render Widget 以及 MultiChild Render Widget。

在这过程中,自定义了 Widget 以及 Render Object,但并没有涉及 Element。

原因是 Element 作为 Widget 与 Render Object 间的桥梁,逻辑相对内聚、独立。 当自定义 Widget 继承自LeafRenderObjectWidgetSingleChildRenderObjectWidgetMultiChildRenderObjectWidget时,一般不用自定义 Element。

自定义 Leaf Render Widget,一般需要以下步骤: + 自定义 Widget 继承自LeafRenderObjectWidget,并重写createRenderObjectupdateRenderObject方法; + 自定义 Render Object 继承自RenderBox: + 确定sizedByParenttrue or false; + 为false,重写performLayout方法,执行 layout 并计算 size; + 为true,重写performResize方法计算 size、重写performLayout方法执行 layout (若需要); + 如果重写了performLayout方法,则需进一步重写computeMax/MinIntrinsicWidth/Height系列方法; + 如需处理用户事件,重写hitTestSelf方法; + 重写paint方法,完成最终的绘制。

自定义 MultiChild Render Widget,一般需要以下步骤: + 自定义 Widget 继承自MultiChildRenderObjectWidget,并重写createRenderObjectupdateRenderObject方法; + 自定义 Render Object 继承自RenderBox,并 minix ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin: + 确定sizedByParenttrue or false; + 为false,重写performLayout方法,对子节点逐个执行 layout 操作并计算 size; + 为true,重写performResize方法计算 size、重写performLayout方法执行 layout; + 如果重写了performLayout方法,则需进一步重写computeMax/MinIntrinsicWidth/Height系列方法; + 重写computeDistanceToActualBaseline方法计算 baseline; + 如需处理用户事件,重写hitTestSelf或/和hitTestChildren方法; + 自定义 ContainerBoxParentData 子类,用于存储 layout 过程中需要的辅助信息; + 重写setupParentData方法,为子节点设置 ParentData; + 重写paint方法,对子节点逐个执行 paint 操作。

『 深入浅出 Flutter Framework 』系列文章至此全部完成!

这一系列文章围绕 Widget、Element 以及 RenderObject 展开讨论,对 Flutter Framework 有了一个简单的认识。

在此过程中对相关的 BuildOwner、PaintingContext、Layer 以及 PipelineOwner 等也进行了一定的讨论。