多窗口模式下的 WindowManager:分屏、自由窗口、画中画模式下的窗口布局与调整逻辑
多窗口模式,说白了就是让多个应用同时出现在屏幕上。这在 Android 里可不是简单的「堆叠」就能搞定的。我刚开始接触这部分代码时,第一反应是:这不就是把窗口缩小一点吗?后来才发现,这里面的门道深着呢。
Android 的多窗口模式分三种:分屏、自由窗口、画中画。每种模式下,WindowManager 的布局逻辑都不一样。咱们一个一个来看。
分屏模式:上下或左右,各占一半
分屏模式最常见。用户把屏幕一分为二,两个应用各占一边。WindowManager 在这里的核心任务,就是计算两个窗口的边界。
我记得在 Android 10 上调试过一个 bug:分屏时,上面的应用总是多占 1 像素。查了半天,发现是 DisplayCutout 区域的计算没处理好。嗯,这里要注意,分屏模式下,状态栏、导航栏、刘海区域都会影响窗口的可用空间。
分屏布局的核心逻辑在 TaskStackLayoutAlgorithm 里。简单来说,就是先算出屏幕的可用区域,然后根据用户拖动的分割线位置,算出两个窗口的 bounds。
// 伪代码:分屏布局计算
Rect displayBounds = getDisplayBounds();
Rect topBounds = new Rect(0, 0, displayBounds.width(), splitPosition);
Rect bottomBounds = new Rect(0, splitPosition, displayBounds.width(), displayBounds.height());
这里有个坑:分割线的最小高度。我曾经遇到过一个 case,用户把分割线拖到最顶部,结果上面的应用只剩 20 像素高,根本没法用。后来系统加了最小高度限制,一般是 240dp 左右。
关键点:分屏模式下,WindowManager 会为每个窗口创建一个独立的 Task,每个 Task 有自己的 WindowContainer。窗口的 resize 操作,本质上是修改 Task 的 bounds。
自由窗口模式:想放哪就放哪
自由窗口模式,说白了就是桌面电脑上的窗口。用户可以随意拖动、缩放。这个模式在平板上用得比较多。
自由窗口的布局逻辑比分屏复杂得多。WindowManager 需要处理:
- 窗口的初始位置和大小
- 拖动时的边界约束
- 缩放时的最小/最大尺寸
- 窗口之间的层级关系
我个人习惯把自由窗口的布局逻辑分成三步:
- 计算可用区域:排除状态栏、导航栏、输入法区域
- 计算窗口位置:根据用户手势或预设规则,确定窗口的左上角坐标
- 计算窗口大小:确保窗口不小于最小尺寸,不超过最大尺寸
你想想看,如果用户把窗口拖到屏幕边缘,会发生什么?嗯,系统会触发「贴边」效果,窗口自动吸附到边缘。这个逻辑在 DragResizeMode 里实现。
避坑指南:我曾经在自由窗口模式下遇到过窗口「跑出屏幕」的问题。原因是 WindowState 的 mAttrs.x 和 mAttrs.y 没有做边界检查。后来我在 relayoutWindow 里加了 sanitizeWindowPosition 方法,确保窗口始终在屏幕内。
画中画模式:小窗口,大讲究
画中画模式,就是视频播放器缩小成一个悬浮小窗口。这个模式在 Android 8.0 引入,现在已经是视频类应用的标配了。
画中画窗口的布局逻辑很特殊:
- 窗口大小固定(一般是 240x135dp 或 320x180dp)
- 窗口位置可拖动,但只能放在屏幕的四个角落
- 窗口层级最高,始终在其他应用之上
我记得在 Android 12 上,画中画窗口新增了「双指缩放」功能。用户可以用手势调整窗口大小。这个功能的实现涉及 PinchResizeGestureHandler,它会实时计算新的窗口尺寸,然后调用 resizePip 方法。
// 画中画窗口的尺寸计算
private void resizePip(float scale) {
int newWidth = (int) (mBaseWidth * scale);
int newHeight = (int) (mBaseHeight * scale);
// 确保不超过最大/最小尺寸
newWidth = Math.max(mMinWidth, Math.min(mMaxWidth, newWidth));
newHeight = Math.max(mMinHeight, Math.min(mMaxHeight, newHeight));
// 更新窗口 bounds
mPipBounds.set(mPipBounds.left, mPipBounds.top,
mPipBounds.left + newWidth, mPipBounds.top + newHeight);
}
注意事项:画中画窗口的 WindowManager.LayoutParams 必须设置 TYPE_APPLICATION_OVERLAY 类型,并且 flags 要包含 FLAG_NOT_FOCUSABLE 和 FLAG_NOT_TOUCH_MODAL。否则,画中画窗口会拦截触摸事件,导致底层应用无法操作。
三种模式的布局对比
为了让你更直观地理解,我整理了一个对比表格:
| 特性 | 分屏模式 | 自由窗口模式 | 画中画模式 |
|---|---|---|---|
| 窗口数量 | 2 个 | 多个 | 1 个 |
| 窗口大小 | 动态调整 | 自由调整 | 固定/缩放 |
| 窗口位置 | 固定区域 | 任意位置 | 四个角落 |
| 层级关系 | 同级 | 可调整 | 最高层 |
| 核心类 | TaskStackLayoutAlgorithm | DragResizeMode | PipManager |
多窗口布局的核心数据结构
不管哪种模式,WindowManager 都依赖一套统一的数据结构来管理窗口布局。我画了一张图,帮你理清这些概念之间的关系。
这张图展示了从 Display 到 WindowState 的层级关系。你想想看,多窗口模式下,每个窗口其实就是一个 Task,而 Task 的 bounds 决定了窗口的位置和大小。WindowManager 的布局调整,本质上就是修改这些 bounds。
布局调整的触发时机
布局调整不是随时都在发生的。我总结了一下,主要有这几个触发时机:
- 用户手势:拖动分割线、拖动窗口、缩放窗口
- 配置变更:屏幕旋转、分辨率变化、density 变化
- 系统事件:输入法弹出/隐藏、导航栏显示/隐藏
- 应用请求:应用调用
ActivityOptions.setLaunchBounds()
每次触发布局调整,WindowManager 都会走一遍 performLayout 流程。这个流程会遍历所有窗口,重新计算它们的 bounds,然后更新 Surface 的位置和大小。
性能优化建议:布局调整是高频操作,尤其是用户拖动窗口时。我建议在 WindowState 里缓存上次的 bounds,如果新 bounds 和缓存一致,就跳过 Surface 更新。这样可以减少不必要的跨进程调用。
总结
多窗口模式下的 WindowManager,核心就是「布局计算」和「布局调整」。分屏模式关注分割线的位置,自由窗口模式关注窗口的边界约束,画中画模式关注窗口的固定尺寸和角落位置。不管哪种模式,底层都依赖 Task 和 WindowContainer 这套数据结构。
嗯,这部分内容确实有点绕。我当年啃源码时,也是反复看了好几遍才理清楚。不过一旦理解了核心逻辑,你会发现多窗口的实现其实很优雅——它只是把单窗口的布局逻辑,扩展到了多个窗口而已。