客户端开发

Flutter 视频页生命周期:路由覆盖、Tab 切换与后台播放的统一处理

Flutter 中被新路由覆盖或切走的页面不会触发 dispose;记录如何用有效可见性统一管理实时视频流、双向对讲、录像与横屏锁定。

约 12 分钟读完
目录
  1. 1. 仅在 dispose / onClose 中释放资源的局限
  2. 2. 基于「有效前台可见性」的资源启停管理
  3. 3. 最小可复用实现:RouteAware + 前后台与方向管理
  4. 4. 实际接入时的三个细节与适用边界

在带有实时视频流播放、双向语音对讲与本地录像功能的 Flutter 移动端项目(Android API 34)里,连续处理过三次表面上看似不相关的缺陷:

  1. 首页的设备实时预览卡片,在用户切到底部其他 Tab 或退到系统桌面后,后台依然在持续拉流解码,偶尔还会传出声音;
  2. 从全屏视频页点击右上角进入「画面设置」「重命名」等二级页面后,全屏页已被遮挡,底层播放器却没有停流,对讲与录像状态也未及时结束;
  3. 全屏页打开了横屏锁定后,Navigator.push 跳进普通的竖屏设置页时,整个设置页也被保持在横屏状态,点开重命名输入框时软键盘容易引发页面布局溢出。

把这三个问题放到一起看,背后其实是同一个容易被忽略的前提:在 Flutter 里,“用户离开了当前页面”和“Widget 触发了 dispose”并不等价。

1. 仅在 dispose / onClose 中释放资源的局限

写普通表单或静态列表页时,我们习惯在 initState(或状态管理器的 onInit)里初始化,在 dispose(或 onClose)里做清理。但一旦页面持有播放器解码、麦克风采集、摄像头扫码或者系统屏幕方向锁定这类独占型重资源,单靠销毁回调就会漏掉至少三种常见路径:

用户操作路径                             Widget 是否销毁   实际是否仍应占用音视频与横屏
--------------------------------------------------------------------------------------
1. Navigator.pop 返回上一页              是 (dispose)      否
2. Navigator.push 进入子设置页覆盖当前页   否 (仍在路由栈底)  否(已被遮挡,且子页需恢复竖屏)
3. 底部导航栏切到其他保活 Tab             否 (IndexedStack) 否(页面隐藏但实例常驻)
4. 按 Home 键或锁屏使 App 退至后台        否                否(除非产品明确要求后台播放)

尤其是全屏页的横屏锁定:SystemChrome.setPreferredOrientations 修改的是整个宿主窗口的方向策略,而不是某一个路由私有的属性。如果把“锁横屏”写在进入页面时、把“恢复竖屏”写在 dispose 里,那么只要用户从全屏视频页 push 进二级设置页,全屏页因为仍留在栈底没有销毁,恢复方向的代码不会执行,新推入的普通页面也会随之保持横屏。

2. 基于「有效前台可见性」的资源启停管理

处理这类问题时,可以将重资源的启停从组件销毁生命周期改为绑定到页面的有效前台可见性(Effective Visibility)。只有当下面三个条件同时成立时,页面才算处于前台可见状态:

  1. 路由栈顶可见:当前路由没有被后续 push 出来的 PageRoute 覆盖(由 RouteAware 追踪);
  2. 所在 Tab 激活:如果页面嵌在保活的多 Tab 容器里,当前选中的正好是它所在的 Tab;
  3. 应用处于前台:AppLifecycleState 为 resumed(由 WidgetsBindingObserver 追踪)。

三者求逻辑与(isRouteTop && isTabActive && isAppForeground),只要任意一项从 true 变成 false,就立即执行一次幂等的暂停与清理;当三项重新同时回到 true 时,再按离开前记录的状态恢复会话。

3. 最小可复用实现:RouteAware + 前后台与方向管理

使用 RouteAware 前,需要先在根部的 MaterialApp(或实际承载该页面的嵌套 Navigator)上注册一个全局 RouteObserver<ModalRoute<void>>:

final RouteObserver<ModalRoute<void>> appRouteObserver =
    RouteObserver<ModalRoute<void>>();

随后在视频页 State 中合成三个维度的可见性,并把“进入可见态”和“离开可见态”明确拆成对称的两个方法:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class LiveVideoPage extends StatefulWidget {
  const LiveVideoPage({
    super.key,
    this.isTabActive = true,
    this.hostOrientations = const [DeviceOrientation.portraitUp],
  });

  /// 若嵌在 IndexedStack 等保活 Tab 中,由父容器传入当前 Tab 是否选中;独立路由页默认为 true。
  final bool isTabActive;

  /// 离开全屏视频页时应恢复的宿主方向策略,避免固定为竖屏后影响平板等横竖自适应宿主。
  final List<DeviceOrientation> hostOrientations;

  @override
  State<LiveVideoPage> createState() => _LiveVideoPageState();
}

class _LiveVideoPageState extends State<LiveVideoPage>
    with RouteAware, WidgetsBindingObserver {
  bool _isRouteTop = true;
  bool _isAppForeground = true;
  bool _sessionActive = false;
  bool _shouldResumePlay = true;

  bool get _effectiveVisible =>
      _isRouteTop && widget.isTabActive && _isAppForeground;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final route = ModalRoute.of(context);
    if (route != null) {
      appRouteObserver.subscribe(this, route);
    }
    _syncSession();
  }

  @override
  void didUpdateWidget(covariant LiveVideoPage oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.isTabActive != widget.isTabActive) {
      _syncSession();
    }
  }

  // --- RouteAware:感知当前路由是否被覆盖或重新回到栈顶 ---
  @override
  void didPush() {
    _isRouteTop = true;
    _syncSession();
  }

  @override
  void didPopNext() {
    _isRouteTop = true;
    _syncSession();
  }

  @override
  void didPushNext() {
    _isRouteTop = false;
    _syncSession();
  }

  @override
  void didPop() {
    _isRouteTop = false;
    _syncSession();
  }

  // --- WidgetsBindingObserver:感知 App 退后台与回前台 ---
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    _isAppForeground = state == AppLifecycleState.resumed;
    _syncSession();
  }

  void _syncSession() {
    if (_effectiveVisible && !_sessionActive) {
      _sessionActive = true;
      _resumePageSession();
    } else if (!_effectiveVisible && _sessionActive) {
      _sessionActive = false;
      _pausePageSession();
    }
  }

  void _resumePageSession() {
    SystemChrome.setPreferredOrientations(const [
      DeviceOrientation.landscapeLeft,
      DeviceOrientation.landscapeRight,
    ]);
    if (_shouldResumePlay) {
      // 启动或恢复底层视频拉流
    }
  }

  void _pausePageSession() {
    // 1. 静默停止录像与双向对讲(不弹出用户主动点击停止时的 Toast 提示)
    // 2. 停止拉流解码,保留 _shouldResumePlay 供返回本页时判断
    // 3. 立即归还宿主原有的屏幕方向策略,不把横屏带进后续子页面
    SystemChrome.setPreferredOrientations(widget.hostOrientations);
  }

  @override
  void dispose() {
    if (_sessionActive) {
      _sessionActive = false;
      _pausePageSession();
    }
    appRouteObserver.unsubscribe(this);
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const SizedBox.expand();
}

4. 实际接入时的三个细节与适用边界

在实际接入这套管理逻辑后,还有三处细节直接影响交互体验:

  1. 页面失焦触发的自动停止应保持静默:用户主动点击“结束录像”或“挂断对讲”按钮时,弹 Toast 提示“录像已保存至相册 / 对讲已结束”是合理反馈;但如果用户只是点进右上角设置页改个设备名,触发 didPushNext 自动停止时若也弹出一串提示框,交互就会显得突兀。把“用户主动操作”与“生命周期自动停止”分开传参可以避免这类打扰。
  2. 首页预览卡片与全屏页保持一致语义:如果首页小窗预览定义为“切走即停、不后台拉流”,全屏页进二级设置时也应遵循同一规则停流,返回全屏页后再按离开前状态自动续播,避免不同页面各自维护一套相冲突的保活逻辑。
  3. 留意 RouteObserver 的覆盖范围边界:
    • 若使用的是 RouteObserver<PageRoute<dynamic>>,半透明弹窗(DialogRoute / ModalBottomSheetRoute)覆盖在视频页上方时不会触发全屏页的 didPushNext——这在弹出清晰度选择菜单时往往正是我们想要的(弹窗期间底层画面继续播);若用泛型更宽的 ModalRoute 观察器,弹窗也会触发失焦,选型时需按实际交互区分。
    • 多窗口分屏、画中画(PiP)、系统来电打断音频焦点以及 iOS 端的 AVAudioSession 类别切换,不属于单纯的 Flutter 路由可见性范畴,仍需在原生音频会话层单独验证。