Flutter Overlay 详解
从 Flutter Framework 源码分析 OverlayEntry、OverlayState、_RenderTheater 与 OverlayPortal 的实现原理
引言
在 Flutter 中,对话框、菜单、Tooltip、拖拽时跟随手指的预览,以及 Navigator 管理的页面,都需要一种能力:让某个 Widget 脱离原本的局部布局,显示在其他内容之上。
Overlay 就是 Flutter Framework 提供的浮层基础设施。
从使用层面看,Overlay 很像一个可以动态插入和移除子节点的 Stack。但从源码实现看,它远不只是 Stack:
OverlayState维护有序的OverlayEntry列表;opaque和maintainState决定被遮挡 Entry 是否继续构建和保留状态;_RenderTheater只布局、绘制真正可见的 onstage 子节点;Overlay.of通过内部的 InheritedWidget 找到当前上下文所属的 Overlay;OverlayPortal让浮层内容仍能访问声明位置附近的InheritedWidget。
本文基于 packages/flutter/lib/src/widgets/overlay.dart 的源码,分析 Overlay 的核心实现,以及这些设计背后的原因。
1. Overlay 不是全局单例
首先需要纠正一个常见误解:Overlay 并不是应用中唯一的全局浮层。
一个 Flutter 应用可以存在多个 Overlay。例如嵌套 Navigator 通常会拥有各自的 Overlay。调用:
final overlayState = Overlay.of(context);
得到的是当前 context 能找到的最近 Overlay,而不是某个固定的全局对象。如果确实需要最外层 Overlay,可以使用:
final rootOverlayState = Overlay.of(context, rootOverlay: true);
这一区别会直接影响弹窗、菜单等浮层最终显示在哪一层。
2. 经典 API:OverlayEntry 与 OverlayState
经典 Overlay API 由两个核心角色组成:
OverlayEntry:描述一个要显示的浮层内容;OverlayState:管理所有 Entry 的顺序、插入、移除和重建。
最小使用方式如下:
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (context) {
return Positioned(
top: 80,
right: 20,
child: Material(
child: TextButton(
onPressed: entry.remove,
child: const Text('关闭浮层'),
),
),
);
},
);
Overlay.of(context).insert(entry);
OverlayEntry 本身不是 Widget。它更像一个可被 OverlayState 管理的配置对象,内部保存 builder 和若干影响渲染策略的属性。
2.1 OverlayEntry 的关键属性
源码中的构造器包含四个核心参数:
OverlayEntry({
required this.builder,
bool opaque = false,
bool maintainState = false,
this.canSizeOverlay = false,
});
它们分别控制:
| 属性 | 作用 |
|---|---|
builder | 构建浮层内容 |
opaque | 声明该 Entry 会完全遮挡其下方内容 |
maintainState | 被不透明 Entry 遮挡后,是否仍保留在 Widget 树中 |
canSizeOverlay | 在 Overlay 收到无限约束时,是否允许该 Entry 决定 Overlay 尺寸 |
其中最重要的是 opaque 和 maintainState。
opaque 并不会检查像素是否真的不透明,也不会自动裁剪下方内容。它只是开发者提供给 Framework 的性能提示:这个 Entry 下面的内容已经看不到了,可以不再构建和渲染。
maintainState 则用于那些虽然暂时不可见,但不能丢失状态的 Entry。Navigator 管理后台 Route 时就需要类似能力。
2.2 OverlayState 用有序列表管理层级
OverlayState 内部维护一个列表:
final List<OverlayEntry> _entries = <OverlayEntry>[];
列表越靠后,Entry 越接近用户,也就是 z-order 越高。默认插入会把 Entry 放到列表末尾:
void insert(OverlayEntry entry, {OverlayEntry? below, OverlayEntry? above}) {
entry._overlay = this;
setState(() {
_entries.insert(_insertionIndex(below, above), entry);
});
}
above 和 below 可以精确指定相对位置。位置计算逻辑可以概括为:
if (below != null) return _entries.indexOf(below);
if (above != null) return _entries.indexOf(above) + 1;
return _entries.length;
除了 insert,OverlayState 还提供 insertAll 和 rearrange。其中 rearrange 可以一次调整一组 Entry 的顺序,Navigator 的 Route 管理会重度依赖这种能力。
3. OverlayState.build:从栈顶向下裁剪
Overlay 最关键的性能优化发生在 OverlayState.build。
它不会简单地把 _entries 全部交给 Stack,而是从栈顶向下遍历 Entry:
for (final OverlayEntry entry in _entries.reversed) {
if (onstage) {
onstageCount += 1;
children.add(_OverlayEntryWidget(
key: entry._key,
overlayState: this,
entry: entry,
));
if (entry.opaque) {
onstage = false;
}
} else if (entry.maintainState) {
children.add(_OverlayEntryWidget(
key: entry._key,
overlayState: this,
entry: entry,
tickerEnabled: false,
));
}
}
这里存在三种情况:
- 在遇到
opaqueEntry 之前,Entry 都属于 onstage,需要正常构建和渲染。 - 遇到
opaqueEntry 后,下面的 Entry 默认不再加入 Widget 树。 - 如果下方 Entry 设置了
maintainState,它仍会保留,但其 Ticker 会被禁用。
最终,OverlayState 会把 children 交给内部的 _Theater:
return _Theater(
skipCount: children.length - onstageCount,
children: children.reversed.toList(growable: false),
);
skipCount 表示 children 开头有多少个节点属于 offstage。它们仍然存在于 Widget 树中,却不应该参与布局和绘制。
这种设计比单纯使用 Offstage 更贴合 Overlay 的需求,因为 Overlay 还要统一管理所有 Entry 的 Stack 布局、绘制顺序和 OverlayPortal 子节点。
4. 为什么 Overlay 使用 _RenderTheater
Overlay 没有直接使用公开的 Stack,而是实现了一个内部渲染对象 _RenderTheater。
_RenderTheater 复用了 Stack 的布局思路,但额外理解 onstage 和 offstage:
RenderBox? get _firstOnstageChild {
if (skipCount == childCount) {
return null;
}
RenderBox? child = firstChild;
for (int toSkip = skipCount; toSkip > 0; toSkip -= 1) {
child = child!.parentData!.nextSibling;
}
return child;
}
前 skipCount 个 child 会被跳过。它们的 RenderObject 仍然挂在树上,但不会参与正常 layout 和 paint。
因此,maintainState: true 的 Entry 可以保存 Element、State 和 RenderObject,同时又避免不可见页面持续消耗布局、绘制和动画资源。
4.1 有限约束下的尺寸
在常见场景中,Overlay 收到有限约束,尺寸直接取最大可用空间:
size = constraints.biggest;
这也是 Overlay 通常铺满整个 Navigator 区域的原因。
4.2 无限约束与 canSizeOverlay
如果约束是无限的,Overlay 无法直接决定自身尺寸。此时 _RenderTheater 会从栈顶向下寻找一个满足条件的 Entry:
canSizeOverlay == true;- 不是 Positioned 子节点;
- 当前处于 onstage。
找到后,该 Entry 的尺寸会被用于确定 Overlay 尺寸。如果找不到合适的 child,Framework 会抛出错误。
这说明 canSizeOverlay 不是普通布局开关,而是对 Overlay 尺寸决策权的明确授权。
5. OverlayEntry 如何触发自身重建
OverlayEntry 不是 Widget,却提供了 markNeedsBuild():
void markNeedsBuild() {
_key.currentState?._markNeedsBuild();
}
每个 Entry 内部持有一个指向 _OverlayEntryWidgetState 的 GlobalKey。当业务代码调用 markNeedsBuild() 时,Entry 通过 GlobalKey 找到树中的 State,再由 State 调用 setState。
void _markNeedsBuild() {
setState(() {
// builder 使用的外部状态已经发生变化
});
}
这是一种典型的跨树通信方式:配置对象不在 Widget 树中,但它仍然可以精确触发对应浮层 Widget 的重建。
需要注意,markNeedsBuild() 只会重建该 Entry,不会改变 Entry 在 Overlay 中的位置。
6. remove() 为什么关心 SchedulerPhase
OverlayEntry.remove() 看似只是从列表中删除自身,源码却对调度阶段做了特殊处理:
void remove() {
final OverlayState overlay = _overlay!;
_overlay = null;
if (!overlay.mounted) {
return;
}
overlay._entries.remove(this);
if (SchedulerBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((duration) {
overlay._markDirty();
});
} else {
overlay._markDirty();
}
}
在 persistentCallbacks 阶段,Flutter 正在执行一帧中的布局和绘制工作。如果此时立即通过 _markDirty() 调用 setState,就可能违反当前帧的构建约束。
因此 remove 会先从 _entries 中删除 Entry,再把 Overlay 的重建延迟到 post-frame callback。其他阶段则可以立即标记 Overlay 为 dirty。
这也意味着:调用 remove() 后,Entry 不一定在当前调用栈内立刻从屏幕消失,最终更新时机取决于当前调度阶段。
6.1 remove 与 dispose 不是一回事
remove() 表示把 Entry 从 Overlay 中移除,dispose() 表示业务方不再使用这个 Entry。
正确生命周期通常是:
entry.remove();
entry.dispose();
如果 dispose 时对应 Widget 还没有真正 unmount,OverlayEntry 会延迟释放内部 notifier,直到 _didUnmount() 被调用。这样监听者仍能收到 Entry 最终离开 Widget 树的通知。
7. Overlay.of 的真实查找链路
Overlay.of(context) 并不是直接查找一个 Overlay Widget,也不是访问全局变量。它最终通过 _RenderTheaterMarker 查找 OverlayState:
Overlay.of(context)
-> Overlay.maybeOf(context)
-> _RenderTheaterMarker.maybeOf(context)
-> LookupBoundary 查找祖先 _RenderTheaterMarker
-> overlayEntryWidgetState.widget.overlayState
每个 _OverlayEntryWidgetState 在 build 时都会注入 _RenderTheaterMarker:
return TickerMode(
enabled: widget.tickerEnabled,
child: _RenderTheaterMarker(
theater: _theater,
overlayEntryWidgetState: this,
child: Builder(builder: widget.entry.builder),
),
);
因此,在某个 Entry 的 builder 中调用 Overlay.of(context),找到的是该 Entry 所属的 Overlay。
rootOverlay: true 则会沿祖先继续查找更远的 Marker,用于把浮层插入最外层 Overlay。
8. OverlayPortal:声明式浮层 API
经典 OverlayEntry API 有一个明显限制:Entry 的 builder 构建在目标 Overlay 的子树中,而不是调用代码所在的局部子树中。
这会导致浮层内容无法自然访问声明位置附近的 Theme、MediaQuery、Provider 或其他 InheritedWidget。
OverlayPortal 用于解决这个问题:
final controller = OverlayPortalController();
OverlayPortal(
controller: controller,
overlayChildBuilder: (context) {
return const Positioned(
top: 80,
child: Material(child: Text('浮层内容')),
);
},
child: TextButton(
onPressed: controller.toggle,
child: const Text('切换浮层'),
),
);
它有两部分:
child正常出现在原 Widget 树中;overlayChildBuilder构建的内容被渲染到祖先 Overlay 上。
OverlayPortal 的重要保证是:overlay child 可以依赖 OverlayPortal 所在位置可见的 InheritedWidget,并且不会比 OverlayPortal 自身活得更久。
8.1 Controller 与 z-order
OverlayPortalController 提供:
controller.show();
controller.hide();
controller.toggle();
每次 show() 都会获得一个全局单调递增的 z-order 索引:
int _now() {
return _wallTime += 1;
}
所以即使 OverlayPortal 已经显示,再次调用 show() 也具有 bring-to-top 的语义:最近 show 的 overlay child 会显示在更上层。
8.2 OverlayPortal 为什么实现复杂
OverlayPortal 的 overlay child 在 Widget 依赖关系上属于 Portal 附近的子树,但在 Render Tree 中又必须成为 _RenderTheater 管理的浮层 child。
它同时需要:
- 读取 Portal 到 Theater 之间的绘制变换;
- 按 Theater 的 Stack 规则参与布局;
- 避免同一个 RenderBox 被重复 layout;
- 按 Controller 的 z-order 与其他 Portal child 排序。
为此,源码引入了 _OverlayEntryLocation、_RenderDeferredLayoutBox 等内部类型。
其中 _RenderDeferredLayoutBox 会延迟真正的 child layout,确保 _RenderTheater 和 Portal 的布局代理都已经完成布局,浮层内容才能读取到正确的 transform。
这也是 OverlayPortal 比 OverlayEntry 更方便,却在 Framework 内部实现得更复杂的原因。
9. OverlayEntry 与 OverlayPortal 如何选择
两套 API 并不是简单的新旧替代关系。
| 场景 | 更适合的 API |
|---|---|
| 浮层生命周期由独立业务对象管理 | OverlayEntry |
| 需要直接插入、移除、重排多个 Entry | OverlayEntry |
| 浮层需要访问声明位置附近的 InheritedWidget | OverlayPortal |
| 希望浮层生命周期与某个 Widget 严格绑定 | OverlayPortal |
| 需要通过 show/hide 控制并自动提升到顶层 | OverlayPortalController |
经典 API 更直接,适合命令式控制。OverlayPortal 更声明式,适合浮层内容与局部 Widget 上下文强相关的场景。
10. 常见误区
10.1 opaque 会自动裁剪下方像素
不会。opaque 是性能提示,用于决定下方 Entry 是否还需要构建和保留。真正的像素裁剪由 clipBehavior 等渲染配置控制。
10.2 maintainState 会让被遮挡页面继续正常运行
不完全正确。它会保留 Widget 状态,但 OverlayState 会为 offstage Entry 设置 tickerEnabled: false,避免不可见动画继续驱动。
10.3 Overlay.of 总能找到应用最外层浮层
不会。默认找到最近的 Overlay,需要最外层时应使用 rootOverlay: true,并理解嵌套 Navigator 的结构。
10.4 remove 后可以直接复用已 dispose 的 Entry
不可以。dispose() 表示 Entry 生命周期结束。需要再次显示时,应创建新的 OverlayEntry。
10.5 Overlay 就是动态 Stack
从布局效果看相似,但 Overlay 还承担 Entry 生命周期、调度阶段处理、不可见节点优化、Ticker 控制、查找链路和 Portal 渲染等职责。
11. 完整时序总结
插入 OverlayEntry
创建 OverlayEntry
-> Overlay.of(context) 找到 OverlayState
-> OverlayState.insert(entry)
-> entry._overlay 绑定 OverlayState
-> _entries 插入指定位置
-> OverlayState.setState
-> build 根据 opaque / maintainState 生成 children
-> _Theater 创建 _RenderTheater
-> _RenderTheater 布局并绘制 onstage children
移除 OverlayEntry
entry.remove()
-> entry._overlay 置空
-> 从 overlay._entries 删除
-> 根据 SchedulerPhase 立即或延迟 _markDirty
-> OverlayState rebuild
-> _OverlayEntryWidget 从树中移除
-> State dispose 并通知监听者
-> entry._didUnmount() 完成延迟清理
显示 OverlayPortal
controller.show()
-> 生成新的 z-order 索引
-> _OverlayPortalState.setState
-> 创建 OverlayPortal 对应的内部 Widget
-> RenderObject child 加入 _RenderTheater
-> 延迟布局机制计算正确 transform
-> overlay child 按 z-order 显示
总结
Overlay 的核心目标不是简单地“把 Widget 放到最上面”,而是在动态浮层场景中同时保证正确的层级、生命周期、上下文依赖和渲染性能。
经典 OverlayEntry API 通过 OverlayState._entries 提供直接的命令式控制;OverlayState.build 与 _RenderTheater 通过 opaque、maintainState 和 skipCount 避免不可见内容浪费资源;Overlay.of 让浮层定位到正确的 Overlay;OverlayPortal 则进一步解决浮层内容无法访问声明位置 InheritedWidget 的问题。
理解这些实现后,再看 Navigator、Dialog、Tooltip、Draggable 等组件的源码,就会发现它们并不是各自实现了一套浮层系统,而是在 Overlay 提供的基础能力上组合出不同的交互。