likes
comments
collection
share

Flutter 必知必会系列 —— 无名英雄 _Theatre 舞台剧

作者站长头像
站长
· 阅读数 11

往期精彩

前面的文章我们介绍了 Overlay 的基本原理,Overlay 仅仅是将需要维持的 OverlayEntry 转换成了 _OverlayEntryWidget ,真正负责显示的组件是 _Theatre。 相当于 Overlay_Theatre 组件提了需求:我给你一组子节点和一个标记位,你给我把需要显示的子节点布局绘制出来。 这一篇文章我们就介绍 _Theatre 的实现逻辑,看看它是怎么实现续期需求的。

Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

_Theatre 是什么

从字面理解 _Theatre 就是舞台的意思,在舞台上就有演员表演,这个演员就是要显示的内容。 我们先看文档是怎么描述的,Flutter 的文档真的很棒,会给出很详细的解释~。

/// Special version of a [Stack], that doesn't layout and render the first
/// skipCount children.
/// 是特殊版本的 Stack,特殊的地方是,_Theatre 组件不会布局和渲染前skipCount数量的子节点
///
/// The first skipCount children are considered "offstage".
/// 前 skipCount 数量的子节点会被认为是不在舞台上的

也就是说 _Theatre 是精简版的 Stack,Stack 会布局和绘制所有的子节点,但是 _Theatre 仅仅布局和绘制在舞台上的子节点

_Theatre 的作用就是仅仅布局和绘制需要显示在页面的子节点,我们就看它是怎么通过三棵树的配合做到这一点的。

整个 _Theatre 代码就下面一点点

class _Theatre extends MultiChildRenderObjectWidget {
  _Theatre({
    Key? key,
    this.skipCount = 0,
    this.clipBehavior = Clip.hardEdge,
    List<Widget> children = const <Widget>[],
  }) : super(key: key, children: children);

  final int skipCount;

  final Clip clipBehavior;

  @override
  _TheatreElement createElement() => _TheatreElement(this);

  @override
  _RenderTheatre createRenderObject(BuildContext context) {
    return _RenderTheatre(
      skipCount: skipCount,
      textDirection: Directionality.of(context),
      clipBehavior: clipBehavior,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderTheatre renderObject) {
    renderObject
      ..skipCount = skipCount
      ..textDirection = Directionality.of(context)
      ..clipBehavior = clipBehavior;
  }
}

从上面我们可以知道:

  • 组件类型 _Theatre 是渲染类型的 Widget,并且有多个自子节点,所以它继承自 MultiChildRenderObjectWidget 。 因此,我们可以按照理解MultiChildRenderObjectWidget 方式来理解它,即 它的布局和绘制逻辑在 **_RenderTheatre`** 中。

  • 参数信息

参数类型作用
keyKey?组件的 Key
skipCountint需要跳过的数量
clipBehaviorClip裁剪的行为
childrenList<Widget>子节点

这里 skipCount 就是标记位,children 就是 Overlay 需要维持的子节点。

我们再来回顾一下,这些参数实际的值是谁?这些值的赋值在 Overlay 的 build 中。

Widget build(BuildContext context) {
    final List<Widget> children = <Widget>[];
    bool onstage = true;
    int onstageCount = 0;
    for (int i = _entries.length - 1; i >= 0; i -= 1) {
      final OverlayEntry entry = _entries[i];
      if (onstage) {
        onstageCount += 1;
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
        ));
        if (entry.opaque)
          onstage = false;
      } else if (entry.maintainState) {
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
          tickerEnabled: false,
        ));
      }
    }
    return _Theatre(
      skipCount: children.length - onstageCount,
      clipBehavior: widget.clipBehavior,
      children: children.reversed.toList(growable: false),
    );
  }

children: 就是维持的数据,注意,不是所有的数据。我们举个例子,Overlay 数组中一共有四个 OverlayEntry,分别是:entry1、entry2、entry3 和 entry4。

entry1entry2entry3entry4
opaquefalsefalsetruefalse
maintainStatefalsetruefalsefalse

上面一顿操作之后,build 方法中 children 的元素就是:entry4、entry3 和 entry2,长度是3,onstageCount 的值是 2,skipCount 的值是 1

因为在遍历完 entry3 的时候,onstage 已经是 false 了,但是由于 entry2 非要活着,所以走到了 else if 的判断中,但是不会影响 onstageCount 的值,而 entry1 咩有继续存活的诉求,所以不会添加到数组中,即数组的数量是 3。

这是代码层的判断,从我们逻辑上的判断就是,因为 entry3 是完全不透明的,所以它下面的内容不需要展示,因此在舞台上的就只有两个,onstageCount 的值是 2。entry1 好理解,它就不参与绘制了。但是由于 entry2 想要继续活着也就把它添加到数组中了,而 entry2 又不需要展示,所以在绘制的时候需要跳过它。

显示的时候,要严格按照我们添加的顺序来显示,后插入的在上层,所以 _Theatre 的 children 就是 entry2、entry3 和 entry4,需要反转一下

倒叙的遍历是为了从总数组中滤除需要维持的数组,后面的数组反转是为了显示出层级关系

和其它渲染类型的组件一样, Widget 层仅仅是将信息传递给了 Render 层,我们在这里仅仅需要知道它的含义,下面分析渲染层的时候,再看具体的执行逻辑。

_RenderTheatre 完成绘制

一般情况下,RenderObject 会将所有的子节点都会布局绘制下来,现在是要从某个节点开始布局绘制,那我们仅仅需要找到开始的节点即可。简单理解就是,原来是是从 0 开始遍历,现在是从 2 开始遍历。

_RenderTheatre 的目标是布局绘制需要绘制的,跳过不需要的

_RenderTheatre 混入了一个 ContainerRenderObjectMixin 这个模型。 而ContainerRenderObjectMixin 定义了子节点的访问方式,ContainerRenderObjectMixin 将子节点转成了一个双向列表。我们上面的例子中,传给 _RenderTheatre 的数组是 entry2、entry3 和 entry4,生成的链表如下:

Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

_RenderTheatre 非常巧妙的地方是,它没有重新生成一个列表,而是找到需要布局绘制的头节点,从头节点开始往后布局绘制,头节点就是 _firstOnstageChild 属性。

我们看 _firstOnstageChild 属性的 get 方法。

  RenderBox? get _firstOnstageChild {
    if (skipCount == super.childCount) { //第一处
      return null;
    }
    RenderBox? child = super.firstChild;
    for (int toSkip = skipCount; toSkip > 0; toSkip--) { 
      final StackParentData childParentData = child!.parentData! as StackParentData;
      child = childParentData.nextSibling; //第二处
      assert(child != null);
    }
    return child;
  }

第一处的判断,如果需要跳过的数量是所有的子节点的数量,说明啥也不需要绘制,直接退出。

我们看第二处:遍历就是链表的遍历,找到第一个需要显示的子节点,从它之后的子节点都是需要布局绘制的。也就是第一个 opaquetrueOverlayEntry ,也就是我们的 Entry3。对于一个链表来说,有了头节点,就相当于有了整个链。

负责布局的 performLayout

和其他的布局组件一样,performLayout 负责舞台剧的布局。

void performLayout() {
    _hasVisualOverflow = false;

    if (_onstageChildCount == 0) {
      return;
    }

    _resolve();

    // Same BoxConstraints as used by RenderStack for StackFit.expand.
    final BoxConstraints nonPositionedConstraints = BoxConstraints.tight(constraints.biggest);

    RenderBox? child = _firstOnstageChild;//第一处
    while (child != null) {//第一处
      final StackParentData childParentData = child.parentData! as StackParentData;

      if (!childParentData.isPositioned) {//第二处
        child.layout(nonPositionedConstraints, parentUsesSize: true);
        childParentData.offset = _resolvedAlignment!.alongOffset(size - child.size as Offset);
      } else {
        _hasVisualOverflow = RenderStack.layoutPositionedChild(child, childParentData, size, _resolvedAlignment!) || _hasVisualOverflow;
      }

      child = childParentData.nextSibling;//第一处
    }
  }

我们接着看: 第一处:这就是一个链表从指定头节点的遍历,遍历的行为是第二处。 第二处:如果是 Positioned 包裹的组件就按着 Positioned 的参数进行子节点的布局。否则就是默认的布局 layoutPositionedChild

所以就是子节点的布局过程,下面看绘制。

负责绘制的 paint

前面的布局完成之后,就需要进行绘制了,绘制的逻辑依然在 paint 方法中。

@override
void paint(PaintingContext context, Offset offset) {
    if (_hasVisualOverflow && clipBehavior != Clip.none) {
      _clipRectLayer.layer = context.pushClipRect(
        needsCompositing,
        offset,
        Offset.zero & size,
        paintStack,
        clipBehavior: clipBehavior,
        oldLayer: _clipRectLayer.layer,
      );
    } else {
      _clipRectLayer.layer = null;
      paintStack(context, offset);
    }
  }
  
@protected
void paintStack(PaintingContext context, Offset offset) {
    RenderBox? child = _firstOnstageChild;
    while (child != null) {
      final StackParentData childParentData = child.parentData! as StackParentData;
      context.paintChild(child, childParentData.offset + offset);
      child = childParentData.nextSibling;
    }
  }

绘制就是根据是否超出边界进行了裁剪。不管是否是超出边界都是 paintStack 的方法。具体的绘制和布局相似,都是指定头节点的遍历,调用子节点的绘制流程。

至此,_Theatre 的布局绘制流程就完事了,我们可以发现一点,那就是仅仅绘制了需要绘制的,而跳过的仅仅保持引用,没有进行绘制

下面我们按照上面的例子写一个验证。

案例验证

为了让案例的 Overlay 更加的干脆,我们在 Demo 中挂载一个 Overlay,而不用系统的 Overlay。

前期准备

先准备一个可以打印布局和测量日志的 渲染组件。

class CustomerWidget extends SingleChildRenderObjectWidget { //第一处
  CustomerWidget({required this.index, required Widget childWidget})
      : super(child: childWidget);

  final int index;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return CustomerRender(index,); //第一处
  }
}

class CustomerRender extends RenderProxyBox {
  int index;

  CustomerRender(this.index);

  @override
  void performLayout() {
    super.performLayout();
    print('performLayout $index'); //第二处
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    super.paint(context, offset);
    print('paint $index'); //第二处
  }
}

如果不熟悉可以参考之前的改造文章,CustomerWidget 在布局和绘制的时候会把日志打印出来,就是第二处的代码。

再对 OVerlay 进行改造,为了不被 Navigator 的 Overlay 污染,我们自己挂一个 Overlay。

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Overlay( //第一处
      initialEntries: [
        OverlayEntry(builder: (context) {
          return Scaffold(
            appBar: AppBar(
              title: Text("Flutter Overlay"),
            ),
            body: CenterBody(),
          );
        })
      ],
    );
  }
}
class CenterBody extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: MaterialButton(
        onPressed: () { insertIndex(context); },
        child: const Text("insert 1"),
      ),
    );
  }
  void insertIndex(BuildContext context) {
    OverlayEntry entry = OverlayEntry( //第三处
      builder: (context) {
        return RepaintBoundary(child: const EntryWidget1()); //第二处
      },
      opaque: false,
      maintainState: true,
    );
    Overlay.of(context)?.insert(entry);
  }
}

我们在第一处添加了 Overlay,所以下面的所有节点向上使用 Overlay.of(context) 查询的时候,都会返回我们手动添加的节点,并且为 Overlay 增加了初始化的 OverlayEntry

注意看第二处的代码,为了让打印的日志更加纯洁增加了绘制的边界,保证不会被其他组件污染。

页面效果如下:

Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

插入第一个 OverlayEntry

插入的代码是第三处,构造的 OverlayEntry 的情况如下:

opaquemaintainStatebuilder
falsetrueEntryWidget1

EntryWidget1 返回的就是我们自定义的组件。

class EntryWidget1 extends StatelessWidget {
  const EntryWidget1({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomerWidget(
        index: 1,
        childWidget: Center(
            child: GestureDetector(
                onTap: () {
                  OverlayEntry entry = OverlayEntry( // 插入Entry2
                    builder: (context) {
                      return const EntryWidget2();
                    },
                    opaque: true,
                    maintainState: false,
                  );
                  Overlay.of(context)?.insert(entry);
                },
                child: const Material(color: Colors.red,
                   child: Text("EntryWidget 1")))));
  }
}

我们来复盘一下插入的逻辑。

Overlay 的 build 方法逻辑:


 Widget build(BuildContext context) {
    final List<Widget> children = <Widget>[];
    bool onstage = true;
    int onstageCount = 0;
    for (int i = _entries.length - 1; i >= 0; i -= 1) {
      final OverlayEntry entry = _entries[i];
      if (onstage) {
        onstageCount += 1;
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
        ));
        if (entry.opaque)
          onstage = false;
      } else if (entry.maintainState) {
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
          tickerEnabled: false,
        ));
      }
    }
    return _Theatre(
      skipCount: children.length - onstageCount,
      clipBehavior: widget.clipBehavior,
      children: children.reversed.toList(growable: false),
    );
  }
  

_entries 的内容是:初始Entry插入的Entry1

初始EntryEntry1
opaquefalsefalse
maintainStatefalsetrue
builder初始页面EntryWidget1

遍历的时候反过来遍历:Entry1 和 初始Entry,从两者的属性来看,这两个都会加入到children 中。

children 的内容是 Entry1 和 初始Entry,在舞台上的数量 onstageCount 是 2。

所以构造的 _Theatre 的入参如下:

skipCount 是 0 children 是 初始Entry 和 Entry1

形成的子节点双向链表如下:

Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

所以 performLayout 和 paint 会把 初始Entry 和 Entry1 都进行布局和绘制。

效果图日志
Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

因为我们没有给初始Entry 增加日志,所以只看到 Entry1 的日志,并且为了效果图便于排版,我简单裁剪了一下。

从日志看,确实进行了布局和绘制,要不然也不会显示到屏幕上。

插入第二个 OverlayEntry

插入的代码是 插入Entry2处,构造的 OverlayEntry 的情况如下:

opaquemaintainStatebuilder
truefalseEntryWidget2

EntryWidget2 返回的也是我们自定义的组件。

class EntryWidget2 extends StatelessWidget {
  const EntryWidget2({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomerWidget(
        index: 2,
        childWidget: GestureDetector(
          onTap: () {
            OverlayEntry entry = OverlayEntry( //插入Entry3处
              builder: (context) {
                return const EntryWidget3();
              },
              opaque: false,
              maintainState: false,
            );
            Overlay.of(context)?.insert(entry);
          },
          child: const Material(
            child: Align(
                alignment: Alignment(0.0, -0.5), child: Text("EntryWidget 2")),
          ),
        ));
  }
}

我们再来复盘一下插入的逻辑,也就是 Overlay 的 build 方法逻辑:

 Widget build(BuildContext context) {
    final List<Widget> children = <Widget>[];
    bool onstage = true;
    int onstageCount = 0;
    for (int i = _entries.length - 1; i >= 0; i -= 1) {
      final OverlayEntry entry = _entries[i];
      if (onstage) {
        onstageCount += 1;
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
        ));
        if (entry.opaque) // tag 处
          onstage = false;
      } else if (entry.maintainState) {
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
          tickerEnabled: false,
        ));
      }
    }
    return _Theatre(
      skipCount: children.length - onstageCount,
      clipBehavior: widget.clipBehavior,
      children: children.reversed.toList(growable: false),
    );
  }

_entries 的内容是:初始Entry、前一个Entry1 和 插入的Entry2

初始EntryEntry1Entry2
opaquefalsefalsetrue
maintainStatefalsetruefalse
builder初始页面EntryWidget1EntryWidget2

遍历的时候反过来遍历:Entry2 、Entry1 和 初始Entry。 当第一个元素 Entry2 走到这处判断的时候,onstage 的值就设置为 false 了,这是一个栅栏,只有 Entry2 在舞台上了。

接着判断 Entry1, 因为 Entry1 非要停留在候场区,maintainState 是true,所以它也添加到了数组中。

而 初始Entry 直接就被舍弃了,因为它既不在舞台上,也不再舞台候场区

build 方法的 children 的内容是 Entry2 和 Entry1,在舞台上的数量onstageCount 是 1。

所以构造的 _Theatre 的入参如下:

skipCount 是 1 children 是 Entry1 和 Entry2

所以在 _Theatre 的渲染对象中位置的链表如下:

Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

所以布局和绘制的时候,只会布局绘制Entry2。

效果图日志
!Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

我们看到 Entry2 的内容完全盖住了 Entry1,日志方面我们看到仅仅 Entry2 的布局测量日志。

日志的上面两条是 插入1 的时候打印的,插入2的时候仅仅打印了下面2条

所以得出结论 虽然 Entry1 也在 _Theatre ,但是它并不会绘制,因为它在候场区。

插入第三个 OverlayEntry

插入的代码是插入 Entry3 处,构造的 OverlayEntry 的情况如下:

opaquemaintainStatebuilder
falsefalseEntryWidget2

EntryWidget3 返回的也是我们自定义的组件。

class EntryWidget3 extends StatelessWidget {
  const EntryWidget3({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomerWidget(
      childWidget: const Text("EntryWidget 3"),
      index: 3,
    );
  }
}

我们就不复盘 Overlay 的 build 逻辑了。直接写出结论。

_entries 的内容是:初始Entry、第一个Entry1 、 第二个插入的Entry2 和 第三个 Entry3。

初始EntryEntry1Entry2Entry3
opaquefalsefalsetruefalse
maintainStatefalsetruefalsefalse
builder初始页面EntryWidget1EntryWidget2EntryWidget3

遍历的时候依旧是反过来遍历:Entry3、Entry2 、Entry1 和 初始Entry。

首先是对 Entry3 的判断,Entry3 可以添加到数组中,并且计数器加一。

同样是 Entry2 处的判断,onstage 的值就设置为false 了,这是一个栅栏,所以是 Entry3 和 Entry2 在舞台上。

接着判断 Entry1, 因为 Entry1 死皮赖脸,maintainState 是 true,所以它也添加到了数组中。

而 初始Entry 直接就被舍弃了,因为它既不在舞台上,也不再舞台候场区。

build 方法的 children 的内容是 Entry3 、Entry2 和 Entry1,在舞台上的数量 onstageCount 是 2。

所以构造的 _Theatre 的入参如下:

skipCount 是 1 children 是 Entry1 、 Entry2 、Entry3

所以在 _Theatre 的渲染对象中位置的链表如下:

Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

跳过的依然是 Entry1,

所以布局和绘制的时候,会布局绘制Entry2 和 Entry3

效果图日志
Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧Flutter 必知必会系列 ——  无名英雄 _Theatre 舞台剧

我们看到 Entry2 的内容完全盖住了 Entry1,并且 Entry3 显示在 Entry2 的上面,日志方面我们看到仅仅 Entry2 和 Entry3 的布局测量日志。

注意一点,在布局绘制 Entry3 的时候,仅仅打印了 paint 2,而没有打印 performLayout 2,也就是只对 Entry2 进行了绘制,没有进行布局。 这是因为,Flutter 对布局进行了优化,如果待布局的节点,本次布局的约束和上次绘制的约束是一样的,那么本次就不会布局,会直接转到绘制。

所以得出结论:完全占据舞台之上的 Entry 都会布局绘制,舞台之下的只有在后场区的Entry 只会被保持引用,但也不会布局和绘制,其他的都会被舍弃。

总结

Overlay 提出的需求 _Theatre 很好的完成了,仅仅显示需要显示的节点,实现的原理就是链表的遍历,找到头节点从头到尾遍历就好了,现在看完了 Overlay、 _Theatre 我们离页面叠加的实现更近了一点,在一篇解释 Navigator

转载自:https://juejin.cn/post/7074418443839078431
评论
请登录