Flutter源码分析系列(三):自定义控件(RenderBox)指南

Flutter 源码分析系列(三):自定义控件(RenderBox)指南

Flutter 本身提供了大量Widget以供开发,但是难免有通过组合完成不了的效果,此时就需要我们自己来实现 RenderObject 了,本文会介绍一下实现一个 RenderObject 的基本步骤,帮助大家快速熟悉开发自定义控件的流程,当然这对于读懂原生 Widget 的实现源码也有很大的益处。

RenderObject 类继承层级解析

首先,介绍一下 RenderObject 子类的继承关系,通过 Android Studio 的 Hierarchy 功能可以直观地对类继承关系进行查看:

RenderObject 类继承关系

看过源码分析系列相关文章中对 runApp() 方法的解析后应该知道,RenderView 对应的是 RenderObject 树的根节点,打开该类的注释,发现有这样一句话:

The view has a unique child [RenderBox], which is required to fill the entire output surface.

意为 RenderView 根节点下只有唯一一个 RenderBox 作为叶节点,它的大小会充满整个绘制表面,由此可以看出,RenderBox 就是绘制上使用的基类了。继续观察一下 RenderObject 的子类继承树,发现有 3 个 Mixin 以及 RenderAbstractViewport 和 RenderSliver 没有继承自 RenderBox,这些类都是干什么用的呢?这里简单介绍下:

RenderAbstractViewport 和 RenderSliver 主要处理滑动相关的控件展示,如 ListView 和 ScrollView。滑动相关的内容就不在本文中讲了,大家可以期待后续的文章。DebugOverflowIndicatorMixin 用于在 debug 下提示绘制是否溢出,该类仅用于 debug,自定义控件时一般用不到。

剩下的两个 mixin 还是比较关键的:
RenderObjectWithChildMixin 用于为只有 1 个 child 的 RenderObject 提供 child 管理模型。
ContainerRenderObjectMixin 用于为有多个 child 的 RenderObject 提供 child 管理模型。
这两个 mixin 是非常常用的,看一下 Hierarchy 可以发现基本上每个 RenderBox 都混入了他们,省去了自己管理 child 的代码。

除此之外还有一个类也有相当多的子类:RenderProxyBox,接下来就分别详细介绍一下继承 RenderBox 和 RenderProxyBox 实现自定义控件的正确姿势。

RenderBox

一个看源码的好习惯就是看到一个新类先看注释,第一句话如下:

A render object in a 2D Cartesian coordinate system.

这句话可以解释 Box 的含义了,实际上就是表示使用了 2D 笛卡尔坐标系来标识位置,这与原生开发是一致的,坐标系原点位于左上,x 轴正向指向屏幕右侧,y 轴正向指向屏幕下侧。

叶节点与父节点

在安卓中,有 View 和 ViewGroup 的区分,前者不能有子 View,即为叶节点,后者可以有多个子 View,即父节点,那么 Flutter 中呢?答案是都是 RenderBox,child 的逻辑区别以 mixin 来解决,如果想拥有 child,混入上一节所讲的 RenderObjectWithChildMixin 或 ContainerRenderObjectMixin 就可以了。

控件的测量与布局

在 RenderBox 中,控件大小的值为 _size 成员,它只包含宽高两个属性值,我们可以通过该成员的 set 和 get 方法访问或修改它的值。在测量时,parent 会传给当前 RenderBox 一个大小的限制,为 BoxConstraints 类型,通过 constraints 这个 get 方法可以获取到,最后测量得到的 size 必须满足这个限制,在 Flutter 的 debug 模式下对 size 是否满足 constraints 做了 assert 检查,如果检查未通过就会布局失败。所以测量上我们要做的是下面两点:

  1. 如果没有 child,那么根据自身的属性计算出满足 constraints 的 size.
  2. 如果有 child,那么综合自身的属性和 child 的测量结果计算出满足 constraints 的 size.

performResize 和 performLayout

通过查看 size 的注释,发现测量的时机在 performResize()performLayout() 方法中,问题来了,为什么有两个测量的方法呢?分析下 RenderObject 类中调用它们的 layout 方法源码:

if (sizedByParent) {
  try {
    performResize();
  } catch (e, stack) {}
}
try {
  performLayout();
} catch (e, stack) {}

可以看出只有 sizedByParent 为 true 时,performResize() 才会被调用,而 performLayout() 是每次布局都会被调用的。sizedByParent 意为该控件的大小是否能仅通过 parent 赋予它的 constraints 就可以被确定下来了,即该控件的大小与它自身的属性和与它的 child 都无关,比如如果一个控件永远充满 parent 的大小,那么 sizedByParent 就应该返回 true。

这里还有另外一个限制,如果 sizedByParent 为 true,大小应在 performResize() 中就确认,并且不能在 performLayout() 方法中再修改了,此时 performLayout() 只负责布局 child。

回到 sizedByParent,为什么有这样一个属性呢?注释中发现是为了优化性能,这里分析一下 RenderObject 中用到它的代码:

if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
  relayoutBoundary = this;
} else {
  final RenderObject parent = this.parent;
  relayoutBoundary = parent._relayoutBoundary;
}

可以看到如果 sizedByParent 为 true,relayoutBoundary 就设置为了自己,否则继续向 parent 查找。除了 sizedByParent 以外,还有其他几个判断项,分别是 !parentUsesSize(parent 的测量不依赖该 RenderObject 的大小)、constraints.isTight(parent 赋予的限制是个定值)、parent is! RenderObject(满足该条件的只能是根节点 RenderView 了)。

relayoutBoundary

这里引出了另外一个问题,什么是 relayoutBoundary?

首先来讲一下如何触发布局的测量,之前有源码分析系列有提到过,在每一帧的绘制 drawFrame 方法中,会对标记为 dirty 的 RenderObject 进行重新布局,我们可以通过调用 markNeedsLayout() 方法将 RenderObject 的布局状态标记为 dirty。分析一下该方法的源码:

void markNeedsLayout() {
  if (_needsLayout) {
    return;
  }
  if (_relayoutBoundary != this) {
    markParentNeedsLayout();
  } else {
    _needsLayout = true;
    if (owner != null) {
      owner._nodesNeedingLayout.add(this);
      owner.requestVisualUpdate();
    }
  }
}

如果自身不是 relayoutBoundary,就继续向 parent 查找,一直向上查找到是 relayoutBoundary 的 RenderObject,再将这个 RenderObject 标记为 dirty 的。这样来看它的作用就比较明显了,意思就是当一个控件的大小被改变时可能会影响到它的 parent,因此 parent 也需要被重新布局,那么到什么时候是个头呢?答案就是 relayoutBoundary,如果一个 RenderObject 是 relayoutBoundary,就表示它的大小变化不会再影响到 parent 的大小了,于是 parent 也就不用重新布局了。知道这点后可以再重新考虑一下之前设置 relayoutBoundary 的四个判断条件,这么判断的原因应该很明确了,这里就不具体讲了。

叶节点

叶节点的测量和布局比较简单,首先根据需求确认 sizedByParent的值,然后通过自身属性和 constraints 计算出大小后调用 size 的 set 方法直接赋值给 size 就好了。由于是叶节点,是不用处理如何布局的问题的,只要知道自身的大小就足够了。

父节点

父节点的流程就相对复杂一些,因为除了测量外还要对子节点进行布局,步骤如下:

  1. 根据 child 的个数选择 RenderObjectWithChildMixin 或 ContainerRenderObjectMixin.
  2. 确认 sizedByParent 的值,如果 sizedByParent 为 true,直接在 performResize() 方法中确认自己的大小.
  3. performLayout() 方法中对 child 进行布局.

重点在于第三个步骤,下面进行详细介绍。

首先要说明的是,与安卓的 onMeasure()onLayout() 不同的是,Flutter 中测量和布局的过程都在 performLayout() 这一个方法中完成。

ParentData

首先要介绍的是一个名为 ParentData 的类,在 Flutter 的布局系统中,该类负责存储父节点所需要的子节点的布局信息,当然该信息偶尔也会用于子节点的布局。
每个 RenderObject 类中都有 parentData 这样一个成员,该成员只能通过 setupParentData 方法赋值,RenderObject 的子类可以通过重写该方法将 ParentData 的子类赋值给 parentdata,以扩展 ParentData 的功能:

void setupParentData(covariant RenderObject child) {
  if (child.parentData is! ParentData)
    child.parentData = ParentData();
}

接下来看一下该类的 Hierarchy 结构:

ParentData 类继承结构

先无视用于滑动的 Sliver 相关的类和用于表格布局的 TabelCellParentData,我们来分析一下剩余的 ParentData类的作用。

ParentData

class ParentData {
  /// Called when the RenderObject is removed from the tree.
  @protected
  @mustCallSuper
  void detach() { }

  @override
  String toString() => '<none>';
}

这是所有 ParentData 的基类,没有存储任何信息也没有实现功能,只定义了一个空实现的 detach() 方法,该方法会在 RenderObject 被移出 tree 的时候调用,这给子类提供了一个在 RenderObject 移出时更新信息的时机。

BoxParentData

/// Parent data used by [RenderBox] and its subclasses.
class BoxParentData extends ParentData {
  /// The offset at which to paint the child in the parent's coordinate system.
  Offset offset = Offset.zero;

  @override
  String toString() => 'offset=$offset';
}

该类注释写的很明确,用于 RenderBox 和它的子类,只有一个 offset 属性,该属性用于存储 child 的布局信息,也就是 child 应该被摆在哪个位置,通常在 child 大小确定后,parent 负责根据自身逻辑将 child 的位置赋值到这里。

ContainerBoxParentData

查看源码后发现该类是个空类,只是为了方便子类混入 ContainerParentDataMixin。

ContainerParentDataMixin

该类使用频率很高,基本上所有父节点的 ParentData 都混入了该类,该类需要与ContainerRenderObjectMixin 共同使用,主要解决了对 child 的管理,它用双链表存储了所有子节点并提供了方便的接口去获取他们。对于开发者,一般来说只用到 ContainerRenderObjectMixin 中的 firstChildlastChildchildCount,用来获取首末 child,child的个数,配合使用 ContainerParentDataMixin 中的 previousSiblingnextSibling就可以对 child 进行遍历了。

这些 ParentData 的基类解决了 child 的布局位置信息的存储和 child 的管理以及引用的获取,再往下的子类就是与各布局的功能相关的类了,如 FlexParentData,存储了 flex 和 fit 的值,分别表示该 child 的 flex 比重和 布局的 fit 策略。

测量 child 大小

测量一个 child 需要调用 RenderObject 中的 void layout(Constraints constraints, { bool parentUsesSize = false }),需要传入两个参数,constraints 即为父节点对子节点大小的限制,该值根据父节点的布局逻辑确定。调用完这个方法后,就可以通过 child.size 拿到 child 测量后的大小了。另外一个参数是 parentUsesSize,该值用于确定 relayoutBoundary,意为 child 的布局变化是否影响 parent,根据实际情况传入该值即可,默认为 false。

布局 child

布局 child 即计算出 child 相对 parent 展示的位置,将该位置赋值给 childParentData 的 offset 中就可以了,该 offset 会在后面的绘制过程中用到。

控件的绘制

绘制方法在 void paint(PaintingContext context, Offset offset) { } 中实现,RenderBox 需要在该方法中实现对自身的绘制以及所有 child 的绘制。

绘制自身内容

通过 context.canvas 获取到 Canvas 对象,之后就可以开始绘制了,需要注意每次绘制都要带上 offset 的偏移量,否则绘制的位置会与布局阶段的预期不同。

绘制 child

对于 child 可以遍历所有 child 并调用 context.paintChild(child, childParentData.offset + offset)方法完成 child 的绘制。除了这种方法以外,Flutter 还提供了 RenderBoxContainerDefaultsMixin,该类提供了一些 RenderBox 默认的行为方法,如上面绘制 child 的流程调用该类中的 defaultPaint(PaintingContext context, Offset offset) 就可以了,可以简化一些模板代码。

repaintBoundary

与 relayoutBoundary 相对应,对于绘制,也有一个 isRepaintBoundary 属性,与 relayoutBoundary 不同的是,这个属性需要由我们自己设置,默认为 false。注释中的第一句话表示了该属性的含义:

Whether this render object repaints separately from its parent.

即该 RenderObject 的绘制是否与它的 parent 相独立,如何做到独立呢?看下 paintChild 方法的源码:

void paintChild(RenderObject child, Offset offset) {
  if (child.isRepaintBoundary) {
    stopRecordingIfNeeded();
    _compositeChild(child, offset);
  } else {
    child._paintWithContext(this, offset);
  }
}

void _compositeChild(RenderObject child, Offset offset) {
  // Create a layer for our child, and paint the child into it.
  if (child._needsPaint) {
    repaintCompositedChild(child, debugAlsoPaintedParent: true);
  } else {
    // 省略assert逻辑
  }
  child._layer.offset = offset;
  appendLayer(child._layer);
}

可以看出在绘制 child 时,如果 isRepaintBoundary 为 true,那么会为该 child 新创建一个 layer,只有在不同 layer 的 RenderObject 才可以各自独立进行绘制。该属性很明显是为了提高渲染效率而存在的,它能够实现区域重绘功能,具体原理如下:

类似触发布局的方法,为了触发绘制,需要调用 markNeedsPaint(),分析下该方法的源码:

void markNeedsPaint() {
  if (_needsPaint)
    return;
  _needsPaint = true;
  if (isRepaintBoundary) {
    if (owner != null) {
      owner._nodesNeedingPaint.add(this);
      owner.requestVisualUpdate();
    }
  } else if (parent is RenderObject) {
    final RenderObject parent = this.parent;
    parent.markNeedsPaint();
  } else {
    if (owner != null)
      owner.requestVisualUpdate();
  }
}

可以看出当调用 markNeedsPaint() 方法时,会从当前 RenderObject 开始一直向父节点查找,直到 isRepaintBoundary 为 true 时,才标记当前 RenderObject 为需要绘制的,也由此实现了区域重绘。当 RenderObject 绘制的很频繁时,可以指定该值为 true,这样在每帧绘制时可以缩小重绘范围,仅重绘自身而不用重绘它的 parent,以此来提高性能。

对绘制区域的限制

控件的点击事件处理

根据上述流程完成布局与绘制后,我们理所应当的可能利用 GestureDetector 监听了一些手势,但是运行起来后发现手势完全没有生效,这是因为我们漏掉了关于点击事件处理相关方法的实现。在 RenderBox 中有三个方法与点击事件相关:

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;
}

@protected
bool hitTestSelf(Offset position) => false;

@protected
bool hitTestChildren(HitTestResult result, { Offset position }) => false;

hitTest 方法用来判断该 RenderObject 是否在被点击的范围内,同时负责将被点击的 RenderObject 添加到 HitTestResult 列表中,参数 position 为点击坐标,返回 true 则表示有 RenderObject 被点击了,反之没有。在默认实现中,简单的判断了 position 是否在 size 范围内,如果在自身范围内的话,继续判断是否有 child 在点击范围内,若没有 child 被点击,再判断自己是否被点击了。一般在子类中实现 hitTestSelfhitTestChildren 即可。在 RenderBoxContainerDefaultsMixin 中有 hitTestChildren 的默认实现,即根据 child 的 hitTest 方法来判断是否被点击,如果没有特殊逻辑,直接使用该方法即可。

RenderProxyBox

除了 RenderBox 之外,还有一个类比较常用,那就是 RenderProxyBox,该类将布局绘制点击事件等方法的处理全部交由 child 来实现,可以理解为 child 的代理,具体代理了哪些方法可以参见 RenderProxyBoxMixin 的源码。

通常对一个已有的 RenderObject 做一些附加处理时会用到该类,如常见的 Opacity、DecoratedBox 等控件就是用该类实现的,它的各属性和 child 完全一致,因此我们专心处理对 child 的额外效果就可以了,避免了逻辑的拷贝。

RenderBox 子类的常规写法

回顾一下之前所讲的内容,本节总结一下 RenderBox 子类的常规写法。

命名

RenderBox子类的名称一般以Render开头。

mixin

根据 child 的数量选择混入 RenderObjectWithChildMixin 或 ContainerRenderObjectMixin,前者对应一个 child,后者对应多个 child。

成员变量

RenderObject 的成员一般声明为 private,配以 set 和 get 方法,get 方法直接返回该成员即可,用来在类中获取该属性,set 方法一般先判断值是否与原值相同,若不同的话根据需要调用 markNeedsLayoutmarkNeedsPaint

示例:

Axis get direction => _direction;
Axis _direction;
set direction(Axis value) {
  if (_direction != value) {
    _direction = value;
    markNeedsLayout();
  }
}

布局、绘制、点击事件

确定 sizedByParent 的值,若该值为 true,则还需要实现 performResize(),然后在该方法中计算出 size,后续 performLayout() 的过程中不能再对 size 进行改动。
对 child 的布局在 performLayout() 中实现,布局后将 child 的 offset 放入 ParentData 中,注意调用 paintChild 时传入正确的 parentUsesSize 属性以优化性能。如果需要扩展 ParentData,那么重写 setupParentData 方法,ParentData 一般选择继承 ContainerBoxParentData。

在 paint 方法中实现自身与 child 的绘制,如果自身会频繁绘制,记得重写 isRepaintBoundary 的值为 true。

根据需要实现hitTestSelfhitTestChildren

绘制 child 和处理 child 点击事件的默认逻辑在 RenderBoxContainerDefaultsMixin 中。

对应 Widget 的常规写法

RenderObject 最终也需要对应到 Widget,除了熟知的 StatelessWidget 和 StatefulWidget 以外,直接对应到 RenderObject 的是 RenderObjectWidget,它有三个实现类:

  1. SingleChildRenderObjectWidget,对应有一个 child 的 RenderObject.
  2. MultiChildRenderObjectWidget,对应有多个 child 的 RenderObject.
  3. LeafRenderObjectWidget 对应叶节点的 RenderObject.

继承所需的类后,需要实现 createRenderObject 和 updateRenderObject 两个方法,前者用于创建新的 Object 实例,后者用于更新 RenderObject 的属性,示例如下:

/// 连续点赞Widget,对应连续点赞一帧的信息描述
class _RawMultiLike extends SingleChildRenderObjectWidget {

  final List<List<_SplashImage>> splashImages;
  final _DescriptionInfo descriptionInfo;
  final Size screenSize;

  const _RawMultiLike({
    Widget child,
    this.splashImages,
    this.descriptionInfo,
    this.screenSize,
  }): super(child: child);

  @override
  _RenderMultiLike createRenderObject(BuildContext context) {
    return _RenderMultiLike(
      splashImageInfos: splashImages,
      descriptionInfo: descriptionInfo,
      screenSize: screenSize,
      configuration: createLocalImageConfiguration(context),
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderMultiLike renderObject) {
    renderObject
      ..splashImageInfos = splashImages
      ..descriptionInfo = descriptionInfo
      ..screenSize = screenSize
      ..configuration = createLocalImageConfiguration(context);
  }

}

Element 层在 Widget 基类已经处理了,一般不用我们关心了。

一些自定义控件相关的 Widget

Flutter 原生提供了一些方便自定义功能的 Widget,如果可以满足需求的话,直接使用这些 Widget 是最方便的,下面列举一下:

自定义画布:CustomPaint
自定义单 child 布局:CustomSingleChildLayout
自定义多 child 布局:CustomMultiChildLayout
动态指定 RepaintBoundary:RepaintBoundary

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345