摘要
在 Jetpack Compose 开发中,我们遭遇了一个极其反常的性能问题:Activity A 打开 Activity B,但关闭 B 返回 A 时,Activity B 的 onStop() 生命周期会精确延迟 10 秒。这个现象并非 B 页面本身的性能问题,而是 Resuming Activity A 上Compose布局的 fillMaxHeight 配合复杂 pointerInput 逻辑,在窗口切换的敏感时刻,触发了 Android WindowManagerService (WMS) 的 输入分发超时。本文将从 WMS 视角、输入事件流(ACTION_CANCEL)和设备碎片化三个维度,彻底剖析这个底层陷阱,并给出兼顾功能与性能的结构分层解决方案。
🔍 问题现象:生命周期错位与精确的 10 秒延迟
我们的应用包含一个可展开至全屏的 Compose 组件,其关键特性是使用 Modifier.fillMaxHeight() 配合 detectVerticalDragGestures 实现全屏手势。
异常流程复现与关键观察:
- Activity A(包含问题组件) 打开 Activity B。
- 用户关闭 Activity B(例如按返回键)。
- 系统尝试将焦点转回 Activity A。
- Activity B 的
onStop()延迟 10 秒才被执行。
本质: onStop 的延迟证明了整个 Activity 切换的事务被系统锁死。这个锁死是由于 WMS 在交接输入焦点给 Resuming Activity A 时,遇到了一个不可逾越的障碍。
💣 根本原因:fillMaxSize 制造的 WMS 输入屏障
延迟的根源不在于 Compose 渲染缓慢,而在于 WMS 判断 Activity A 的输入状态处于高风险的“非静止”状态。
1. 全屏 Hit Test 边界与输入通道占有
- 全屏放大器:
fillMaxHeight使得 Activity A 上的这个组件在输入分发层面上成为了一个全屏的、活跃的输入接收者(Hit Test Boundary)。 - WMS 视角: WMS 试图将输入焦点和通道转移回 A。它看到 A 的整个屏幕都被一个复杂的
pointerInput逻辑占据。
2. ACTION_CANCEL 信号的阻塞
当 Activity B 关闭时,WMS 应立即向所有可能持有输入的组件发送一个 ACTION_CANCEL 信号,要求它们清理手势状态。
- 用户假设验证: 我们的排查验证了您的假设:延迟的本质就是
ACTION_CANCEL信号被延迟发送/处理。 - 机制: WMS 认为如果它立即将输入通道交给 A,可能会与 A 的全屏手势逻辑产生冲突。WMS 必须等待 Activity A 的输入通道处于安全状态。
- 结果: WMS 进入等待状态。由于 A 的全屏覆盖没有迅速释放,WMS 无法继续流程,导致整个切换事务被卡住,直到触发了系统默认的输入分发超时(10 秒) 。超时后,WMS 暴力完成切换,Activity B 的
onStop才得以执行。
3. 为什么是部分手机才有问题?(系统碎片化)
这个问题之所以具有设备依赖性,是因为它发生在 Android 底层的 WMS 框架中,而这部分代码被厂商深度定制。
| 因素 | 影响机制 |
|---|---|
| 厂商 ROM 定制 | 不同厂商 (如 MIUI, One UI) 对 WMS 的 输入分发逻辑 和 Window 焦点转移策略 有不同实现。某些 ROM 对全屏透明覆盖物的处理更严格或更保守,更容易触发其内置的超时检查。 |
| 系统性能与负载 | 10 秒是超时阈值。在 高性能 设备上,主线程可以在几百毫秒内完成所有必要的计算和清理,成功避免超时。在 中低端或高负载 设备上,主线程处理 WMS 请求和 Compose 布局计算的时间被拉长,超过了系统阈值,从而引爆 10 秒延迟。 |
结论: 我们的代码制造了高风险的“定时炸弹”,而不同设备的 ROM 和性能决定了这颗炸弹是否会爆炸。
🛠️ 最终解决方案:功能与性能兼得的“分层设计”
要彻底修复问题,必须在保持全屏视觉和手势功能的同时,解除主内容组件的输入限制。
核心思路:将全屏输入逻辑从主内容 Box 中剥离出来。
结构修改回顾
| 组件 | 修改前 (问题所在) | 修改后 (修复) | 目的 |
|---|---|---|---|
| 主内容 Box 高度 | Modifier.fillMaxHeight() | Modifier.wrapContentHeight() | 缩小 Hit Test 区域,解除 WMS 的顾虑。 |
| 全屏手势 | 嵌套在主 Column 内 | 移至独立的背景 Box (fillMaxSize().pointerInput(...)) | 隔离手势逻辑,使其不干扰主内容测量和焦点状态。 |
| 主内容 Column | Modifier.fillMaxSize() | Modifier.fillMaxWidth().wrapContentHeight() | 确保主内容只占据所需空间,不参与全屏输入竞争。 |
通过这种结构分离的方式,我们消除了 WMS 发生超时的根源,确保了 Activity A 在恢复时能够迅速且安全地接管输入焦点,从而让 Activity B 的 onStop 得以及时执行。
💡 最佳实践与启示
这个案例是一个宝贵的经验:在 Jetpack Compose 中,当处理涉及 Activity/Window 切换的复杂 UI 元素时,请遵循以下原则:
- 最小化 Hit Test 区域: 除非绝对必要,否则不要将复杂的
pointerInput逻辑应用于fillMaxSize或fillMaxHeight的组件。 - 分层处理输入: 将视觉覆盖、简单点击和复杂手势分置于不同的
Box或Modifier链中。 - 优先使用标准组件: 对于全屏覆盖,优先考虑使用
Dialog或Popup,它们会创建单独的 Window,从根本上隔离了与父 Activity 生命周期和输入通道的冲突。