Flutter 必知必会系列 —— 无名英雄 _Theatre 舞台剧
往期精彩
前面的文章我们介绍了 Overlay 的基本原理,Overlay 仅仅是将需要维持的 OverlayEntry 转换成了 _OverlayEntryWidget ,真正负责显示的组件是 _Theatre。
相当于 Overlay 给 _Theatre 组件提了需求:我给你一组子节点和一个标记位,你给我把需要显示的子节点布局绘制出来。 这一篇文章我们就介绍 _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`** 中。 - 
参数信息
 
| 参数 | 类型 | 作用 | 
|---|---|---|
| key | Key? | 组件的 Key | 
| skipCount | int | 需要跳过的数量 | 
| clipBehavior | Clip | 裁剪的行为 | 
| children | List<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。
| entry1 | entry2 | entry3 | entry4 | |
|---|---|---|---|---|
| opaque | false | false | true | false | 
| maintainState | false | true | false | false | 
上面一顿操作之后,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,生成的链表如下:

_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;
  }
第一处的判断,如果需要跳过的数量是所有的子节点的数量,说明啥也不需要绘制,直接退出。
我们看第二处:遍历就是链表的遍历,找到第一个需要显示的子节点,从它之后的子节点都是需要布局绘制的。也就是第一个 opaque 是 true 的 OverlayEntry ,也就是我们的 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。
注意看第二处的代码,为了让打印的日志更加纯洁增加了绘制的边界,保证不会被其他组件污染。
页面效果如下:

插入第一个 OverlayEntry
插入的代码是第三处,构造的 OverlayEntry 的情况如下:
| opaque | maintainState | builder | 
|---|---|---|
| false | true | EntryWidget1 | 
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
| 初始Entry | Entry1 | |
|---|---|---|
| opaque | false | false | 
| maintainState | false | true | 
| builder | 初始页面 | EntryWidget1 | 
遍历的时候反过来遍历:Entry1 和 初始Entry,从两者的属性来看,这两个都会加入到children 中。
children 的内容是 Entry1 和 初始Entry,在舞台上的数量 onstageCount 是 2。
所以构造的 _Theatre 的入参如下:
skipCount 是 0 children 是 初始Entry 和 Entry1
形成的子节点双向链表如下:

所以 performLayout 和 paint 会把 初始Entry 和 Entry1 都进行布局和绘制。
| 效果图 | 日志 | 
|---|---|
![]()  | ![]()  | 
因为我们没有给初始Entry 增加日志,所以只看到 Entry1 的日志,并且为了效果图便于排版,我简单裁剪了一下。
从日志看,确实进行了布局和绘制,要不然也不会显示到屏幕上。
插入第二个 OverlayEntry
插入的代码是 插入Entry2处,构造的 OverlayEntry 的情况如下:
| opaque | maintainState | builder | 
|---|---|---|
| true | false | EntryWidget2 | 
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
| 初始Entry | Entry1 | Entry2 | |
|---|---|---|---|
| opaque | false | false | true | 
| maintainState | false | true | false | 
| builder | 初始页面 | EntryWidget1 | EntryWidget2 | 
遍历的时候反过来遍历: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 的渲染对象中位置的链表如下:

所以布局和绘制的时候,只会布局绘制Entry2。
| 效果图 | 日志 | 
|---|---|
!![]()  | ![]()  | 
我们看到 Entry2 的内容完全盖住了 Entry1,日志方面我们看到仅仅 Entry2 的布局测量日志。
日志的上面两条是 插入1 的时候打印的,插入2的时候仅仅打印了下面2条。
所以得出结论 虽然 Entry1 也在 _Theatre ,但是它并不会绘制,因为它在候场区。
插入第三个 OverlayEntry
插入的代码是插入 Entry3 处,构造的 OverlayEntry 的情况如下:
| opaque | maintainState | builder | 
|---|---|---|
| false | false | EntryWidget2 | 
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。
| 初始Entry | Entry1 | Entry2 | Entry3 | |
|---|---|---|---|---|
| opaque | false | false | true | false | 
| maintainState | false | true | false | false | 
| builder | 初始页面 | EntryWidget1 | EntryWidget2 | EntryWidget3 | 
遍历的时候依旧是反过来遍历: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 的渲染对象中位置的链表如下:

跳过的依然是 Entry1,
所以布局和绘制的时候,会布局绘制Entry2 和 Entry3
| 效果图 | 日志 | 
|---|---|
![]()  | ![]()  | 
我们看到 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





