Flutter 页面生命周期的三层监听:应用、路由与容器
Flutter 没有原生意义上的页面生命周期回调:initState/dispose 只是存在性回调。本文拆解应用、路由、容器三层可见性监听的机制、伪代码与踩坑
引言
从原生开发转到 Flutter,几乎每个人都会问同一个问题:onResume / onPause(Android)或 viewWillAppear / viewDidDisappear(iOS)在哪里?
答案是:没有。Flutter 没有页面级可见性生命周期回调,而且这是刻意设计——框架里根本没有”页面”这个概念,只有 Widget。一个 Widget 可不可见,取决于父级怎么组合它:它可能在被压住的路由里、在没选中的 Tab 里、在滚出视口的列表里,框架不会替你把这些情况汇总成一对 onShow / onHide。
于是很多人退而求其次,拿 initState / dispose 当页面生命周期用。这在最窄的场景下碰巧能工作,但本质上是把”存在性”误当成了”可见性”,一旦引入保活或 Tab 容器就会彻底失效。
本文先讲清这个误区,再拆解 Flutter 中真正可用的三层可见性监听——应用层、路由层、容器层——各自的机制、关键伪代码和踩坑点,最后给出把三层拼起来的实战模式。
1. initState/dispose 是存在性回调,不是可见性回调
State 的生命周期回答的问题是”这个 State 对象什么时候被挂载、什么时候被卸载”:
createState → initState → didChangeDependencies → build
→ didUpdateWidget / setState → build(若干次)
→ deactivate →(可能 activate 回来)→ dispose
它和”用户能不能看见这个页面”是两个维度。用一张表对照常见宿主场景:
| 场景 | initState/dispose 的行为 | 页面实际可见性 |
|---|---|---|
| 路由 push 进入 | 首次构建时initState | 可见 ✅ 碰巧一致 |
| 路由被新路由盖住 | 什么都不触发(组件保持挂载) | 不可见 ❌ 漏掉 |
| 上层路由 pop、重新露出 | 什么都不触发 | 可见 ❌ 漏掉 |
| 路由被 pop 销毁 | dispose | 不可见 ✅ 碰巧一致 |
IndexedStack 切 Tab | 什么都不触发(所有子页永久挂载) | 变化 ❌ 全漏 |
TabBarView + 保活 | 首次访问initState,之后永无信号 | 变化 ❌ 全漏 |
TabBarView 无保活 | 划出缓存区就dispose | ❌ 把”不可见”变成”状态销毁”,且预加载让initState 早于可见 |
| 应用退后台 | 什么都不触发 | 不可见 ❌ 漏掉 |
结论:initState / dispose 只在”非保活路由页的创建与销毁”这一种情况像页面生命周期,而且连这种情况都漏掉最常用的”被盖住 / 重新露出”。保活机制(AutomaticKeepAliveClientMixin、IndexedStack)的意义恰恰是把可见性和存在性解耦——也就彻底堵死了拿 dispose 当 onPause 的路。
正确的心智模型:可见性由父级组合决定,想拿到信号,要么问系统(应用层),要么问导航器(路由层),要么问掌握切换事实的宿主(容器层)。
2. 第一层:应用级 —— WidgetsBindingObserver
应用整体的前后台切换,由 WidgetsBindingObserver.didChangeAppLifecycleState 上报:
resumed ⇄ inactive ⇄ hidden ⇄ paused →(进程销毁前)detached
resumed:前台可见可交互;inactive:可见但不可交互的瞬态(任务切换器、来电、系统弹窗);hidden:完全不可见(Flutter 3.13+ 引入,退后台时先于paused到达,前后台两个方向都会经过它);paused:已入后台(仅移动端)。
关键伪代码——注意对 inactive 的过滤,别把拉下通知栏都当成退后台:
class _PageState extends State<Page> with WidgetsBindingObserver {
bool _isAppVisible = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 只在 hidden(退隐)与 resumed(回前台)间翻转,忽略 inactive 瞬态
final visible = switch (state) {
AppLifecycleState.resumed => true,
AppLifecycleState.hidden => false,
_ => _isAppVisible,
};
if (visible == _isAppVisible) return;
setState(() => _isAppVisible = visible);
}
}
Flutter 3.13+ 还提供了包装好的 AppLifecycleListener(onShow / onHide / onRestart 等具名回调),语义相同,选一个用即可。
这一层的边界:它只管应用整体。应用内切 Tab、压路由,它一个字都不会说。典型的必须用它的场景:Android 上 video_player(ExoPlayer)在应用退后台后声音会继续播,不会像 iOS 的 AVPlayer 那样自动暂停——不监听这一层就会出”退后台还在响”的 bug。内嵌 WebView 同理。
3. 第二层:路由级 —— RouteObserver + RouteAware
这一层是 Flutter 里最接近原生页面生命周期的东西,专管”我这个路由被盖住了/重新露出了”。
两步接线。第一步,创建全局观察者并注册进导航器:
// 泛型选 PageRoute 是关键,见下文
final routeObserver = RouteObserver<PageRoute<dynamic>>();
MaterialApp(navigatorObservers: [routeObserver], ...)
// 或 GoRouter(observers: [routeObserver], ...)
第二步,页面订阅(订阅时机必须在 didChangeDependencies,因为要 ModalRoute.of(context)):
class _PageState extends State<Page> with RouteAware {
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute) routeObserver.subscribe(this, route);
}
@override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
@override
void didPushNext() { /* 被新路由盖住 → 暂停播放/停止刷新 */ }
@override
void didPopNext() { /* 上面的路由弹出,我重新露出 → 恢复 */ }
}
四个回调的语义:
didPush:我自己被推入(≈ 首次显示);didPop:我自己被弹出(≈ 销毁前);didPushNext:新路由压到我上面(被盖住);didPopNext:压我的路由弹出(重新露出)。
三个关键细节:
泛型过滤是这层的精髓。 RouteObserver<R> 只在新旧路由都是 R 类型时才派发通知。把泛型定为 PageRoute 后,对话框、底部弹窗这些 PopupRoute 压上来时不会触发 didPushNext——这通常正是业务想要的:弹个评论面板,底下的视频应该继续播放;跳到全屏的详情页,视频才应该暂停。一行泛型替你区分了”半遮”和”全遮”。
只通知直接下一层。 A 上压 B、B 上压 C 时,didPushNext 只发给 B(C 的直接前一个路由),A 不会重复收到——A 早在 B 压上来时就知道自己被盖住了,语义自洽。
它只对路由生效。 嵌套 Navigator 要在各自的 observers 里单独注册;didReplace 不会转成 RouteAware 通知;Tab 切换与它无关——那是第三层的事。
实践中建议把订阅样板封装成一个可复用组件,用回调把事件交给宿主:
RouteVisibilityListener(
onCovered: () => setState(() => _isRouteVisible = false),
onRevealed: () => setState(() => _isRouteVisible = true),
child: Scaffold(...),
)
4. 第三层:容器级 —— 没有回调,只有宿主下发
IndexedStack、TabBarView、PageView 里的子页可见性,由父级手里的一个 index 决定。框架在这一层没有任何信号:IndexedStack 的落选子页保持挂载、布局照做、Ticker 照跑,只是不绘制;保活的 TabBarView 子页划走后也原样活着。
既然只有宿主知道”现在选中的是谁”,信号就只能由宿主沿着组件树往下发——这就是 isActive 属性模式:
// 宿主:唯一掌握切换事实的人
IndexedStack(
index: currentIndex,
children: [
for (final (index, tab) in tabs.indexed)
switch (tab) {
Tab.home => HomePage(key: ValueKey(tab), isActive: currentIndex == index),
Tab.feed => FeedPage(key: ValueKey(tab), isActive: currentIndex == index),
},
],
)
// 子页:在 didUpdateWidget 里捕捉翻转沿
@override
void didUpdateWidget(covariant FeedPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive && !widget.isActive) _pause();
if (!oldWidget.isActive && widget.isActive) _resume();
}
两个工程要点:
单一事实来源。 千万不要写 static const feedIndex = 1; 然后在 children 列表里靠人肉对齐——顺序一换就静默错位。让页面顺序、激活判定、底栏条目全部从同一个列表按位置推导;顺序需要服务端下发时,把这个列表从编译期常量升级成一个 Provider/状态即可,结构不变。跳转特定 Tab 一律用 tabs.indexOf(target) 反查,不假设固定 index。
子页要按身份加 Key。 顺序动态化后,children 重排时 Flutter 默认按”位置 + 类型”匹配 Element,不加 Key 会导致整页重建、状态全丢;按身份 ValueKey(tab) 后重排只是 reparent,状态保留。
5. 三层拼起来:一个短视频 App 的实战组合
一个带底部 Tab 的短视频 App,推荐流页面”该不该播放”其实是三个独立问题的与运算:
- 我所在的 Tab 是当前选中的吗?(容器层,宿主下发
isActive) - 我所在的路由被全屏路由盖住了吗?(路由层,
RouteObserver<PageRoute>) - 应用在前台吗?(应用层,
WidgetsBindingObserver)
// 主壳:容器层 + 路由层在这里汇合
class _ShellState extends State<Shell> {
bool _coveredByRoute = false;
@override
Widget build(BuildContext context) {
return RouteVisibilityListener(
onCovered: () => setState(() => _coveredByRoute = true),
onRevealed: () => setState(() => _coveredByRoute = false),
child: IndexedStack(
index: currentIndex,
children: [
for (final (index, tab) in tabs.indexed)
buildPage(tab, isActive: !_coveredByRoute && currentIndex == index),
],
),
);
}
}
// 推荐流页面:应用层在页面内补上
class _FeedPageState extends State<FeedPage> with WidgetsBindingObserver {
bool _isAppVisible = true; // didChangeAppLifecycleState 维护
@override
Widget build(BuildContext context) {
return VideoFeed(
// 三层与运算:任何一层说"不可见"就暂停
isActive: widget.isActive && _isAppVisible,
);
}
}
这样组合后,几类原生开发里”理所当然”、Flutter 里却极易漏掉的行为都被系统性覆盖:
- 从推荐流跳全屏详情页 → 路由层
didPushNext→ 暂停(不用在每个跳转入口手写pause()); - 详情页返回 →
didPopNext→ 恢复; - 弹出评论半屏面板 →
PopupRoute被泛型过滤 → 不暂停,视频继续播; - 切到别的 Tab → 容器层
isActive翻转 → 暂停(还可以顺势释放解码器); - 应用退后台 → 应用层
hidden→ 暂停,堵住 Android 后台声音继续播的洞。
补充两个边界情况:
平台视图(如 WebView)没有暂停 API 时,可以在收到失活信号后注入一段 JS 兜底:
controller.runJavaScript(
"document.querySelectorAll('video,audio').forEach((m) => m.pause());",
);
如果三层都不够——比如要按”实际绘制面积比例”触发曝光埋点、或列表滚动中的元素级可见性——那才轮到第三方包 visibility_detector:它按像素真相回调 onVisibilityChanged,什么场景都覆盖,代价是引入依赖和逐帧检测开销。把它当兜底选项,而不是第一选择。
6. 速查表
| 你想知道的 | 用哪层 | 关键 API |
|---|---|---|
| 应用退后台/回前台 | 应用层 | WidgetsBindingObserver / AppLifecycleListener |
| 我的路由被盖住/露出 | 路由层 | RouteObserver<PageRoute> + RouteAware |
| 弹窗要不要算”被盖住” | 路由层 | 泛型定PageRoute:弹窗(PopupRoute)自动排除 |
| Tab/IndexedStack 切换 | 容器层 | 宿主下发isActive,子页 didUpdateWidget 捕捉翻转 |
| 组件创建/销毁 | State 生命周期 | initState / dispose(别当可见性用) |
| 像素级曝光比例 | 兜底 | visibility_detector |
总结
Flutter 不给页面生命周期,不是缺陷,而是把”页面”的定义权交给了你:可见性由组合方式决定,监听也就按组合方式分层——应用层问系统,路由层问导航器,容器层问宿主。initState / dispose 管的是存在性,和可见性只是偶尔重合。
工程上记住三句话:需要播放/刷新控制的页面,把三层信号做成一次与运算;路由层用 RouteObserver<PageRoute> 的泛型过滤区分全屏页和弹窗;容器层坚持单一事实来源 + 身份 Key,别让一个魔法 index 埋在两个文件里等着背刺你。