沉浸式图片预览返回后,Android 系统栏怎么就是不回来?

最近遇到一个挺让人头疼的问题:在 Flutter 应用里,从详情页打开图片全屏预览,再点击进入沉浸模式,退出后状态栏和导航栏有时候会一直不恢复。

用户路径很简单:详情页 → 全屏图片查看器 → 沉浸式预览 → 返回 → 再返回。Flutter 侧的路由 pop 正常,SystemChrome 的调用看起来也都在该调的位置,但实机上系统栏就是不出来。

这个 bug 的欺骗性很强——表面上一切正常,没有 crash,没有明显的异常日志,单纯加几次恢复调用或者延迟调用也解决不了问题。最终发现,这是应用层状态所有权问题Flutter 引擎回归问题两层原因叠加导致的。

最初的实现为什么不够用

最早的做法是让图片查看器页面自己直接管理 SystemChrome

  • 进入普通全屏时设置 edgeToEdge
  • 点击图片进入沉浸模式时设置 immersiveSticky
  • 返回前和 dispose 时再切回 edgeToEdge

这个实现看起来完整,但存在结构性缺陷。viewer、应用主题、路由生命周期都可能直接写入同一个进程级的系统 UI 状态,没有明确的单一所有者。而且 SystemChrome 是异步平台通道调用,dispose 里的恢复、返回前的恢复,以及可能晚到的沉浸调用之间存在竞态条件。

尝试过在多个生命周期节点加兜底恢复,也在 app resume 时强制恢复,但实机上依然会复现。问题不在于“有没有调用恢复”,而在于谁最终拥有 system UI 的状态,以及调用顺序是否真正确定。

建立单一真相源:SyzygySystemBarsController

后来引入了一个进程级的 SyzygySystemBarsController,把系统 UI 的呈现抽象成三种明确状态:

  • app(应用正常状态)
  • imageViewerChrome(查看器带 chrome 的状态)
  • imageViewerImmersive(纯沉浸状态)

图片查看器不再直接操作平台状态,而是持有一个类型化的 SyzygySystemUiSession

  • beginImageViewer 创建当前会话
  • setChromeVisible 表达是显示 chrome 还是进入沉浸
  • release 结束会话,恢复应用层最新的 overlay 样式

Controller 内部使用串行操作队列、session id 和操作 epoch,保证:

  • 同一时刻只有一个权威 owner 决定系统栏呈现
  • 已释放或被新会话替代的旧操作无法覆盖当前状态
  • 快速切换、快速 pop 等场景下依然能按确定顺序收敛

这一步解决了应用层的竞态问题。但实机上仍然会出现“最后一次 Dart 调用已经是 edgeToEdge,系统栏却依然隐藏”的情况。这说明问题已经不在应用层状态机了。

Flutter 3.44.0 的 Android Engine 回归

使用的 Flutter 版本是 3.44.0 stable,对应 engine revision 4c525dac5e。经过比对,发现这个问题和上游 issue #186723 完全一致:

immersiveSticky 切换到 edgeToEdge 后,Android 旧的 systemUiVisibility flags 没有被清理,导致即使 Dart 侧请求了 edgeToEdge,系统栏依然保持隐藏状态。

上游的修复(PR #187207)是在进入 edge-to-edge 之前,先执行:

activity.getWindow().getDecorView().setSystemUiVisibility(0);

把遗留的沉浸模式 flags 清掉,再应用新的 WindowInsets 配置。

最终采用的方案

最终方案同时处理了两个层面:

1. 应用层单一所有权 + 显式状态转换

UI 页面只负责表达意图,不直接维护平台状态。Controller 成为唯一真相源,所有平台调用都通过串行队列执行。

2. 针对 Flutter 3.44 Android 回归的精确 workaround

仅当以下三个条件同时满足时才执行重置逻辑:

  • 当前平台是 Android
  • 上一个状态是 imageViewerImmersive
  • 下一个目标是 edgeToEdge

确定的调用顺序是:

immersiveSticky
  → SystemUiMode.manual + [top, bottom]
  → SystemUiMode.edgeToEdge
  → 恢复目标 SystemUiOverlayStyle

中间的 manual + [top, bottom] 这一步会强制 Android 重新显示两个系统 overlay,从而清理沉浸模式遗留的隐藏 flags。之后再进入应用正常使用的 edgeToEdge 状态。

这个 workaround 被严格限制在精确的 transition 上,不会影响普通主题切换、未进入沉浸的路由,以及 iOS 等其他平台。

路由释放合同

还调整了路由生命周期:在用户主动返回时,先 await session.release(),确认恢复操作已进入串行队列并完成,再执行 Navigator.popdispose 保留幂等释放作为兜底,避免重复释放产生多余的状态转换。

验证方式

建立了四类自动化合同测试:

  • Android platform channel 调用顺序断言
  • viewer 路由恢复后的最终状态断言
  • 从真实 history 文件入口的完整双返回路径测试
  • 故意阻塞沉浸调用后立即返回的竞态测试

实机验证时,在 Android 测试机上连续执行完整路径 10 次,每次状态栏和导航栏都能立即恢复,且后续返回上层页面时状态保持正确。

为什么这个方案比较可靠

  • 单一真相源:只有 Controller 拥有可变的 system UI 状态
  • 显式状态模型:app、viewer chrome、viewer immersive 是互斥的,而不是靠布尔值推断
  • 串行副作用:所有平台调用按确定顺序执行
  • 精确的平台适配:workaround 只处理已确认的 engine 问题,不向业务层泄漏平台细节
  • 可测试的恢复顺序:不仅验证最终结果,还验证 native method channel 的调用序列

当未来升级 Flutter,确认 engine 回归已修复后,可以移除 workaround,但 Controller、Session 和串行所有权机制会继续保留,因为它们解决的是独立的应用层问题。

这个 bug 也提醒,在涉及系统 chrome 和 insets 的跨平台代码里,经常需要同时管理好上层状态机和底层的 native 行为。


参考资料