自适应布局、桌面窗口与多形态设备性能
自适应布局响应的是运行时窗口的可用空间、折叠姿态和输入方式;设备名称最多用来概括使用场景。桌面窗口、折叠姿态切换、旋转,都会在应用运行中改变这块空间,按设备类别预设的布局到了这些场景就会判断错误。本文讨论自适应布局、桌面窗口和多形态设备下的性能问题,做法上我们主要抓两条:把尺寸决策集中到页面入口,并限制重组与重新布局的范围。
版本范围与源码依据
自适应界面(Adaptive UI)依据当前应用窗口的可用空间、折叠姿态和输入方式调整布局。同一台设备上,窗口的实际类别可以差得很远:平板上开一个分屏窗口,宽度可能落到紧凑档;手机连接桌面显示器,反而可能是大档甚至超大档。排查布局问题时,我们以窗口的实际测量为准。
本文采用这些版本:
| 层级 | 版本依据 | 说明 |
|---|---|---|
| Android 平台 | Android 17 / API 37 / android-17.0.0_r1 | ViewRootImpl、WindowManager、WM Shell、BLAST、SurfaceFlinger 与 HWC 按该源码标签核查 |
| Android 内核 | android17-6.18-2026-06_r6 | 调度、cpuset 与 dma-fence 只说明公共内核机制,不推断厂商 DPU/GPU 实现 |
| Compose UI | 示例沿用兼容 API;2026-10-25 当前稳定版为 1.12.1 | Compose 独立发布,运行时 API 编译复核使用 1.12.0,不由 API 37 推导 |
| Material 3 Adaptive | 本文固定基线 1.2.0;当前稳定版 1.3.0 | 示例和源码结论仍按 1.2.0;Large / Extra-large 需要显式开启 |
| Jetpack WindowManager | 1.5.1 稳定版 | WindowInfoTracker、FoldingFeature 与 WindowMetricsCalculator 按这一版说明 |
2026-10-25 复核,Material 3 Adaptive 1.3.0 已是稳定版,1.3.0-rc01 只是此前的候选发布物;部分窗口信息 API 已调整。生产项目若采用 1.3.x,应按该版本的迁移说明改写调用;反过来说,1.3.x 里标记弃用的 API,在 1.2.0 中仍可能是正确用法。
一、窗口尺寸类别按当前窗口计算
1. 宽度有五档,高度有三档
当前官方窗口分类如下:
| 分类 | 当前窗口可用尺寸 |
|---|---|
| 紧凑(Compact)宽度 | width < 600dp |
| 中等(Medium)宽度 | 600dp ≤ width < 840dp |
| 扩展(Expanded)宽度 | 840dp ≤ width < 1200dp |
| 大(Large)宽度 | 1200dp ≤ width < 1600dp |
| 超大(Extra-large)宽度 | width ≥ 1600dp |
| 紧凑(Compact)高度 | height < 480dp |
| 中等(Medium)高度 | 480dp ≤ height < 900dp |
| 扩展(Expanded)高度 | height ≥ 900dp |
宽度决定导航形态、窗格(pane)数量和信息密度,高度同样要看。我们最容易忽略的就是高度:横屏手机或桌面上的矮窗口,可能宽度已经到中等或扩展档,高度却还在紧凑档。这时只看宽度就机械地切成双窗格,触控区域、列表和详情内容都会被压缩。

图:宽度适合决定上层信息密度,高度Compact的横屏或矮窗口仍需单独布局策略。
Material 3 Adaptive 1.2.0 对宽度分类和窗格数量有几处默认行为:
currentWindowAdaptiveInfo()默认仍按 Compact、Medium、Expanded 三档计算;传入supportLargeAndXLargeWidth = true后,计算才会包含 1200dp 与 1600dp 两个断点。calculatePaneScaffoldDirective()生成窗格布局指令:Compact 和 Medium 默认允许一个横向分区,Expanded 允许两个,Large 和 Extra-large 最多允许三个。calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth()会让 Medium 使用双窗格,但源码文档也提醒,这可能使内容过密,只适合确有需求的页面。- 上述横向分区数是最大布局指令,窗格是否都可见,还要看目的地历史、适配策略、铰链和局部约束的实际选择。1.2.0 在 tabletop 姿态,或单横向分区且 Expanded 高度时,还会允许两个纵向分区;读到“一个横向分区”时按横向分区上限理解即可,窗格总数以实际布局为准。
2. 类别切换与连续调整是两种频率
再看调整窗口的频率。拖动自由窗口时,原始约束可能连续变化;窗口尺寸类别却只在跨过 600、840、1200、1600dp 这些断点时才变。上层页面模式因此只有少数几个离散状态,但断点范围内该付的布局成本一点没有少:
- 父约束每次变化都可能触发 Compose 测量、布局,或 View 的遍历(traversal);
- 文本宽度、行数、Lazy 容器的可见项、图片目标尺寸和系统占用区域都可能在同一尺寸类别内变化;
remember保存计算结果或对象身份;约束变化引发的重新测量,它拦不住;derivedStateOf只适合把高频可观察输入转换成变化较少的派生状态,普遍加速窗口尺寸调整超出了它的能力范围。
窗口尺寸类别我们只在页面级决策里用。组件要排下按钮、图表或卡片内容,靠的是它自己收到的约束,整个窗口类别不必逐层传到每个子组件。
3. 系统栏和窗口装饰仍占用内容空间
再往下扣一层:窗口到了 Expanded 宽度,也保证不了每个窗格都拿到设计中的最小宽度,中间还隔着系统占用。桌面窗口标题栏(caption)、状态栏、导航栏、显示缺口和输入法(IME)都会先吃掉一块内容区域;边到边(edge-to-edge)页面应在统一位置处理 WindowInsets,否则页面布局容器、窗格和子组件各自加一遍内边距,很容易重复扣除。
LocalWindowInfo.current.containerSize、平台 WindowMetrics、布局阶段得到的内容约束,这是三套不同层次的尺寸。页面模式可以由窗口级信息决定;具体窗格怎么排,以它自己的测量约束为准。三者混用又不注明坐标空间,就容易重复扣除 Insets,或者把窗口尺寸当成内容尺寸。
二、把自适应决策集中在页面入口
1. 各依赖使用自己的版本号
先把依赖版本钉住,后面讨论入口写法时才有共同的参照。这些依赖用于 Compose 的典型列表—详情布局,以及直接读取折叠特征。三个 Material 3 Adaptive 依赖模块保持同一版本,WindowManager 单独指定版本。
dependencies {
implementation("androidx.compose.material3.adaptive:adaptive:1.2.0")
implementation("androidx.compose.material3.adaptive:adaptive-layout:1.2.0")
implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.2.0")
implementation("androidx.window:window:1.5.1")
}
版本目录或 Compose BOM(统一管理 Compose 依赖版本的物料清单)可以替代这里的硬编码版本,但构建产物仍要能还原最终解析到的依赖模块版本。平台 API 37、Compose UI、Material 3 Adaptive 各有自己的版本号,一个“Android 17 版本”标签覆盖不了三者,排查时要分别对版本。
2. 一个页面只计算一次上层布局指令
我们看稳定版 1.2.0 的列表—详情入口。它显式启用五档宽度分类,并把同一个 PaneScaffoldDirective 同时交给导航器和窗格布局;ArticleList 与 ArticleDetail 是页面自己的内容组件。
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveArticleRoute(
articles: List<ArticleSummary>,
) {
val adaptiveInfo =
currentWindowAdaptiveInfo(
supportLargeAndXLargeWidth = true,
)
val directive = calculatePaneScaffoldDirective(adaptiveInfo)
val navigator =
rememberListDetailPaneScaffoldNavigator<Long>(
scaffoldDirective = directive,
)
val coroutineScope = rememberCoroutineScope()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ArticleList(
articles = articles,
onArticleClick = { articleId ->
coroutineScope.launch {
navigator.navigateTo(
pane = ListDetailPaneScaffoldRole.Detail,
contentKey = articleId,
)
}
},
)
}
},
detailPane = {
AnimatedPane {
ArticleDetail(
articleId = navigator.currentDestination?.contentKey,
)
}
},
)
}
rememberListDetailPaneScaffoldNavigator() 用可保存状态记录目的地历史,泛型内容键(key)必须能写入 Android 的状态容器 Bundle。示例使用 Long,完整业务对象就不进保存状态。布局指令随窗口信息变化时,导航器会接收新值;列表数据和选中内容仍由 ViewModel、数据仓库或其他页面状态持有。

图:示例Long内容键可保存,完整业务对象留给ViewModel或数据层;库指令只负责摆放策略。
AnimatedPane 提供窗格进入、退出和适配动画,但图片解码、数据库查询、复杂内容组合的成本,它管不到。拖动窗口时如果动画和资源加载叠在一起,先把两者分开测量,再根据产品设计缩小动画范围,或停用不必要的过渡。
3. BoxWithConstraints 只处理组件级约束
BoxWithConstraints 基于 SubcomposeLayout:先拿到父级约束,再据此组合子内容。内容读的是 maxWidth、maxHeight 这些约束,约束一变,这些内容可能重新组合并再次测量。所以它适合卡片、工具栏、图表这类局部组件;页面层层都套一层,就等于层层追加这种二阶段组合和测量工作。
判断某个组件是否需要它,我们可以问三个问题:
- 分支是否只依赖这个组件收到的约束?
- 分支内是否执行了解析、排序、图片请求或其他不该处于组合阶段的工作?
- 父级已经给出明确模式时,子级能否直接接收枚举或数据,省去重复分类?
还要把它的开销和 Compose 已有的几类优化分开:强跳过(strong skipping)、稳定参数、Lazy 列表项键解决的是各自的问题——强跳过决定参数满足条件时能否省略一次可组合函数调用,稳定的键用于维持列表项身份;父约束变化后的测量和布局,强跳过和稳定的键都取消不了。
4. 窗格变化时要保留用户状态
窗格切换时还有一个容易漏的点:单窗格与双窗格往往使用不同的 Compose 调用位置,即使数据键相同,内容换到另一个调用位置也可能创建新的组合实例。所以滚动位置、草稿、筛选条件和导航选择都要有明确的状态所有者:
- 业务数据和选中项放在 ViewModel 或业务状态容器;
- 短期 UI 状态使用
rememberSaveable,并确认保存键与保存策略; - 多个窗格需要各自的滚动位置时,按窗格和内容 ID 保存;
- Lazy 列表项键使用不可变业务 ID,不使用索引或会变化的标题;
- 不因窗口尺寸类别变化重新发起同一网络请求或清空导航历史。
说“移动已有界面”只是视觉层面的描述。Compose 用槽位表(slot table)记录组合结构,调用位置一变,原来的组合实例不一定能复用。性能目标应写成可验证的状态保留、组合范围和帧时间。
三、折叠姿态信息的读取与使用
1. FoldingFeature 提供哪些信息
WindowManager 1.5.1 的 FoldingFeature 公开:
state:FLAT表示平坦展开,HALF_OPENED表示半开姿态;orientation:HORIZONTAL表示特征横向延伸,VERTICAL表示纵向延伸;occlusionType:NONE表示特征区域不遮挡内容,FULL表示其边界范围完全遮挡内容;isSeparating:显示特征是否把内容区域分成两个逻辑区域;bounds:该特征在当前窗口坐标空间中的边界。
这里要先交代一个限制:FoldingFeature 给出的是姿态和区域信息;产品若需要连续的铰链角度,必须用设备明确支持的其他 API,并单独处理能力探测、权限、采样频率和兼容性;HALF_OPENED 同样推导不出固定角度。
Material 3 Adaptive 的 WindowAdaptiveInfo 已包含由窗口折叠特征计算出的姿态对象 Posture。只做典型布局时,直接用这份信息就够了;等页面要处理遮挡区域、相机预览或设备特定交互时,再直接订阅 WindowInfoTracker。
2. 订阅必须跟随当前 Activity 生命周期
我们订阅折叠特征时,时机要跟着生命周期走。这段 Activity 代码只在生命周期达到 STARTED 后收集当前窗口的折叠特征,存进 Activity 自己的 StateFlow 状态流;每次开始收集前先清掉旧值,Activity 重建后,也会为新实例建立新的数据流订阅。
class FoldAwareActivity : ComponentActivity() {
private val _foldingFeature = MutableStateFlow<FoldingFeature?>(null)
val foldingFeature: StateFlow<FoldingFeature?> =
_foldingFeature.asStateFlow()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
_foldingFeature.value = null
try {
WindowInfoTracker
.getOrCreate(this@FoldAwareActivity)
.windowLayoutInfo(this@FoldAwareActivity)
.map { layoutInfo ->
layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
}
.distinctUntilChanged()
.collect { feature ->
_foldingFeature.value = feature
}
} finally {
_foldingFeature.value = null
}
}
}
}
}
windowLayoutInfo(activity) 首次发出数据的时机取决于设备实现,页面要允许暂时没有 FoldingFeature。它返回的数据流绑定当前 Activity 的窗口实例,不要缓存到跨 Activity 单例里长期复用。
![]()
图:不要把绑定当前窗口的Flow放入跨Activity单例;多特征时只取第一个是业务选择。
页面可以在 Compose 中按生命周期收集公开的 StateFlow;业务数据仍由 ViewModel 管理。一个窗口也可能报告多个显示特征,示例只取第一个是业务上的取舍,API 本身没有这样的全局保证。
3. 窗口姿态和内容状态分开
窗口姿态只决定内容怎么摆,决定不了内容是否存在:列表选择、表单输入、媒体位置、文档编辑状态,都要在 FLAT ↔ HALF_OPENED 切换时原样保留。据此可以把页面输入拆成两组:
| 状态 | 示例 | 推荐所有者 |
|---|---|---|
| 内容状态 | 选中 ID、草稿、滚动位置、加载结果 | ViewModel、SavedStateHandle、保存状态容器 |
| 布局状态 | 窗口尺寸类别、姿态、Insets、窗格布局指令 | 当前窗口/组合生命周期 |
这组区分在做性能分析时也有用:内容状态没变却出现大量业务计算,说明布局事件串进了数据处理;内容状态变了,才按业务更新链路去查。
四、调整窗口大小会同时改变应用内容和系统几何
1. 单窗口标准路径
我们先把单窗口的标准路径摆出来,后面拆几何、缓冲区和呈现时,每一站才对得上号。普通 View 或 Compose 页面仍走标准应用窗口路径:
Choreographer#doFrame → ViewRootImpl 遍历 → HWUI RenderThread → 窗口 BLAST 缓冲区 → SurfaceFlinger → 目标显示设备的 HWC → 呈现栅栏(present fence)。
这条链上每一站都有自己的职责:ViewRootImpl 遍历里做的是一轮测量、布局和绘制准备;BLAST 协调窗口缓冲区与 SurfaceControl 事务;SurfaceFlinger 决定 DEVICE / CLIENT 分工,CLIENT 部分可由 RenderEngine 在 GPU 上合成,再交 HWC(硬件合成器)参与最终呈现;一轮呈现结束时,显示设备发出呈现栅栏,作为本轮完成的同步信号。
约束变化可能让 Compose 重新组合、测量、布局、记录绘制命令,也可能只触发其中一部分。而向系统侧请求重新布局的动作由 ViewRootImpl.performTraversals() 发起:尺寸、可见性、Insets、LayoutParams、Surface 等条件满足时,它才通过 IWindowSession.relayout() 请求 WMS 重新布局;内容稳定的帧不会固定执行一次 relayout()。再往上,WMS 维护窗口状态,WM Shell 在系统界面进程中负责多窗口和转场等上层交互。
2. 窗口、进程和显示设备要分别分析
我们把窗口调整放到系统全景里看:这张图区分了应用内容缓冲区、WMS/WM Shell 维护的窗口几何状态,以及每个显示设备的呈现结果,同进程多窗口与跨进程窗口都覆盖了;屏幕上并排的两个窗格,不一定就是两个顶层窗口。
flowchart LR
subgraph PA["Process A"]
UIA["UI Looper / Choreographer"]
VRA["ViewRoot A"]
VRB["ViewRoot B when another top-level Window exists"]
RTA["Process HWUI RenderThread"]
UIA --> VRA --> RTA
UIA --> VRB --> RTA
end
subgraph PB["Process B"]
UIB["UI Looper / Choreographer"]
VRC["ViewRoot C"]
RTB["Process HWUI RenderThread"]
UIB --> VRC --> RTB
end
RTA --> BA["Independent BLAST buffer A/B"]
RTB --> BC["Independent BLAST buffer C"]
WMS["WMS / WM Shell
bounds, Insets, leash, transition"] --> TX["SurfaceControl geometry transactions"]
BA --> SF["SurfaceFlinger FrontEnd"]
BC --> SF
TX --> SF
SF --> D0["Display 0 visible layer set"]
SF --> D1["Display 1 visible layer set"]
D0 --> P0["HWC 0 / present fence 0"]
D1 --> P1["HWC 1 / present fence 1"]
顺着图往下读:同一 UI Looper 上的多个 ViewRootImpl 共享 Choreographer 回调队列,同进程的硬件加速窗口还共享进程级 RenderThread,但每个窗口有自己的 Surface 和缓冲区周转。进程之间呢?UI 线程和 RenderThread 各自独立,可只要落在同一显示设备上,SurfaceFlinger、GPU/内存带宽、HWC 硬件合成平面和显示截止时间就还是共享的。
所以判断窗口数量,要看事实而不是视觉形态。双栏 Compose/View 布局通常仍只有一个顶层窗口、一个应用窗口缓冲区;Activity Embedding 会在同一任务中嵌入多个 Activity;再叠加 Dialog、Popup、画中画(PiP)、自由窗口或多实例,顶层窗口到底有几个,必须根据实际的 pid、UI 线程编号(tid)、ViewRootImpl、WindowState、SurfaceFlinger 图层和 displayId 判断。
3. 窗口几何、缓冲区和显示呈现对应三个时间点
调整窗口大小或执行转场时,系统同时处理:
- 任务(Task)/窗口边界,以及转场控制 Surface(leash)的位置和裁剪等几何属性;
- 应用按新约束绘制的窗口缓冲区;
- SurfaceFlinger 为目标显示设备选择可见图层并完成本轮呈现。
过渡阶段可能出现新几何配旧缓冲区:系统会缩放旧内容,或用任务快照、启动窗口填补应用重绘的间隙。同步机制也分两侧:WMS 的 BLASTSyncEngine 可以等待已加入同步组的窗口容器(WindowContainer)完成绘制和事务;公开的 SurfaceSyncGroup 则面向应用或嵌入式 Surface。未加入同步组的相机、编解码器、渲染引擎这些生产者,时序仍要业务自行协调。
这里有一个常见的定位偏差:应用的 queueBuffer() 返回,只说明生产者(Producer)已经提交缓冲区。此刻 GPU 完成栅栏可能尚未发出信号,SurfaceFlinger 也可能还没采纳这个缓冲区,目标显示设备自然尚未完成呈现。所以排查窗口调整卡顿,别只盯 Compose 测量;反过来,应用帧都按时,也排除不了显示端。
4. 每个显示设备有自己的呈现结果
窗口一旦移到外接屏,密度、刷新率、色彩模式、Insets、HWC 能力和输入路由都可能跟着变。SurfaceFlinger 是按显示设备分别建立可见图层集合的,HWC 策略与呈现栅栏也各产各的。所以默认屏正常,证明不了外接屏正常;同一进程的两个窗口,也不要求在两个屏幕同时呈现。
公共分析依据到这里为止:内核源码标签可以解释线程调度、限制线程可用 CPU 范围的 cpuset,以及在设备间同步缓冲区访问的 dma-fence。某台设备的硬件叠加平面、DPU 缩放单元、GPU 调度或显示驱动延迟,还要靠厂商轨迹、设备配置和实际同步栅栏来说明。
五、Android 17 让固定方向假设失效
我们先交代这条行为变化的时间线。Android 16 的大屏设备上,面向 API 36 的应用会被系统忽略方向、宽高比和可调整大小限制;应用或单个 Activity 可以用清单属性 PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY 暂时恢复旧的兼容模式,但这个属性保证不了大屏设备锁定方向。到了 Android 17,面向 API 37 的应用连这个临时选项也没有了。
变化的适用范围是显示设备最小宽度达到 600dp 的大屏,也就是官方资料里的 sw600dp 及以上。在这类设备上,布局前提里要拿掉下面这些配置或调用:
screenOrientation的portrait/landscape系列固定值;setRequestedOrientation()请求的固定方向值;resizeableActivity;minAspectRatio与maxAspectRatio。
getRequestedOrientation() 读回的是请求值,当前窗口是否按该方向显示,还要以实际窗口和配置为准。
例外也要记全:游戏类别、低于 sw600dp 的屏幕,以及用户在设备宽高比设置中明确选择应用原有行为的情况,都是官方列出的例外。加上不同厂商的桌面窗口能力和窗口策略本身有差异,验收记录要写清设备与窗口模式。
渲染路径没有换:这项变化没有替换 ViewRootImpl、BLAST 或 SurfaceFlinger;变化在于应用会更常遇到旋转、自由调整窗口、分屏和多种宽高比。兼容工作集中在:
- 不以固定竖屏尺寸初始化导航、相机预览或画布;
- Activity 重建后恢复输入、选中项、滚动位置和导航历史;
- 自行处理配置变化时,逐项验证资源、Insets、密度和窗口状态更新;
- 相机预览使用传感器方向、目标旋转和
Matrix变换,不把设备自然方向当成窗口方向; - 动画起止位置按当前窗口边界计算,避免沿用启动时缓存的屏幕宽高。
六、大屏渲染成本的实际构成
1. 面板分辨率与实际渲染工作量
拿到一台 4K 屏,先别按 3840×2160 直接估算工作量:像素数是 1920×1080 的四倍,但窗口可能只占屏幕一部分,系统可能使用不同渲染尺度,HWC 也可能把部分图层交给硬件合成。为此应当记录:
- 当前窗口边界、密度和实际缓冲区尺寸;
- 可见区域与裁剪;
- 重叠绘制(同一像素在一帧内被多次绘制)、模糊、阴影、透明混合和离屏层;
- SurfaceFlinger 最终选择客户端合成(CLIENT)还是硬件合成(DEVICE);
- GPU、内存带宽和 HWC 限制。
我们也别忘了内容本身:同一页面在更大窗口中通常会显示更多列表项、文字、图片和窗格,节点数量和像素填充可能同时增加,也可能只增加一项。性能报告应使用 Perfetto 轨迹、GPU 工具和内存数据描述实际变化;“进入大屏就固定增加四倍成本”这类预设,代替不了实测。
2. 图片按展示目标请求
窗口一调整就同步解码最大尺寸图片,CPU、堆内存和主线程时间立刻被吃掉。图片管线应该做到:
- 根据目标展示尺寸请求合适采样结果;
- 把磁盘读取与解码移出主线程;
- 对快速变化的尺寸做请求合并或选择有限的尺寸档;
- 取消已离开可见区域的请求;
- 为内存缓存设定与设备、页面和并发窗口相符的上限;
- 区分缩略图、预览和原图,不在每个拖动像素上创建新原图请求。
窗口变大后要不要立刻加载更高分辨率的资源,取决于内容清晰度、网络与内存目标。可以等窗口尺寸稳定后再升级图片,也可以保留已有预览、异步替换;两种策略都要检查闪烁、峰值内存和无障碍缩放。
3. 更多窗格会扩大活跃工作集
窗格多了,页面当前仍保留并参与更新的状态、图片、订阅和界面节点——也就是活跃工作集——也会跟着变大。双窗格或三窗格可能同时保留列表、详情、辅助内容、导航与动画;Lazy 容器按需组合和测量,但预取、缓存窗口、动画或 beyond-bounds 等路径也可能保留 / 处理视口外项目;其余窗格的状态、图片和订阅更不会因此自动暂停。要检查:
- 不可见窗格是否仍在运行动画或定时刷新;
- 详情切换是否保留过多大图和文本布局;
- 多个窗格是否重复订阅同一个持续活跃的数据流,并执行相同转换;
- 预取范围是否因窗口变大而没有上限;
- 关闭窗格后,业务缓存和组合状态何时释放。
缓存策略要围绕可重复使用的数据与明确容量设计;窗口再大,容量上限该守还得守。
七、多窗口的生命周期与输入焦点
多窗口环境下,生命周期和焦点是两回事。多窗口支持多恢复(multi-resume):多个 Activity 可以同时处于 RESUMED,但只有一个是 top-resumed,也就是系统当前优先交互的那个;可见窗口也可能没有输入焦点。所以播放器、相机、动画、协作光标和轮询任务做降载决策时,我们要综合:
- 生命周期状态;
- 窗口可见性与遮挡;
- top-resumed 状态;
- 输入焦点;
- 独占硬件可用性;
- 用户是否要求继续播放、采集或同步。
两个极端都有代价:“失去焦点便停止全部工作”会中断仍需显示的内容,“只在 onPause() 降载”又可能让后台可见窗口持续高频刷新。策略应按业务类型定义,再到分屏切焦点、PiP、桌面多实例和外接屏里验证。
桌面窗口的多实例、拖拽与共享状态
进入桌面窗口化,同一应用会同时拥有多个任务(task),甚至跨显示器展示同一业务对象。清单属性 PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI 只允许系统界面提供“新窗口”入口,启动模式、路由、草稿冲突和数据库并发还是要由应用自己解决。
状态可以分成三层:
- 实例内的滚动、选择和窗格状态;
- 由数据仓库管理、带修订版本号和事务的共享业务状态;
- 可以共享、但不隐含“当前窗口”的进程级缓存与连接池。
再看拖放。跨窗口拖放只在回调中传递轻量的 ClipData、URI 和访问授权,MIME 类型校验、Bitmap 解码、缩略图生成与导入事务放到后台。Android 15 提供 DRAG_FLAG_GLOBAL_SAME_APPLICATION,允许拖放送到同 UID 且 targetSdk≥24 的可见应用窗口;DRAG_FLAG_START_INTENT_SENDER_ON_UNHANDLED_DRAG 会在没有窗口处理 drop 时请求系统启动提供的 IntentSender,但系统可按屏幕尺寸或窗口模式拒绝。ClipData.Item 必须包含指向 Activity 的不可变 IntentSender,service / broadcast 目标在这里不符合要求。这两个入口仍要重新过一遍任务路由和权限验证,大对象不要序列化进 Intent 或 Binder 调用。
同一进程的多个窗口各有 ViewRootImpl 和缓冲区周转,却可能共享 UI Looper 与进程级 RenderThread。后果是:窗口 B 自己的 DrawFrame 很短,也可能排在窗口 A 的长任务后面,错过显示截止时间。跨显示器时,displayId、密度、刷新率、色彩模式和每个屏幕的呈现结果要分别记录;连接或断开外屏、跨屏拖动、最大化或还原、输入焦点切换,都算独立用例。
桌面窗口还要覆盖鼠标悬停、滚轮、右键、键盘快捷键、Tab 焦点和窗口标题栏 Insets。精确指针参与模式 EngagementMode.PRECISE_POINTER 到 WindowManager 1.6.0-alpha05 才加入,1.5.1 稳定版里还没有它;描述稳定版的实现时,这项预览 API 要排除在外。
八、资源限定符与运行时分类的分工
我们先把资源限定符和运行时窗口分类的关系分清。sw<N>dp、w<N>dp、方向、密度这些限定符适合选择资源,但它们与运行时窗口分类并不完全等价:
sw<N>dp匹配当前 Configuration 的smallestScreenWidthDp,把它当成不可变的设备标签会出错。Android 17 对浮动任务可按任务窗口的较短边 / 密度重算它,嵌入式窗口也有更新路径,因此自由窗口调整时资源可能重选;w<N>dp更接近当前可用宽度,但资源重选仍经过 Configuration/Resources 路径;- Compose 窗口尺寸类别是运行时布局输入;
- 组件测量约束描述的是局部可用空间。
同一项目可以同时使用这些机制,但每一种只处理自己的层级:间距、最小窗格宽度和列数放在 values 资源中,页面结构由窗口尺寸类别和姿态决定,组件内部由约束完成排版。复制多份完整布局则会让维护与状态恢复难度上升,某个资源目录也容易长期无人测试。
检查资源时覆盖横竖屏、分屏、自由窗口、字体缩放、显示缩放、外接屏,以及不同语言和地区设置(locale);另外,图片资源目录只解决资源选择这一层,运行时的图片尺寸策略还要有它之外的输入。
九、视觉验证与帧性能分析
1. 尺寸测试组合覆盖断点两侧
先从尺寸组合入手,每个关键页面至少覆盖:
| 维度 | 建议样本 |
|---|---|
| 宽度断点 | 599/600dp、839/840dp、1199/1200dp、1599/1600dp |
| 高度断点 | 479/480dp、899/900dp |
| 窗口行为 | 旋转、分屏进入或退出、自由调整大小、最大化或还原 |
| 折叠行为 | FLAT、HALF_OPENED、分隔/遮挡特征、内外屏切换 |
| 显示拓扑 | 内屏、外接屏、跨屏移动、不同刷新率/密度 |
| 输入 | 触摸、鼠标、键盘、焦点切换、IME |
| 状态恢复 | Activity 重建、进程恢复、多实例、显示设备断开 |
Compose Preview、Compose UI Check、DeviceConfigurationOverride 和截图测试适合验证布局;完整显示路径上的帧时间——UI 线程、RenderThread、GPU、SurfaceFlinger 或 HWC——要在真实运行的设备上另测。
2. Macrobenchmark 只测试可重复的用户操作
哪些操作适合交给 Macrobenchmark?启动、滚动、窗格导航和预设尺寸切换这类可重复的用户操作都合适。连续拖动桌面窗口的系统手势则不一定:在不同设备和 Shell 实现上难以稳定复现,此时应改用固定环境的脚本化操作,或者手工操作时采集 Perfetto,并记录每次窗口边界。
每组性能数据都要能还原现场:设备、Android 源码标签或系统构建、目标 SDK、Material 3 Adaptive/Compose 版本、窗口尺寸、密度、displayId、刷新率、温度、编译模式、基线配置文件(Baseline Profile)和输入脚本都得记。单拿一组平均帧率,解释不了跨断点或调整窗口时的短时异常。
3. FrameTimeline 要按 SurfaceFrame 和 DisplayFrame 读取
窗口调整的问题,我们最终要落到 FrameTimeline 上看。这段 PerfettoSQL 比较目标应用窗口的 Expected 和 Actual SurfaceFrame:Expected 是系统预期的帧时间窗,Actual 是实际执行记录。$target_upid 是 Perfetto process.upid 的唯一进程实例标识,与操作系统 pid 要区分开:可先查询 process 表核对 pid、名称与生命周期,$layer_glob 是目标窗口图层的匹配模式;两者必须依据本次轨迹确定,仅凭包名模糊匹配整个系统是不够的。
WITH actual_app AS (
SELECT
id AS actual_slice_id,
upid,
surface_frame_token,
display_frame_token,
layer_name,
ts AS actual_start_ns,
ts + dur AS actual_end_ns,
jank_type,
present_type
FROM actual_frame_timeline_slice
WHERE upid = $target_upid
AND surface_frame_token > 0
AND layer_name GLOB $layer_glob
AND dur >= 0
),
expected_app AS (
SELECT
id AS expected_slice_id,
upid,
surface_frame_token,
layer_name,
ts AS expected_start_ns,
ts + dur AS expected_end_ns
FROM expected_frame_timeline_slice
WHERE upid = $target_upid
AND surface_frame_token > 0
AND layer_name GLOB $layer_glob
AND dur >= 0
)
SELECT
a.actual_slice_id,
e.expected_slice_id,
a.surface_frame_token,
a.display_frame_token,
a.layer_name,
e.expected_start_ns,
e.expected_end_ns,
a.actual_start_ns,
a.actual_end_ns,
a.actual_end_ns - e.expected_end_ns AS overrun_ns,
a.jank_type,
a.present_type
FROM actual_app AS a
LEFT JOIN expected_app AS e
USING (upid, surface_frame_token, layer_name)
ORDER BY a.actual_start_ns;
查询先筛掉 dur < 0 的未闭合记录;这些记录的数量应另计作采集质量。Expected 关联键 (upid, surface_frame_token, layer_name) 应先核查唯一性,重复键要单独列出来看。expected_slice_id / overrun_ns 为 NULL 表示未找到同层完整 Expected,这类行要按数据缺失记录,按时或零超期都还只是猜测。overrun_ns > 0 才表示该已匹配 Actual 的结束时间晚于 Expected 截止时间,单位是纳秒,数值本身仍不说明原因。往下查分两条路:应用 SurfaceFrame 异常,查 UI 线程、RenderThread、GPU、缓冲区和同步栅栏;应用按时,而 display_frame_token 对应的 DisplayFrame 异常,再查 WMS/Shell 事务、SurfaceFlinger、客户端合成、HWC 与目标屏幕的呈现时间。

图:关联键先查唯一性;缺失与重复要如实记录,时间差为正也只是归因的起点。
多窗口、多进程时,查询结果要按窗口和进程分别看:同一进程有多个窗口,分别查各自的图层、ViewRootImpl 和缓冲区队列;同一显示设备有多个进程,分别确认每个应用是否按时提交。surface_frame_token 与 display_frame_token 是关联应用帧和显示帧的标识;显示帧 token 的值可能为 0 或暂不可关联;这个 token 本身代表不了某块屏幕的实际呈现时间,要和缓冲区事务 BufferTX、图层快照、呈现栅栏放在一起看,才构成完整依据——单看某个时间片名称,定位不了责任模块。
4. 调整窗口轨迹的分析顺序
- 记录异常发生时的
displayId、窗口边界与目标呈现时间。 - 列出可见窗口、转场控制 Surface、窗口标题栏、IME 和 SurfaceFlinger 图层。
- 为每个窗口标明进程编号、UI 线程编号、RenderThread、
ViewRootImpl与 BLAST 图层。 - 查看 Compose/View 是否发生组合、测量、布局或绘制,以及是否伴随业务计算、GC 或同步 I/O。
- 按时间戳对应 Configuration 变化、
relayout()、窗口容器事务(WCT)、WMS 同步和 Shell 转场。 - 确认窗口几何事务和应用窗口缓冲区分别在哪个
DisplayFrame生效。 - 检查获取、释放和呈现同步栅栏,以及 HWC 合成策略。
- 找到最早偏离目标时间线的对象,再决定优化应用、Shell/WMS、SurfaceFlinger/HWC 或设备驱动。
十、常见误判
这一节把容易踩的场景集中起来对照:左列是现场现象,中列是先查的对象,右列提醒我们哪些结论容易下得太早,判定前要回到对应小节再核对。
| 现象 | 先查什么 | 不能直接下的结论 |
|---|---|---|
| 跨过 840dp 时卡一下 | 窗格数量、组合与测量、图片请求、过渡动画 | WindowSizeClass 分类计算很重 |
| 断点内拖动仍持续测量 | 父约束、文本换行、Lazy 可见项、Insets | 尺寸类别没变就不应布局 |
双窗格页面只有一个 DrawFrame | 是否只是同一窗口内两个普通窗格 | 系统漏画了第二个窗口 |
| Dialog 出现后主窗口延迟 | 同一 UI 回调队列的顺序、共享 RenderThread、显示图层集合 | Dialog 面积小,不影响主窗口 |
queueBuffer() 按时 | 获取栅栏、SurfaceFlinger 是否已采纳缓冲区、DisplayFrame、呈现时间 | 用户已经看到该帧 |
| 外接屏慢、内屏正常 | displayId、显示模式、HWC、每屏呈现栅栏 | 同进程窗口应同时显示 |
| 折叠后详情重新加载 | 内容状态所有者、组合位置、保存状态键 | FoldingFeature 自动清空状态 |
| 4K 显示器 GPU 升高 | 实际缓冲区、窗口面积、重叠绘制、CLIENT 合成 | 分辨率必然带来固定四倍耗时 |
| 非焦点窗口仍在刷新 | 生命周期、top-resumed、可见性和业务策略 | onPause() 一定会停止它 |
| 面向 API 37 时大屏方向变化 | 固定方向限制被忽略、Activity 重建、资源与 Insets | Android 17 更换了显示管线 |
十一、提交前检查清单
-
平台结论固定到 Android 17 / API 37 /
android-17.0.0_r1 -
内核结论固定到
android17-6.18-2026-06_r6 - 记录 Compose UI、Material 3 Adaptive 与 WindowManager 的独立版本
-
页面按当前窗口分类,不使用
isTablet代替窗口信息 - 已明确选择是否启用五档宽度分类,没有把 Large/Extra-large 误算进默认三档
- Compact 高度下的横屏和矮窗口有单独验收
- 页面级布局指令只在上层计算,组件级约束留给组件处理
-
BoxWithConstraints内没有数据库、网络、同步解码或高成本解析 - 窗格切换不清空选中项、表单、滚动位置和导航历史
-
FoldingFeature没有被当成连续铰链角度 - WindowInfoTracker 订阅与当前 Activity 生命周期绑定
- 图片请求按展示目标、可见性和缓存容量设计
- 多窗口任务综合生命周期、top-resumed、输入焦点与业务意图
- 已测试 Android 17 上面向 API 37 时的方向、宽高比和尺寸调整行为
- 调整窗口的轨迹区分几何事务、缓冲区提交与显示呈现
- 多窗口标出进程、线程、ViewRoot 和图层,多显示设备分别看呈现栅栏
- 视觉测试、Macrobenchmark 和 Perfetto 各自回答对应问题
全文小结
自适应布局的输入是当前窗口、设备姿态、Insets 和输入能力;设备名称,以及应用启动时记下的那一次屏幕宽度,都代替不了这些实时输入。页面入口负责把连续的环境信息收敛成离散布局指令,组件再依据局部约束完成布局;内容状态应当独立持有,避免随布局模式切换而丢失或重复加载。
性能验收也要分层:应用侧区分组合、测量与布局,系统侧区分 WMS/Shell 几何变化、应用缓冲区提交和各显示设备的呈现。测试矩阵至少覆盖断点两侧、连续拖拽、多窗口、多实例、多显示设备以及状态恢复。
参考资料
- Android 16:自适应布局行为变化
- Android 17:方向、可调整大小和宽高比限制被忽略
- Android 17 target 37 行为变更
- AndroidX 当前版本
- 窗口尺寸分类
- Adaptive layout 入门
- List-detail canonical layout
- 折叠屏窗口特征
FoldingFeatureAPIWindowInfoTrackerAPI- 桌面窗口支持
- Material 3 Adaptive 1.2.0 发布说明
- WindowManager 1.5.1 发布说明
- Perfetto FrameTimeline
WindowAdaptiveInfo.kt(Material 3 Adaptive 1.2.0)PaneScaffoldDirective.kt(Material 3 Adaptive 1.2.0)ThreePaneScaffoldNavigator.kt(Material 3 Adaptive 1.2.0)FoldingFeature.kt(WindowManager 1.5.1)WindowInfoTracker.kt(WindowManager 1.5.1)ViewRootImpl.java(Android 17)TaskFragment.java(Android 17):浮动与嵌入窗口重算 smallestScreenWidthDp 的配置路径。View.java(Android 17) 与Activity.java(Android 17):拖放 flag 前提、系统选择与 requested orientation 读取边界。- Perfetto process 表:upid 与操作系统 pid 的区分。
Choreographer.java(Android 17)RenderThread.cpp(Android 17)BLASTSyncEngine.java(Android 17)BLASTBufferQueue.cpp(Android 17)SurfaceFlinger.cpp(Android 17)HWComposer.cpp(Android 17)kernel/sched/core.c(android17-6.18-2026-06_r6)dma-fence.c(android17-6.18-2026-06_r6)