沉浸式图片预览返回后,Android 系统栏怎么就是不回来?
沉浸式图片预览返回后,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.pop。dispose 保留幂等释放作为兜底,避免重复释放产生多余的状态转换。
验证方式
建立了四类自动化合同测试:
- 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 行为。
参考资料
- Flutter issue #186723:https://github.com/flutter/flutter/issues/186723
- Flutter fix PR #187207:https://github.com/flutter/flutter/pull/187207
- Flutter edge-to-edge breaking change:https://docs.flutter.dev/release/breaking-changes/default-systemuimode-edge-to-edge