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