Flutter Overlay 详解

从 Flutter Framework 源码分析 OverlayEntry、OverlayState、_RenderTheater 与 OverlayPortal 的实现原理

Flutter源码FlutterOverlayOverlayEntryOverlayPortal

引言

在 Flutter 中,对话框、菜单、Tooltip、拖拽时跟随手指的预览,以及 Navigator 管理的页面,都需要一种能力:让某个 Widget 脱离原本的局部布局,显示在其他内容之上。

Overlay 就是 Flutter Framework 提供的浮层基础设施。

从使用层面看,Overlay 很像一个可以动态插入和移除子节点的 Stack。但从源码实现看,它远不只是 Stack:

  • OverlayState 维护有序的 OverlayEntry 列表;
  • opaquemaintainState 决定被遮挡 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 尺寸

其中最重要的是 opaquemaintainState

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

abovebelow 可以精确指定相对位置。位置计算逻辑可以概括为:

if (below != null) return _entries.indexOf(below);
if (above != null) return _entries.indexOf(above) + 1;
return _entries.length;

除了 insert,OverlayState 还提供 insertAllrearrange。其中 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,
    ));
  }
}

这里存在三种情况:

  1. 在遇到 opaque Entry 之前,Entry 都属于 onstage,需要正常构建和渲染。
  2. 遇到 opaque Entry 后,下面的 Entry 默认不再加入 Widget 树。
  3. 如果下方 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 内部持有一个指向 _OverlayEntryWidgetStateGlobalKey。当业务代码调用 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 的子树中,而不是调用代码所在的局部子树中。

这会导致浮层内容无法自然访问声明位置附近的 ThemeMediaQuery、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。

它同时需要:

  1. 读取 Portal 到 Theater 之间的绘制变换;
  2. 按 Theater 的 Stack 规则参与布局;
  3. 避免同一个 RenderBox 被重复 layout;
  4. 按 Controller 的 z-order 与其他 Portal child 排序。

为此,源码引入了 _OverlayEntryLocation_RenderDeferredLayoutBox 等内部类型。

其中 _RenderDeferredLayoutBox 会延迟真正的 child layout,确保 _RenderTheater 和 Portal 的布局代理都已经完成布局,浮层内容才能读取到正确的 transform。

这也是 OverlayPortal 比 OverlayEntry 更方便,却在 Framework 内部实现得更复杂的原因。

9. OverlayEntry 与 OverlayPortal 如何选择

两套 API 并不是简单的新旧替代关系。

场景更适合的 API
浮层生命周期由独立业务对象管理OverlayEntry
需要直接插入、移除、重排多个 EntryOverlayEntry
浮层需要访问声明位置附近的 InheritedWidgetOverlayPortal
希望浮层生命周期与某个 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 通过 opaquemaintainStateskipCount 避免不可见内容浪费资源;Overlay.of 让浮层定位到正确的 Overlay;OverlayPortal 则进一步解决浮层内容无法访问声明位置 InheritedWidget 的问题。

理解这些实现后,再看 Navigator、Dialog、Tooltip、Draggable 等组件的源码,就会发现它们并不是各自实现了一套浮层系统,而是在 Overlay 提供的基础能力上组合出不同的交互。