Flutter 页面生命周期的三层监听:应用、路由与容器

Flutter 没有原生意义上的页面生命周期回调:initState/dispose 只是存在性回调。本文拆解应用、路由、容器三层可见性监听的机制、伪代码与踩坑

Flutter&DartFlutter生命周期RouteObserverWidgetsBindingObserver

引言

从原生开发转到 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 只在”非保活路由页的创建与销毁”这一种情况像页面生命周期,而且连这种情况都漏掉最常用的”被盖住 / 重新露出”。保活机制(AutomaticKeepAliveClientMixinIndexedStack)的意义恰恰是把可见性和存在性解耦——也就彻底堵死了拿 disposeonPause 的路。

正确的心智模型:可见性由父级组合决定,想拿到信号,要么问系统(应用层),要么问导航器(路由层),要么问掌握切换事实的宿主(容器层)。

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+ 还提供了包装好的 AppLifecycleListeneronShow / 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. 第三层:容器级 —— 没有回调,只有宿主下发

IndexedStackTabBarViewPageView 里的子页可见性,由父级手里的一个 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,推荐流页面”该不该播放”其实是三个独立问题的与运算:

  1. 我所在的 Tab 是当前选中的吗?(容器层,宿主下发 isActive
  2. 我所在的路由被全屏路由盖住了吗?(路由层,RouteObserver<PageRoute>
  3. 应用在前台吗?(应用层,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 埋在两个文件里等着背刺你。